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:
ruv 2026-03-06 16:14:07 -05:00
parent 79aaf2d217
commit cab98df34a
45 changed files with 11636 additions and 48 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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`.

View File

@ -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"] }

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@ -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

View File

@ -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"]}}

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

View File

@ -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>,
}

View File

@ -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,
}

View File

@ -0,0 +1,6 @@
pub mod discovery;
pub mod flash;
pub mod ota;
pub mod provision;
pub mod server;
pub mod wasm;

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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>,
}

View File

@ -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,
}

View File

@ -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
}
}

View File

@ -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,
}

View File

@ -0,0 +1,3 @@
pub mod config;
pub mod firmware;
pub mod node;

View File

@ -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()
}
}

View File

@ -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");
}

View File

@ -0,0 +1,8 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
wifi_densepose_desktop::run();
}

View File

@ -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>,
}

View File

@ -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"
]
}
}

View File

@ -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>

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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>
);

View File

@ -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;

View File

@ -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",
};

View File

@ -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>
);
}

View File

@ -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",
};

View File

@ -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";
}

View File

@ -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"]
}

View File

@ -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",
},
});