feat: add Tauri v2 desktop crate with React frontend (Phase 1 skeleton)
Rust backend (wifi-densepose-desktop): - 14 Tauri commands across 6 groups: discovery, flash, OTA, WASM, server, provision - Domain types: Node, NodeRegistry, FlashSession, OtaSession, BatchOtaSession - AppState with DiscoveryState and ServerState behind Mutex - Workspace Cargo.toml updated with new member - cargo check passes cleanly React/TypeScript frontend: - TypeScript types matching Rust domain model - Hooks: useNodes (discovery polling), useServer (start/stop/status) - Components: StatusBadge, NodeCard, Sidebar - Pages: Dashboard, Nodes (table + expandable details), FlashFirmware (3-step wizard with progress bar), Settings (server/security/discovery) - App.tsx with sidebar navigation routing - Vite 6 + React 18 + @tauri-apps/api v2 Implements ADR-052 Phase 1 skeleton. All commands return stub data. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
79aaf2d217
commit
cab98df34a
File diff suppressed because it is too large
Load Diff
|
|
@ -16,6 +16,7 @@ members = [
|
|||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
"crates/wifi-densepose-desktop",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "wifi-densepose-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Tauri v2 desktop frontend for RuView WiFi DensePose"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"identifier": "default",
|
||||
"description": "RuView default capability set",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
Binary file not shown.
|
After Width: | Height: | Size: 121 B |
|
|
@ -0,0 +1,34 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::domain::node::DiscoveredNode;
|
||||
|
||||
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
|
||||
#[tauri::command]
|
||||
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
|
||||
let _timeout = timeout_ms.unwrap_or(3000);
|
||||
// Stub: return placeholder data
|
||||
Ok(vec![DiscoveredNode {
|
||||
ip: "192.168.1.100".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
hostname: Some("ruview-node-1".into()),
|
||||
node_id: 1,
|
||||
firmware_version: Some("0.3.0".into()),
|
||||
health: crate::domain::node::HealthStatus::Online,
|
||||
last_seen: chrono::Utc::now().to_rfc3339(),
|
||||
}])
|
||||
}
|
||||
|
||||
/// List available serial ports on this machine.
|
||||
#[tauri::command]
|
||||
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
||||
// Stub: return empty list
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SerialPortInfo {
|
||||
pub name: String,
|
||||
pub vid: Option<u16>,
|
||||
pub pid: Option<u16>,
|
||||
pub manufacturer: Option<String>,
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Flash firmware binary to an ESP32 via serial port.
|
||||
#[tauri::command]
|
||||
pub async fn flash_firmware(
|
||||
port: String,
|
||||
firmware_path: String,
|
||||
chip: Option<String>,
|
||||
baud: Option<u32>,
|
||||
) -> Result<FlashResult, String> {
|
||||
let _ = (port, firmware_path, chip, baud);
|
||||
// Stub: return placeholder result
|
||||
Ok(FlashResult {
|
||||
success: true,
|
||||
message: "Stub: flash not yet implemented".into(),
|
||||
duration_secs: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current flash progress (stub for polling-based approach).
|
||||
#[tauri::command]
|
||||
pub async fn flash_progress() -> Result<FlashProgress, String> {
|
||||
Ok(FlashProgress {
|
||||
phase: "idle".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub duration_secs: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashProgress {
|
||||
pub phase: String,
|
||||
pub progress_pct: f32,
|
||||
pub bytes_written: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
pub mod discovery;
|
||||
pub mod flash;
|
||||
pub mod ota;
|
||||
pub mod provision;
|
||||
pub mod server;
|
||||
pub mod wasm;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Push firmware to a single node via HTTP OTA (port 8032).
|
||||
#[tauri::command]
|
||||
pub async fn ota_update(
|
||||
node_ip: String,
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
) -> Result<OtaResult, String> {
|
||||
let _ = (node_ip, firmware_path, psk);
|
||||
Ok(OtaResult {
|
||||
success: true,
|
||||
node_ip: "stub".into(),
|
||||
message: "Stub: OTA not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Push firmware to multiple nodes with rolling update strategy.
|
||||
#[tauri::command]
|
||||
pub async fn batch_ota_update(
|
||||
node_ips: Vec<String>,
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
) -> Result<Vec<OtaResult>, String> {
|
||||
let _ = (firmware_path, psk);
|
||||
Ok(node_ips
|
||||
.into_iter()
|
||||
.map(|ip| OtaResult {
|
||||
success: true,
|
||||
node_ip: ip,
|
||||
message: "Stub: batch OTA not yet implemented".into(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OtaResult {
|
||||
pub success: bool,
|
||||
pub node_ip: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::config::ProvisioningConfig;
|
||||
|
||||
/// Provision NVS configuration to an ESP32 via serial port.
|
||||
#[tauri::command]
|
||||
pub async fn provision_node(
|
||||
port: String,
|
||||
config: ProvisioningConfig,
|
||||
) -> Result<ProvisionResult, String> {
|
||||
let _ = (port, config);
|
||||
Ok(ProvisionResult {
|
||||
success: true,
|
||||
message: "Stub: provisioning not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read current NVS configuration from a connected ESP32.
|
||||
#[tauri::command]
|
||||
pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
|
||||
let _ = port;
|
||||
Ok(ProvisioningConfig::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvisionResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Start the sensing server as a managed child process.
|
||||
#[tauri::command]
|
||||
pub async fn start_server(
|
||||
config: ServerConfig,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let _ = config;
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
srv.running = true;
|
||||
srv.pid = Some(0); // Stub PID
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the managed sensing server process.
|
||||
#[tauri::command]
|
||||
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
srv.running = false;
|
||||
srv.pid = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get sensing server status.
|
||||
#[tauri::command]
|
||||
pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusResponse, String> {
|
||||
let srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
Ok(ServerStatusResponse {
|
||||
running: srv.running,
|
||||
pid: srv.pid,
|
||||
http_port: None,
|
||||
ws_port: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub http_port: Option<u16>,
|
||||
pub ws_port: Option<u16>,
|
||||
pub udp_port: Option<u16>,
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServerStatusResponse {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
pub http_port: Option<u16>,
|
||||
pub ws_port: Option<u16>,
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// List WASM modules loaded on a specific node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_list(node_ip: String) -> Result<Vec<WasmModuleInfo>, String> {
|
||||
let _ = node_ip;
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Upload a WASM module to a node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_upload(
|
||||
node_ip: String,
|
||||
wasm_path: String,
|
||||
) -> Result<WasmUploadResult, String> {
|
||||
let _ = (node_ip, wasm_path);
|
||||
Ok(WasmUploadResult {
|
||||
success: true,
|
||||
module_id: "stub-module-0".into(),
|
||||
message: "Stub: WASM upload not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start, stop, or unload a WASM module on a node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_control(
|
||||
node_ip: String,
|
||||
module_id: String,
|
||||
action: String,
|
||||
) -> Result<(), String> {
|
||||
let _ = (node_ip, module_id, action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WasmModuleInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub size_bytes: u64,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WasmUploadResult {
|
||||
pub success: bool,
|
||||
pub module_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// NVS provisioning configuration for a single ESP32 node.
|
||||
/// Maps to the firmware's nvs_config_t struct.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProvisioningConfig {
|
||||
pub wifi_ssid: Option<String>,
|
||||
pub wifi_password: Option<String>,
|
||||
pub target_ip: Option<String>,
|
||||
pub target_port: Option<u16>,
|
||||
pub node_id: Option<u8>,
|
||||
pub tdm_slot: Option<u8>,
|
||||
pub tdm_total: Option<u8>,
|
||||
pub edge_tier: Option<u8>,
|
||||
pub presence_thresh: Option<u16>,
|
||||
pub fall_thresh: Option<u16>,
|
||||
pub vital_window: Option<u16>,
|
||||
pub vital_interval_ms: Option<u16>,
|
||||
pub top_k_count: Option<u8>,
|
||||
pub hop_count: Option<u8>,
|
||||
pub channel_list: Option<Vec<u8>>,
|
||||
pub dwell_ms: Option<u32>,
|
||||
pub power_duty: Option<u8>,
|
||||
pub wasm_max_modules: Option<u8>,
|
||||
pub wasm_verify: Option<bool>,
|
||||
pub ota_psk: Option<String>,
|
||||
}
|
||||
|
||||
impl ProvisioningConfig {
|
||||
/// Validate invariants:
|
||||
/// - tdm_slot < tdm_total when both set
|
||||
/// - channel_list.len() == hop_count when both set
|
||||
/// - 10 <= power_duty <= 100
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let (Some(slot), Some(total)) = (self.tdm_slot, self.tdm_total) {
|
||||
if slot >= total {
|
||||
return Err(format!(
|
||||
"tdm_slot ({}) must be less than tdm_total ({})",
|
||||
slot, total
|
||||
));
|
||||
}
|
||||
}
|
||||
if let (Some(ref channels), Some(hops)) = (&self.channel_list, self.hop_count) {
|
||||
if channels.len() != hops as usize {
|
||||
return Err(format!(
|
||||
"channel_list length ({}) must equal hop_count ({})",
|
||||
channels.len(),
|
||||
hops
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(duty) = self.power_duty {
|
||||
if !(10..=100).contains(&duty) {
|
||||
return Err(format!(
|
||||
"power_duty ({}) must be between 10 and 100",
|
||||
duty
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh-level configuration that generates per-node ProvisioningConfig instances.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
pub common: ProvisioningConfig,
|
||||
pub nodes: Vec<MeshNodeEntry>,
|
||||
}
|
||||
|
||||
/// Per-node override within a mesh configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshNodeEntry {
|
||||
pub port: String,
|
||||
pub node_id: u8,
|
||||
pub tdm_slot: u8,
|
||||
}
|
||||
|
||||
impl MeshConfig {
|
||||
/// Generate a ProvisioningConfig for a specific mesh node,
|
||||
/// merging common settings with per-node overrides.
|
||||
pub fn config_for_node(&self, entry: &MeshNodeEntry) -> ProvisioningConfig {
|
||||
let mut cfg = self.common.clone();
|
||||
cfg.node_id = Some(entry.node_id);
|
||||
cfg.tdm_slot = Some(entry.tdm_slot);
|
||||
cfg.tdm_total = Some(self.nodes.len() as u8);
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A firmware binary to be flashed or OTA-pushed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FirmwareBinary {
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub version: Option<String>,
|
||||
pub chip_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Lifecycle of a serial flash operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashPhase {
|
||||
Connecting,
|
||||
Erasing,
|
||||
Writing,
|
||||
Verifying,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A serial flash session aggregate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashSession {
|
||||
pub id: String,
|
||||
pub port: String,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub phase: FlashPhase,
|
||||
pub bytes_written: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
||||
/// Lifecycle of an OTA update.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OtaPhase {
|
||||
Uploading,
|
||||
Rebooting,
|
||||
Verifying,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// An OTA update session aggregate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OtaSession {
|
||||
pub id: String,
|
||||
pub target_ip: String,
|
||||
pub target_mac: Option<String>,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub phase: OtaPhase,
|
||||
pub bytes_uploaded: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
||||
/// Strategy for batch OTA updates across a mesh.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OtaStrategy {
|
||||
Sequential,
|
||||
TdmSafe,
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// A batch OTA session coordinating updates across multiple nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchOtaSession {
|
||||
pub id: String,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub strategy: OtaStrategy,
|
||||
pub max_concurrent: usize,
|
||||
pub node_count: usize,
|
||||
pub completed: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod config;
|
||||
pub mod firmware;
|
||||
pub mod node;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// MAC address value object (e.g., "AA:BB:CC:DD:EE:FF").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MacAddress(pub String);
|
||||
|
||||
impl MacAddress {
|
||||
pub fn new(addr: impl Into<String>) -> Self {
|
||||
Self(addr.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MacAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Node health status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HealthStatus {
|
||||
Online,
|
||||
Offline,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
impl Default for HealthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Offline
|
||||
}
|
||||
}
|
||||
|
||||
/// A discovered ESP32 CSI node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredNode {
|
||||
pub ip: String,
|
||||
pub mac: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub node_id: u8,
|
||||
pub firmware_version: Option<String>,
|
||||
pub health: HealthStatus,
|
||||
pub last_seen: String,
|
||||
}
|
||||
|
||||
/// Aggregate root: maintains the set of all known nodes, keyed by MAC.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NodeRegistry {
|
||||
nodes: std::collections::HashMap<MacAddress, DiscoveredNode>,
|
||||
}
|
||||
|
||||
impl NodeRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Insert or update a node. Deduplicates by MAC address.
|
||||
pub fn upsert(&mut self, mac: MacAddress, node: DiscoveredNode) {
|
||||
self.nodes.insert(mac, node);
|
||||
}
|
||||
|
||||
/// Get a node by MAC address.
|
||||
pub fn get(&self, mac: &MacAddress) -> Option<&DiscoveredNode> {
|
||||
self.nodes.get(mac)
|
||||
}
|
||||
|
||||
/// List all known nodes.
|
||||
pub fn all(&self) -> Vec<&DiscoveredNode> {
|
||||
self.nodes.values().collect()
|
||||
}
|
||||
|
||||
/// Number of registered nodes.
|
||||
pub fn len(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod state;
|
||||
|
||||
use commands::{discovery, flash, ota, provision, server, wasm};
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(state::AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Discovery
|
||||
discovery::discover_nodes,
|
||||
discovery::list_serial_ports,
|
||||
// Flash
|
||||
flash::flash_firmware,
|
||||
flash::flash_progress,
|
||||
// OTA
|
||||
ota::ota_update,
|
||||
ota::batch_ota_update,
|
||||
// WASM
|
||||
wasm::wasm_list,
|
||||
wasm::wasm_upload,
|
||||
wasm::wasm_control,
|
||||
// Server
|
||||
server::start_server,
|
||||
server::stop_server,
|
||||
server::server_status,
|
||||
// Provision
|
||||
provision::provision_node,
|
||||
provision::read_nvs,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
wifi_densepose_desktop::run();
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
use std::sync::Mutex;
|
||||
|
||||
use crate::domain::node::DiscoveredNode;
|
||||
|
||||
/// Sub-state for discovered nodes.
|
||||
#[derive(Default)]
|
||||
pub struct DiscoveryState {
|
||||
pub nodes: Vec<DiscoveredNode>,
|
||||
}
|
||||
|
||||
/// Sub-state for the managed sensing server process.
|
||||
#[derive(Default)]
|
||||
pub struct ServerState {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Top-level application state managed by Tauri.
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
pub discovery: Mutex<DiscoveryState>,
|
||||
pub server: Mutex<ServerState>,
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "RuView Desktop",
|
||||
"version": "0.3.0",
|
||||
"identifier": "net.ruv.ruview",
|
||||
"build": {
|
||||
"frontendDist": "../ui/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../ui && npm run dev",
|
||||
"beforeBuildCommand": "cd ../ui && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "RuView Desktop",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RuView Desktop</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "ruview-desktop-ui",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useState } from "react";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import { Nodes } from "./pages/Nodes";
|
||||
import { FlashFirmware } from "./pages/FlashFirmware";
|
||||
import { Settings } from "./pages/Settings";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "nodes"
|
||||
| "flash"
|
||||
| "ota"
|
||||
| "wasm"
|
||||
| "sensing"
|
||||
| "mesh"
|
||||
| "settings";
|
||||
|
||||
interface NavItem {
|
||||
id: Page;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", shortcut: "D" },
|
||||
{ id: "nodes", label: "Nodes", shortcut: "N" },
|
||||
{ id: "flash", label: "Flash", shortcut: "F" },
|
||||
{ id: "ota", label: "OTA", shortcut: "O" },
|
||||
{ id: "wasm", label: "WASM", shortcut: "W" },
|
||||
{ id: "sensing", label: "Sensing", shortcut: "S" },
|
||||
{ id: "mesh", label: "Mesh", shortcut: "M" },
|
||||
{ id: "settings", label: "Settings", shortcut: "G" },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh" }}>
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
style={{
|
||||
width: 200,
|
||||
background: "#1e293b",
|
||||
borderRight: "1px solid #334155",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0 16px 16px",
|
||||
borderBottom: "1px solid #334155",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
RuView
|
||||
</h1>
|
||||
<span style={{ fontSize: 11, color: "#64748b" }}>
|
||||
WiFi DensePose Desktop
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActivePage(item.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "10px 16px",
|
||||
border: "none",
|
||||
background:
|
||||
activePage === item.id ? "#334155" : "transparent",
|
||||
color:
|
||||
activePage === item.id ? "#f1f5f9" : "#94a3b8",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
textAlign: "left",
|
||||
borderLeft:
|
||||
activePage === item.id
|
||||
? "3px solid #38bdf8"
|
||||
: "3px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
background:
|
||||
activePage === item.id ? "#38bdf8" : "#475569",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color:
|
||||
activePage === item.id ? "#0f172a" : "#94a3b8",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 11,
|
||||
color: "#475569",
|
||||
borderTop: "1px solid #334155",
|
||||
}}
|
||||
>
|
||||
v0.3.0
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
||||
{activePage === "dashboard" && <Dashboard />}
|
||||
{activePage === "nodes" && <Nodes />}
|
||||
{activePage === "flash" && <FlashFirmware />}
|
||||
{activePage === "settings" && <Settings />}
|
||||
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: 24, marginBottom: 8 }}>
|
||||
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
|
||||
</h2>
|
||||
<p style={{ color: "#64748b" }}>
|
||||
This page is not yet implemented.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import type { Node } from "../types";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface NodeCardProps {
|
||||
node: Node;
|
||||
onClick?: (node: Node) => void;
|
||||
}
|
||||
|
||||
function formatUptime(secs: number | null): string {
|
||||
if (secs == null) return "--";
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
||||
return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
function formatLastSeen(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - d.getTime();
|
||||
if (diffMs < 60_000) return "just now";
|
||||
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||
const isOnline = node.health === "online";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node)}
|
||||
style={{
|
||||
background: "var(--card-bg, #1e1e2e)",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
opacity: isOnline ? 1 : 0.6,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--accent, #6366f1)";
|
||||
e.currentTarget.style.boxShadow = "0 0 0 1px var(--accent, #6366f1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border, #2e2e3e)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={node.health} />
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "8px 16px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
|
||||
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} />
|
||||
<DetailRow label="Chip" value={node.chip.toUpperCase()} />
|
||||
<DetailRow label="Role" value={node.mesh_role} />
|
||||
<DetailRow
|
||||
label="TDM"
|
||||
value={
|
||||
node.tdm_slot != null && node.tdm_total != null
|
||||
? `${node.tdm_slot}/${node.tdm_total}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
/>
|
||||
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} />
|
||||
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-muted, #64748b)",
|
||||
fontSize: "10px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: "1px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontFamily: mono ? "monospace" : "inherit",
|
||||
fontSize: "12px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { type ReactNode } from "react";
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
items: NavItem[];
|
||||
activeId: string;
|
||||
onNavigate: (id: string) => void;
|
||||
}
|
||||
|
||||
// Minimal SVG icons to avoid external dependency
|
||||
const ICONS: Record<string, ReactNode> = {
|
||||
dashboard: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
nodes: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<circle cx="5" cy="19" r="3" />
|
||||
<circle cx="19" cy="19" r="3" />
|
||||
<line x1="12" y1="8" x2="5" y2="16" />
|
||||
<line x1="12" y1="8" x2="19" y2="16" />
|
||||
</svg>
|
||||
),
|
||||
flash: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
server: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<line x1="6" y1="6" x2="6.01" y2="6" />
|
||||
<line x1="6" y1="18" x2="6.01" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export const DEFAULT_NAV_ITEMS: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: ICONS.dashboard },
|
||||
{ id: "nodes", label: "Nodes", icon: ICONS.nodes },
|
||||
{ id: "flash", label: "Flash", icon: ICONS.flash },
|
||||
{ id: "server", label: "Server", icon: ICONS.server },
|
||||
{ id: "settings", label: "Settings", icon: ICONS.settings },
|
||||
];
|
||||
|
||||
export function Sidebar({ items, activeId, onNavigate }: SidebarProps) {
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
width: "200px",
|
||||
minWidth: "200px",
|
||||
height: "100%",
|
||||
background: "var(--sidebar-bg, #12121a)",
|
||||
borderRight: "1px solid var(--border, #2e2e3e)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
{/* App title */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0 20px 20px",
|
||||
fontSize: "18px",
|
||||
fontWeight: 800,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
RuView
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1 }}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "10px 20px",
|
||||
border: "none",
|
||||
background: isActive
|
||||
? "var(--accent-muted, rgba(99, 102, 241, 0.12))"
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? "var(--accent, #6366f1)"
|
||||
: "var(--text-secondary, #94a3b8)",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: "left",
|
||||
borderLeft: isActive
|
||||
? "3px solid var(--accent, #6366f1)"
|
||||
: "3px solid transparent",
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background =
|
||||
"var(--hover-bg, rgba(255,255,255,0.04))";
|
||||
e.currentTarget.style.color = "var(--text-primary, #e2e8f0)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
e.currentTarget.style.color = "var(--text-secondary, #94a3b8)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
fontSize: "10px",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
}}
|
||||
>
|
||||
v0.3.0
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { HealthStatus } from "../types";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: HealthStatus;
|
||||
/** Optional size variant. Default: "sm" */
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<HealthStatus, { bg: string; text: string; label: string }> = {
|
||||
online: {
|
||||
bg: "rgba(34, 197, 94, 0.15)",
|
||||
text: "#22c55e",
|
||||
label: "Online",
|
||||
},
|
||||
offline: {
|
||||
bg: "rgba(239, 68, 68, 0.15)",
|
||||
text: "#ef4444",
|
||||
label: "Offline",
|
||||
},
|
||||
degraded: {
|
||||
bg: "rgba(234, 179, 8, 0.15)",
|
||||
text: "#eab308",
|
||||
label: "Degraded",
|
||||
},
|
||||
unknown: {
|
||||
bg: "rgba(148, 163, 184, 0.15)",
|
||||
text: "#94a3b8",
|
||||
label: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
const SIZE_STYLES: Record<string, { fontSize: string; padding: string; dot: string }> = {
|
||||
sm: { fontSize: "11px", padding: "2px 8px", dot: "6px" },
|
||||
md: { fontSize: "13px", padding: "4px 12px", dot: "8px" },
|
||||
lg: { fontSize: "15px", padding: "6px 16px", dot: "10px" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
||||
const style = STATUS_STYLES[status];
|
||||
const sizeStyle = SIZE_STYLES[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
backgroundColor: style.bg,
|
||||
color: style.text,
|
||||
fontSize: sizeStyle.fontSize,
|
||||
fontWeight: 600,
|
||||
padding: sizeStyle.padding,
|
||||
borderRadius: "9999px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: sizeStyle.dot,
|
||||
height: sizeStyle.dot,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: style.text,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Node } from "../types";
|
||||
|
||||
interface UseNodesOptions {
|
||||
/** Auto-poll interval in milliseconds. Set to 0 to disable. Default: 10000 */
|
||||
pollInterval?: number;
|
||||
/** Whether to start scanning on mount. Default: false */
|
||||
autoScan?: boolean;
|
||||
}
|
||||
|
||||
interface UseNodesReturn {
|
||||
nodes: Node[];
|
||||
isScanning: boolean;
|
||||
error: string | null;
|
||||
scan: () => Promise<void>;
|
||||
/** Total nodes discovered */
|
||||
total: number;
|
||||
/** Nodes currently online */
|
||||
onlineCount: number;
|
||||
/** Nodes currently offline */
|
||||
offlineCount: number;
|
||||
}
|
||||
|
||||
export function useNodes(options: UseNodesOptions = {}): UseNodesReturn {
|
||||
const { pollInterval = 10_000, autoScan = false } = options;
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const discovered = await invoke<Node[]>("discover_nodes", {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
setNodes(discovered);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
}, [isScanning]);
|
||||
|
||||
// Auto-scan on mount if requested
|
||||
useEffect(() => {
|
||||
if (autoScan) {
|
||||
scan();
|
||||
}
|
||||
}, [autoScan]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Polling interval
|
||||
useEffect(() => {
|
||||
if (pollInterval <= 0) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
scan();
|
||||
}, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollInterval]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onlineCount = nodes.filter(
|
||||
(n) => n.health === "online"
|
||||
).length;
|
||||
const offlineCount = nodes.filter(
|
||||
(n) => n.health === "offline"
|
||||
).length;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
isScanning,
|
||||
error,
|
||||
scan,
|
||||
total: nodes.length,
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { ServerConfig, ServerStatus } from "../types";
|
||||
|
||||
const DEFAULT_CONFIG: ServerConfig = {
|
||||
http_port: 8080,
|
||||
ws_port: 8765,
|
||||
udp_port: 5005,
|
||||
static_dir: null,
|
||||
model_dir: null,
|
||||
log_level: "info",
|
||||
};
|
||||
|
||||
interface UseServerOptions {
|
||||
/** Poll interval for status checks in ms. Default: 5000 */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
interface UseServerReturn {
|
||||
status: ServerStatus | null;
|
||||
isRunning: boolean;
|
||||
error: string | null;
|
||||
start: (config?: Partial<ServerConfig>) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useServer(options: UseServerOptions = {}): UseServerReturn {
|
||||
const { pollInterval = 5000 } = options;
|
||||
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await invoke<ServerStatus>("server_status");
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (overrides: Partial<ServerConfig> = {}) => {
|
||||
setError(null);
|
||||
const config: ServerConfig = { ...DEFAULT_CONFIG, ...overrides };
|
||||
try {
|
||||
await invoke("start_server", { config });
|
||||
// Allow the server a moment to start, then refresh status
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("stop_server");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// Initial status check
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
if (pollInterval <= 0) return;
|
||||
|
||||
intervalRef.current = setInterval(refresh, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollInterval, refresh]);
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
|
||||
return {
|
||||
status,
|
||||
isRunning,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
http_port: number | null;
|
||||
ws_port: number | null;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
setNodes(found);
|
||||
} catch (err) {
|
||||
console.error("Discovery failed:", err);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServerStatus = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const status = await invoke<ServerStatus>("server_status");
|
||||
setServerStatus(status);
|
||||
} catch (err) {
|
||||
console.error("Server status check failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScan();
|
||||
fetchServerStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 24 }}>Dashboard</h2>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: scanning ? "#475569" : "#38bdf8",
|
||||
color: "#0f172a",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: scanning ? "not-allowed" : "pointer",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Scan Network"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Server status panel */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1e293b",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
border: "1px solid #334155",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 14, color: "#94a3b8", marginBottom: 8 }}>
|
||||
Sensing Server
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: serverStatus?.running ? "#22c55e" : "#ef4444",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{serverStatus?.running
|
||||
? `Running (PID ${serverStatus.pid})`
|
||||
: "Stopped"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node grid */}
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "#94a3b8",
|
||||
marginBottom: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Discovered Nodes ({nodes.length})
|
||||
</h3>
|
||||
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "#1e293b",
|
||||
borderRadius: 8,
|
||||
padding: 32,
|
||||
textAlign: "center",
|
||||
color: "#64748b",
|
||||
border: "1px solid #334155",
|
||||
}}
|
||||
>
|
||||
No nodes discovered. Click "Scan Network" to search.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{nodes.map((node, i) => (
|
||||
<div
|
||||
key={node.mac || i}
|
||||
style={{
|
||||
background: "#1e293b",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
border: "1px solid #334155",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "start",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#64748b" }}>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background:
|
||||
node.health === "online" ? "#064e3b" : "#7f1d1d",
|
||||
color:
|
||||
node.health === "online" ? "#34d399" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{node.health}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: "#94a3b8" }}>
|
||||
<div>MAC: {node.mac || "unknown"}</div>
|
||||
<div>Firmware: {node.firmware_version || "unknown"}</div>
|
||||
<div>Node ID: {node.node_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -0,0 +1,615 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { SerialPort, Chip, FlashProgress, FlashPhase } from "../types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3;
|
||||
|
||||
export function FlashFirmware() {
|
||||
// --- State ---
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [selectedPort, setSelectedPort] = useState<string>("");
|
||||
const [firmwarePath, setFirmwarePath] = useState<string>("");
|
||||
const [chip, setChip] = useState<Chip>("esp32s3");
|
||||
const [baud, setBaud] = useState<number>(460800);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [progress, setProgress] = useState<FlashProgress | null>(null);
|
||||
const [isFlashing, setIsFlashing] = useState(false);
|
||||
const [flashResult, setFlashResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- Load serial ports ---
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<SerialPort[]>("list_serial_ports");
|
||||
setPorts(result);
|
||||
if (result.length === 1) {
|
||||
setSelectedPort(result[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
// --- Listen for flash progress events ---
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
listen<FlashProgress>("flash-progress", (event) => {
|
||||
setProgress(event.payload);
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- File picker ---
|
||||
const pickFirmware = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Firmware Binary", extensions: ["bin"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
setFirmwarePath(selected);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Flash ---
|
||||
const startFlash = async () => {
|
||||
if (!selectedPort || !firmwarePath) return;
|
||||
|
||||
setIsFlashing(true);
|
||||
setFlashResult(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await invoke("flash_firmware", {
|
||||
port: selectedPort,
|
||||
firmwarePath,
|
||||
chip,
|
||||
baud,
|
||||
});
|
||||
setFlashResult({
|
||||
success: true,
|
||||
message: "Firmware flashed successfully.",
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setFlashResult({ success: false, message: msg });
|
||||
} finally {
|
||||
setIsFlashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step validation ---
|
||||
const canProceed = (s: WizardStep): boolean => {
|
||||
if (s === 1) return selectedPort !== "";
|
||||
if (s === 2) return firmwarePath !== "";
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px", maxWidth: "700px" }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
margin: "0 0 4px",
|
||||
}}
|
||||
>
|
||||
Flash Firmware
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
Flash firmware to an ESP32 via serial connection
|
||||
</p>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator current={step} />
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
color: "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select Serial Port */}
|
||||
{step === 1 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitle}>Step 1: Select Serial Port</h2>
|
||||
<p style={stepDesc}>
|
||||
Connect your ESP32 via USB and select the serial port.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label style={labelStyle}>Serial Port</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<select
|
||||
value={selectedPort}
|
||||
onChange={(e) => setSelectedPort(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
disabled={isLoadingPorts}
|
||||
>
|
||||
<option value="">
|
||||
{isLoadingPorts
|
||||
? "Loading..."
|
||||
: ports.length === 0
|
||||
? "No ports detected"
|
||||
: "Select a port..."}
|
||||
</option>
|
||||
{ports.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.description ? ` - ${p.description}` : ""}
|
||||
{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={loadPorts} style={secondaryBtnStyle} disabled={isLoadingPorts}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!canProceed(1)}
|
||||
style={canProceed(1) ? primaryBtnStyle : disabledBtnStyle}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select Firmware */}
|
||||
{step === 2 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitle}>Step 2: Select Firmware</h2>
|
||||
<p style={stepDesc}>
|
||||
Choose the firmware binary file and chip configuration.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={firmwarePath}
|
||||
readOnly
|
||||
placeholder="No file selected"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button onClick={pickFirmware} style={secondaryBtnStyle}>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "16px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={labelStyle}>Chip</label>
|
||||
<select
|
||||
value={chip}
|
||||
onChange={(e) => setChip(e.target.value as Chip)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="esp32">ESP32</option>
|
||||
<option value="esp32s3">ESP32-S3</option>
|
||||
<option value="esp32c3">ESP32-C3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Baud Rate</label>
|
||||
<select
|
||||
value={baud}
|
||||
onChange={(e) => setBaud(Number(e.target.value))}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value={115200}>115200</option>
|
||||
<option value={230400}>230400</option>
|
||||
<option value={460800}>460800</option>
|
||||
<option value={921600}>921600</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button onClick={() => setStep(1)} style={secondaryBtnStyle}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!canProceed(2)}
|
||||
style={canProceed(2) ? primaryBtnStyle : disabledBtnStyle}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Flash */}
|
||||
{step === 3 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitle}>Step 3: Flash</h2>
|
||||
|
||||
{/* Summary */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.15)",
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "12px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<SummaryField label="Port" value={selectedPort} />
|
||||
<SummaryField
|
||||
label="Firmware"
|
||||
value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath}
|
||||
/>
|
||||
<SummaryField label="Chip" value={chip.toUpperCase()} />
|
||||
<SummaryField label="Baud" value={String(baud)} />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{(isFlashing || progress) && !flashResult && (
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<ProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{flashResult && (
|
||||
<div
|
||||
style={{
|
||||
background: flashResult.success
|
||||
? "rgba(34, 197, 94, 0.1)"
|
||||
: "rgba(239, 68, 68, 0.1)",
|
||||
border: `1px solid ${flashResult.success ? "rgba(34, 197, 94, 0.3)" : "rgba(239, 68, 68, 0.3)"}`,
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
color: flashResult.success ? "#86efac" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{flashResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(2);
|
||||
setFlashResult(null);
|
||||
setProgress(null);
|
||||
}}
|
||||
style={secondaryBtnStyle}
|
||||
disabled={isFlashing}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{flashResult ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(1);
|
||||
setFlashResult(null);
|
||||
setProgress(null);
|
||||
setFirmwarePath("");
|
||||
setSelectedPort("");
|
||||
}}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
Flash Another
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startFlash}
|
||||
disabled={isFlashing}
|
||||
style={isFlashing ? disabledBtnStyle : primaryBtnStyle}
|
||||
>
|
||||
{isFlashing ? "Flashing..." : "Start Flash"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepIndicator({ current }: { current: WizardStep }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Select Port" },
|
||||
{ n: 2, label: "Select Firmware" },
|
||||
{ n: 3, label: "Flash" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
{steps.map(({ n, label }, i) => {
|
||||
const isActive = n === current;
|
||||
const isDone = n < current;
|
||||
return (
|
||||
<div key={n} style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
background: isActive
|
||||
? "var(--accent, #6366f1)"
|
||||
: isDone
|
||||
? "rgba(34, 197, 94, 0.2)"
|
||||
: "var(--border, #2e2e3e)",
|
||||
color: isActive
|
||||
? "#fff"
|
||||
: isDone
|
||||
? "#22c55e"
|
||||
: "var(--text-muted, #64748b)",
|
||||
}}
|
||||
>
|
||||
{isDone ? "\u2713" : n}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive
|
||||
? "var(--text-primary, #e2e8f0)"
|
||||
: "var(--text-muted, #64748b)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "1px",
|
||||
background: "var(--border, #2e2e3e)",
|
||||
margin: "0 12px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<FlashPhase, string> = {
|
||||
connecting: "Connecting...",
|
||||
erasing: "Erasing flash...",
|
||||
writing: "Writing firmware...",
|
||||
verifying: "Verifying...",
|
||||
done: "Complete",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
function ProgressBar({ progress }: { progress: FlashProgress | null }) {
|
||||
const pct = progress?.progress_pct ?? 0;
|
||||
const phase = progress?.phase ?? "connecting";
|
||||
const speed = progress?.speed_bps ?? 0;
|
||||
const speedKB = speed > 0 ? `${(speed / 1024).toFixed(1)} KB/s` : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-secondary, #94a3b8)" }}>
|
||||
{PHASE_LABELS[phase]}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted, #64748b)" }}>
|
||||
{pct.toFixed(1)}% {speedKB && `| ${speedKB}`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "8px",
|
||||
background: "var(--border, #2e2e3e)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
phase === "error"
|
||||
? "#ef4444"
|
||||
: phase === "done"
|
||||
? "#22c55e"
|
||||
: "var(--accent, #6366f1)",
|
||||
borderRadius: "4px",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
marginBottom: "1px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--card-bg, #1e1e2e)",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
};
|
||||
|
||||
const stepTitle: React.CSSProperties = {
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
margin: "0 0 4px",
|
||||
};
|
||||
|
||||
const stepDesc: React.CSSProperties = {
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginBottom: "16px",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginBottom: "6px",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "6px",
|
||||
background: "var(--input-bg, #12121a)",
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 20px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
background: "var(--accent, #6366f1)",
|
||||
color: "#fff",
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const secondaryBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 16px",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "6px",
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const disabledBtnStyle: React.CSSProperties = {
|
||||
...primaryBtnStyle,
|
||||
background: "var(--border, #2e2e3e)",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
cursor: "not-allowed",
|
||||
};
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import { useState } from "react";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { Node } from "../types";
|
||||
|
||||
export function Nodes() {
|
||||
const { nodes, isScanning, scan, error } = useNodes({
|
||||
pollInterval: 10_000,
|
||||
autoScan: true,
|
||||
});
|
||||
const [expandedMac, setExpandedMac] = useState<string | null>(null);
|
||||
|
||||
const toggleExpand = (node: Node) => {
|
||||
const key = node.mac ?? node.ip;
|
||||
setExpandedMac((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px", maxWidth: "1200px" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Nodes
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={isScanning}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
background: isScanning
|
||||
? "var(--border, #2e2e3e)"
|
||||
: "var(--accent, #6366f1)",
|
||||
color: isScanning ? "var(--text-muted, #64748b)" : "#fff",
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
cursor: isScanning ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
color: "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card-bg, #1e1e2e)",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "8px",
|
||||
padding: "48px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card-bg, #1e1e2e)",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border, #2e2e3e)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Th>Status</Th>
|
||||
<Th>MAC</Th>
|
||||
<Th>IP</Th>
|
||||
<Th>Firmware</Th>
|
||||
<Th>Chip</Th>
|
||||
<Th>Last Seen</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node) => {
|
||||
const key = node.mac ?? node.ip;
|
||||
const isExpanded = expandedMac === key;
|
||||
return (
|
||||
<NodeRow
|
||||
key={key}
|
||||
node={node}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleExpand(node)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
mono = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontFamily: mono ? "monospace" : "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLastSeen(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: Node;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
borderBottom: isExpanded ? "none" : "1px solid var(--border, #2e2e3e)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "rgba(255,255,255,0.02)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "transparent")
|
||||
}
|
||||
>
|
||||
<Td>
|
||||
<StatusBadge status={node.health} />
|
||||
</Td>
|
||||
<Td mono>{node.mac ?? "--"}</Td>
|
||||
<Td mono>{node.ip}</Td>
|
||||
<Td>{node.firmware_version ?? "--"}</Td>
|
||||
<Td>{node.chip.toUpperCase()}</Td>
|
||||
<Td>{formatLastSeen(node.last_seen)}</Td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr style={{ borderBottom: "1px solid var(--border, #2e2e3e)" }}>
|
||||
<td colSpan={6} style={{ padding: "0 16px 16px" }}>
|
||||
<ExpandedDetails node={node} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDetails({ node }: { node: Node }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.15)",
|
||||
borderRadius: "6px",
|
||||
padding: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "12px 24px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<DetailField label="Hostname" value={node.hostname ?? "--"} />
|
||||
<DetailField label="Node ID" value={String(node.node_id)} />
|
||||
<DetailField label="Mesh Role" value={node.mesh_role} />
|
||||
<DetailField
|
||||
label="TDM Slot"
|
||||
value={
|
||||
node.tdm_slot != null && node.tdm_total != null
|
||||
? `${node.tdm_slot} / ${node.tdm_total}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Uptime"
|
||||
value={
|
||||
node.uptime_secs != null
|
||||
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<DetailField label="Discovery" value={node.discovery_method} />
|
||||
<DetailField
|
||||
label="Capabilities"
|
||||
value={
|
||||
node.capabilities
|
||||
? Object.entries(node.capabilities)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(", ") || "none"
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
{node.friendly_name && (
|
||||
<DetailField label="Name" value={node.friendly_name} />
|
||||
)}
|
||||
{node.notes && <DetailField label="Notes" value={node.notes} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-secondary, #94a3b8)" }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { AppSettings } from "../types";
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
server_http_port: 8080,
|
||||
server_ws_port: 8765,
|
||||
server_udp_port: 5005,
|
||||
bind_address: "0.0.0.0",
|
||||
ui_path: "",
|
||||
ota_psk: "",
|
||||
auto_discover: true,
|
||||
discover_interval_ms: 10_000,
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [showPsk, setShowPsk] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load persisted settings on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const persisted = await invoke<AppSettings | null>("get_settings");
|
||||
if (persisted) {
|
||||
setSettings(persisted);
|
||||
}
|
||||
} catch {
|
||||
// Settings command may not exist yet -- use defaults
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("save_settings", { settings });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px", maxWidth: "600px" }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
margin: "0 0 4px",
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
Configure server, network, and application preferences
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
color: "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved toast */}
|
||||
{saved && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.3)",
|
||||
borderRadius: "6px",
|
||||
padding: "12px 16px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "13px",
|
||||
color: "#86efac",
|
||||
}}
|
||||
>
|
||||
Settings saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensing Server */}
|
||||
<Section title="Sensing Server">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<Field label="HTTP Port">
|
||||
<NumberInput
|
||||
value={settings.server_http_port}
|
||||
onChange={(v) => update("server_http_port", v)}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="WebSocket Port">
|
||||
<NumberInput
|
||||
value={settings.server_ws_port}
|
||||
onChange={(v) => update("server_ws_port", v)}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="UDP Port">
|
||||
<NumberInput
|
||||
value={settings.server_udp_port}
|
||||
onChange={(v) => update("server_udp_port", v)}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bind Address">
|
||||
<TextInput
|
||||
value={settings.bind_address}
|
||||
onChange={(v) => update("bind_address", v)}
|
||||
placeholder="0.0.0.0"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<Field label="UI Static Files Path">
|
||||
<TextInput
|
||||
value={settings.ui_path}
|
||||
onChange={(v) => update("ui_path", v)}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Security */}
|
||||
<Section title="Security">
|
||||
<Field label="OTA Pre-Shared Key (PSK)">
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<input
|
||||
type={showPsk ? "text" : "password"}
|
||||
value={settings.ota_psk}
|
||||
onChange={(e) => update("ota_psk", e.target.value)}
|
||||
placeholder="Enter PSK for OTA authentication"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPsk((prev) => !prev)}
|
||||
style={secondaryBtnStyle}
|
||||
>
|
||||
{showPsk ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
Used for authenticating OTA firmware updates to nodes.
|
||||
</p>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Discovery */}
|
||||
<Section title="Network Discovery">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<Field label="Auto-Discover">
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.auto_discover}
|
||||
onChange={(e) => update("auto_discover", e.target.checked)}
|
||||
style={{ accentColor: "var(--accent, #6366f1)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
Enable periodic scanning
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Scan Interval (ms)">
|
||||
<NumberInput
|
||||
value={settings.discover_interval_ms}
|
||||
onChange={(v) => update("discover_interval_ms", v)}
|
||||
min={1000}
|
||||
max={120_000}
|
||||
step={1000}
|
||||
disabled={!settings.auto_discover}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "24px",
|
||||
}}
|
||||
>
|
||||
<button onClick={reset} style={secondaryBtnStyle}>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<button onClick={save} style={primaryBtnStyle}>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card-bg, #1e1e2e)",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
margin: "0 0 16px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (!isNaN(n)) onChange(n);
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "6px",
|
||||
background: "var(--input-bg, #12121a)",
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 20px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
background: "var(--accent, #6366f1)",
|
||||
color: "#fff",
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const secondaryBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 16px",
|
||||
border: "1px solid var(--border, #2e2e3e)",
|
||||
borderRadius: "6px",
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary, #94a3b8)",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// =============================================================================
|
||||
// types.ts — TypeScript types matching the Rust domain model for RuView
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node Discovery & Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MacAddress = string; // "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
export type HealthStatus = "online" | "offline" | "degraded" | "unknown";
|
||||
|
||||
export type DiscoveryMethod = "mdns" | "udp_probe" | "http_sweep" | "manual";
|
||||
|
||||
export type MeshRole = "coordinator" | "node" | "aggregator";
|
||||
|
||||
export type Chip = "esp32" | "esp32s3" | "esp32c3";
|
||||
|
||||
export interface TdmConfig {
|
||||
slot: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface NodeCapabilities {
|
||||
wasm: boolean;
|
||||
ota: boolean;
|
||||
csi: boolean;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
ip: string;
|
||||
mac: MacAddress | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
tdm_slot: number | null;
|
||||
tdm_total: number | null;
|
||||
edge_tier: number | null;
|
||||
uptime_secs: number | null;
|
||||
discovery_method: DiscoveryMethod;
|
||||
last_seen: string; // ISO 8601 datetime
|
||||
health: HealthStatus;
|
||||
chip: Chip;
|
||||
mesh_role: MeshRole;
|
||||
capabilities: NodeCapabilities | null;
|
||||
friendly_name: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Firmware Flashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FlashPhase =
|
||||
| "connecting"
|
||||
| "erasing"
|
||||
| "writing"
|
||||
| "verifying"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface FlashProgress {
|
||||
phase: FlashPhase;
|
||||
progress_pct: number; // 0.0 - 100.0
|
||||
bytes_written: number;
|
||||
bytes_total: number;
|
||||
speed_bps: number;
|
||||
}
|
||||
|
||||
export interface FirmwareBinary {
|
||||
path: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
chip: Chip | null;
|
||||
}
|
||||
|
||||
export interface FlashSession {
|
||||
port: string;
|
||||
firmware: FirmwareBinary;
|
||||
chip: Chip;
|
||||
baud: number;
|
||||
progress: FlashProgress | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FlashResult {
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
bytes_written: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ChipInfo {
|
||||
chip: Chip;
|
||||
mac: MacAddress;
|
||||
flash_size_bytes: number;
|
||||
crystal_freq_mhz: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTA Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OtaStrategy = "sequential" | "tdm_safe" | "parallel";
|
||||
|
||||
export type BatchNodeState =
|
||||
| "queued"
|
||||
| "uploading"
|
||||
| "rebooting"
|
||||
| "verifying"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
export interface OtaSession {
|
||||
node_ip: string;
|
||||
firmware_path: string;
|
||||
progress_pct: number;
|
||||
state: BatchNodeState;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BatchOtaSession {
|
||||
strategy: OtaStrategy;
|
||||
max_concurrent: number;
|
||||
batch_delay_secs: number;
|
||||
fail_fast: boolean;
|
||||
nodes: OtaSession[];
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface OtaResult {
|
||||
node_ip: string;
|
||||
success: boolean;
|
||||
previous_version: string | null;
|
||||
new_version: string | null;
|
||||
duration_ms: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface OtaStatus {
|
||||
current_version: string;
|
||||
partition: string;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM Modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WasmModuleState = "running" | "stopped" | "error" | "loading";
|
||||
|
||||
export interface WasmModule {
|
||||
module_id: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
state: WasmModuleState;
|
||||
node_ip: string;
|
||||
loaded_at: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensing Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServerConfig {
|
||||
http_port: number;
|
||||
ws_port: number;
|
||||
udp_port: number;
|
||||
static_dir: string | null;
|
||||
model_dir: string | null;
|
||||
log_level: string;
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
http_port: number | null;
|
||||
ws_port: number | null;
|
||||
udp_port: number | null;
|
||||
uptime_secs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SensingUpdate {
|
||||
timestamp: string;
|
||||
node_id: number;
|
||||
subcarrier_count: number;
|
||||
rssi: number;
|
||||
activity: string | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serial Port
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SerialPort {
|
||||
name: string; // e.g. "COM3" or "/dev/ttyUSB0"
|
||||
description: string; // e.g. "Silicon Labs CP210x"
|
||||
chip: Chip | null; // detected chip type, if any
|
||||
manufacturer: string | null;
|
||||
vid: number | null; // USB vendor ID
|
||||
pid: number | null; // USB product ID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppSettings {
|
||||
server_http_port: number;
|
||||
server_ws_port: number;
|
||||
server_udp_port: number;
|
||||
bind_address: string;
|
||||
ui_path: string;
|
||||
ota_psk: string;
|
||||
auto_discover: boolean;
|
||||
discover_interval_ms: number;
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: "esnext",
|
||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue