mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
feat: complete Tauri desktop frontend with all pages and enhanced design (#198)
* docs: add ADR-052 Tauri desktop frontend with DDD bounded contexts Proposes a Tauri v2 desktop application as the primary UI for RuView, replacing 6+ CLI tools with a single cross-platform app. Covers hardware discovery, firmware flashing (espflash), OTA updates, WASM module management, sensing server control, and live visualization. Includes DDD domain model with 6 bounded contexts, aggregate definitions, domain events, and anti-corruption layers for ESP32 firmware APIs. Closes #177 Co-Authored-By: claude-flow <ruv@ruv.net> * docs: add persistent node registry, OTA safety gate, plugin architecture to ADR-052 Incorporates engineering review feedback: - Persistent node registry (~/.ruview/nodes.db) — discovery becomes reconciliation - BatchOtaSession aggregate with TdmSafe rolling update strategy - Plugin architecture section — control plane extensibility trajectory - Renumbered sections for new content (9-12 added, impl phases now section 13) Co-Authored-By: claude-flow <ruv@ruv.net> * docs: add ADR-053 UI design system — Foundation Book + Unity-inspired interface - Dark professional theme with rUv purple accent (#7c3aed) - Foundation Book typographic hierarchy (heading-xl through body-sm) - Unity Editor-inspired panel layout (sidebar + list/detail split + inspector) - 6 component specs: NodeCard, FlashProgress, MeshGraph, PropertyGrid, StatusBadge, LogViewer - Color system with status indicators (online/warning/error/info) - 4px base grid spacing system - Branding: splash screen, status bar, about dialog Refs #177 Co-Authored-By: claude-flow <ruv@ruv.net> * fix: rewrite ADR-053 UI design system with practical terminology Replace sci-fi themed language (Asimov Foundation references, Prime Radiant, Encyclopedia Galactica, Terminus, Seldon Crisis) with clear, practical terminology that engineers and operators can immediately understand. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: specify Three.js for mesh topology visualization in ADR-053 Use Three.js for the mesh topology view, consistent with existing visualization patterns in ui/observatory/js/ and ui/components/. Includes implementation details: MeshPhongMaterial for node status, BufferGeometry for dynamic updates, OrbitControls, raycasting. Co-Authored-By: claude-flow <ruv@ruv.net> * 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> * feat: implement ADR-053 design system across all frontend components Create design-system.css with all ADR-053 tokens: - CSS custom properties: colors, spacing, fonts, panel dimensions - Typography scale classes (heading-xl through data-lg) - Form control and button base styles - Custom scrollbar, selection highlight, animations Update all components to use design system tokens: - Replace hardcoded colors with var(--bg-surface), var(--border), etc. - Replace generic monospace with var(--font-mono) (JetBrains Mono) - Replace system font stack with var(--font-sans) (Inter) - Replace spacing values with var(--space-N) tokens - StatusBadge: use var(--status-online/warning/error/info) - Dashboard: add stat cards with data-lg class, use StatusBadge - FlashFirmware: pulse animation on progress bar during writes - Settings: default bind_address 127.0.0.1 (matches ADR-050) Add status bar footer with "Powered by rUv", node count, server status. Load Inter + JetBrains Mono from Google Fonts in index.html. Update ADR-053 status from Proposed to Accepted. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: add missing @tauri-apps/plugin-dialog and plugin-shell dependencies Required for firmware file picker in FlashFirmware page and shell sidecar support. Fixes Vite build failure. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: add defensive optional chaining for node.chip access Rust DiscoveredNode stub doesn't include chip field yet. Use optional chaining (node.chip?.toUpperCase()) to prevent TypeError at runtime. Co-Authored-By: claude-flow <ruv@ruv.net> * feat: add OTA, Edge Modules, Sensing, Mesh View pages with enhanced design system Implement all 4 remaining pages (OtaUpdate, EdgeModules, Sensing, MeshView) and enhance the design system with glassmorphism cards, count-up animations, page transitions, gradient accents, live status bar, and consistent status dot glows across the UI. Co-Authored-By: claude-flow <ruv@ruv.net> * docs: add desktop crate README and link from main README Co-Authored-By: claude-flow <ruv@ruv.net> * docs: add download/run instructions to desktop README Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+3109
-48
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,174 @@
|
||||
# RuView Desktop
|
||||
|
||||
> **Work in Progress** — This crate is under active development. APIs and UI are subject to change.
|
||||
|
||||
Cross-platform desktop application for managing ESP32 WiFi sensing networks. Built with **Tauri v2** (Rust backend) and **React + TypeScript** (frontend), following the [ADR-053 design system](../../docs/adr/ADR-053-ui-design-system.md).
|
||||
|
||||
## Overview
|
||||
|
||||
RuView Desktop provides a unified interface for node discovery, firmware management, over-the-air updates, WASM edge module deployment, real-time sensing data visualization, and mesh network topology monitoring — all from a single native application.
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| **Dashboard** | System overview with live stat cards, server panel, quick actions, and node grid | Done |
|
||||
| **Nodes** | Sortable table of discovered ESP32 nodes with expandable detail rows | Done |
|
||||
| **Flash** | 3-step serial firmware flash wizard (select port, pick firmware, flash + verify) | Done |
|
||||
| **OTA Update** | Single-node and batch over-the-air firmware updates with strategy selection | Done |
|
||||
| **Edge Modules** | WASM module upload, lifecycle management (start/stop/unload) per node | Done |
|
||||
| **Sensing** | Server start/stop, live log viewer (pause/clear), activity feed with confidence bars | Done |
|
||||
| **Mesh View** | Force-directed canvas graph showing mesh topology with click-to-inspect nodes | Done |
|
||||
| **Settings** | Server configuration (ports, bind address, discovery interval, theme) | Done |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
wifi-densepose-desktop/
|
||||
├── src/
|
||||
│ ├── main.rs # Tauri app entry point
|
||||
│ ├── lib.rs # Command registration
|
||||
│ ├── commands/ # Tauri IPC command handlers
|
||||
│ │ ├── discovery.rs # Node discovery (mDNS/UDP probe)
|
||||
│ │ ├── flash.rs # Serial firmware flashing
|
||||
│ │ ├── ota.rs # OTA update (single + batch)
|
||||
│ │ ├── wasm.rs # WASM module management
|
||||
│ │ └── server.rs # Sensing server lifecycle
|
||||
│ └── domain/ # DDD domain models
|
||||
│ ├── node.rs # DiscoveredNode, NodeRegistry, HealthStatus
|
||||
│ └── config.rs # ProvisioningConfig with validation
|
||||
├── ui/ # React + TypeScript frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Shell with sidebar nav, live status bar
|
||||
│ │ ├── design-system.css # ADR-053 design tokens and components
|
||||
│ │ ├── types.ts # TypeScript types mirroring Rust domain
|
||||
│ │ ├── components/ # Shared UI components (StatusBadge, NodeCard)
|
||||
│ │ ├── hooks/ # React hooks (useServer, useNodes)
|
||||
│ │ └── pages/ # 8 page components
|
||||
│ └── index.html
|
||||
└── tauri.conf.json # Tauri v2 configuration
|
||||
```
|
||||
|
||||
## Tauri Commands
|
||||
|
||||
| Group | Command | Description |
|
||||
|-------|---------|-------------|
|
||||
| **Discovery** | `discover_nodes` | Scan network for ESP32 nodes via mDNS/UDP |
|
||||
| **Flash** | `list_serial_ports` | List available serial ports |
|
||||
| | `detect_chip` | Detect connected chip type |
|
||||
| | `start_flash` | Flash firmware via serial |
|
||||
| **OTA** | `ota_update` | Push firmware to a single node |
|
||||
| | `batch_ota_update` | Push firmware to multiple nodes |
|
||||
| **WASM** | `wasm_list` | List loaded WASM modules on a node |
|
||||
| | `wasm_upload` | Upload a .wasm module to a node |
|
||||
| | `wasm_control` | Start/stop/unload a WASM module |
|
||||
| **Server** | `start_server` | Start the sensing HTTP/WS server |
|
||||
| | `stop_server` | Stop the sensing server |
|
||||
| | `server_status` | Get current server status |
|
||||
| **Provision** | `get_provision_config` | Read provisioning configuration |
|
||||
| | `save_provision_config` | Save provisioning configuration |
|
||||
|
||||
## Design System (ADR-053)
|
||||
|
||||
The UI follows a dark professional theme with the following design tokens:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--bg-base` | `#0d1117` | Main background |
|
||||
| `--bg-surface` | `#161b22` | Cards, sidebar, panels |
|
||||
| `--bg-elevated` | `#1c2333` | Elevated elements |
|
||||
| `--accent` | `#7c3aed` | Primary accent (purple) |
|
||||
| `--status-online` | `#3fb950` | Online/success indicators |
|
||||
| `--status-error` | `#f85149` | Error/offline indicators |
|
||||
| `--font-mono` | JetBrains Mono | Technical data, code |
|
||||
| `--font-sans` | Inter | UI text, labels |
|
||||
|
||||
### UI Features
|
||||
|
||||
- **Glassmorphism cards** with `backdrop-filter: blur(12px)`
|
||||
- **Count-up animations** on dashboard stat numbers
|
||||
- **Page transitions** with fade-in + scale on navigation
|
||||
- **Gradient accents** on logo, nav indicator, primary buttons
|
||||
- **Status dot glows** with ambient `box-shadow` per health state
|
||||
- **Staggered fade-ins** for card grids
|
||||
- **Force-directed graph** for mesh topology (pure Canvas 2D)
|
||||
|
||||
## Download
|
||||
|
||||
Pre-built binaries are available on the [Releases](https://github.com/ruvnet/RuView/releases) page.
|
||||
|
||||
| Platform | Download | Status |
|
||||
|----------|----------|--------|
|
||||
| Windows x64 | [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-desktop-alpha) | Debug build |
|
||||
| macOS | — | Planned |
|
||||
| Linux | — | Planned |
|
||||
|
||||
### Running the pre-built exe (Windows)
|
||||
|
||||
The current release is a **debug build** that loads the frontend from a local Vite dev server. Follow these steps:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo (or download just the ui/ folder)
|
||||
git clone https://github.com/ruvnet/RuView.git
|
||||
cd RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
|
||||
# 2. Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start the Vite dev server
|
||||
npx vite --host
|
||||
|
||||
# 4. Download and run the exe from the release page
|
||||
# (or run from the repo if you built it locally)
|
||||
# The app window will open and connect to localhost:5173
|
||||
```
|
||||
|
||||
> **Requirements:** Windows 10 (1803+) or Windows 11. WebView2 runtime is required (pre-installed on Windows 10 1803+ and all Windows 11).
|
||||
|
||||
> **Note:** Production builds will bundle the frontend assets directly into the exe, removing the need for a dev server.
|
||||
|
||||
## Build from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Rust 1.85+](https://rustup.rs/)
|
||||
- [Node.js 20+](https://nodejs.org/)
|
||||
- [Tauri v2 CLI](https://v2.tauri.app/start/prerequisites/)
|
||||
- **Windows:** MSVC build tools + MinGW-w64 (for `dlltool`)
|
||||
- **macOS:** Xcode Command Line Tools
|
||||
- **Linux:** `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`
|
||||
|
||||
### Development mode
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
cd ui && npm install
|
||||
|
||||
# Start in dev mode (hot-reload on both Rust and React)
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
# Build optimized release with bundled frontend
|
||||
cargo tauri build
|
||||
```
|
||||
|
||||
The installer/bundle will be in `target/release/bundle/` (`.msi` on Windows, `.dmg` on macOS, `.deb`/`.AppImage` on Linux).
|
||||
|
||||
## Domain Types
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `Node` | ip, mac, hostname, node_id, firmware_version, chip, mesh_role, health, ... | Full node record |
|
||||
| `HealthStatus` | online, offline, degraded, unknown | Node health state |
|
||||
| `FlashSession` | port, firmware, chip, baud, progress | Active flash operation |
|
||||
| `OtaResult` | node_ip, success, previous_version, new_version, duration_ms | OTA outcome |
|
||||
| `WasmModule` | module_id, name, size_bytes, state, node_ip | Edge module record |
|
||||
| `ServerStatus` | running, pid, http_port, ws_port | Sensing server state |
|
||||
| `SensingUpdate` | timestamp, node_id, subcarrier_count, rssi, activity, confidence | Real-time data |
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE) for details.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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"]}}
|
||||
+2630
File diff suppressed because it is too large
Load Diff
+2630
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,15 @@
|
||||
<!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>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1877
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"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,354 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import { Nodes } from "./pages/Nodes";
|
||||
import { FlashFirmware } from "./pages/FlashFirmware";
|
||||
import { OtaUpdate } from "./pages/OtaUpdate";
|
||||
import { EdgeModules } from "./pages/EdgeModules";
|
||||
import { Sensing } from "./pages/Sensing";
|
||||
import { MeshView } from "./pages/MeshView";
|
||||
import { Settings } from "./pages/Settings";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "nodes"
|
||||
| "flash"
|
||||
| "ota"
|
||||
| "wasm"
|
||||
| "sensing"
|
||||
| "mesh"
|
||||
| "settings";
|
||||
|
||||
interface NavItem {
|
||||
id: Page;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: "\u25A6" },
|
||||
{ id: "nodes", label: "Nodes", icon: "\u25C9" },
|
||||
{ id: "flash", label: "Flash", icon: "\u26A1" },
|
||||
{ id: "ota", label: "OTA", icon: "\u2B06" },
|
||||
{ id: "wasm", label: "Edge Modules", icon: "\u2B21" },
|
||||
{ id: "sensing", label: "Sensing", icon: "\u2248" },
|
||||
{ id: "mesh", label: "Mesh View", icon: "\u2B2F" },
|
||||
{ id: "settings", label: "Settings", icon: "\u2699" },
|
||||
];
|
||||
|
||||
interface LiveStatus {
|
||||
nodeCount: number;
|
||||
onlineCount: number;
|
||||
serverRunning: boolean;
|
||||
serverPort: number | null;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
||||
const [hoveredNav, setHoveredNav] = useState<Page | null>(null);
|
||||
const [pageKey, setPageKey] = useState(0);
|
||||
const [liveStatus, setLiveStatus] = useState<LiveStatus>({
|
||||
nodeCount: 0,
|
||||
onlineCount: 0,
|
||||
serverRunning: false,
|
||||
serverPort: null,
|
||||
});
|
||||
|
||||
const navigateTo = useCallback((page: Page) => {
|
||||
setActivePage(page);
|
||||
setPageKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Poll live status every 5 seconds
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const [nodes, server] = await Promise.all([
|
||||
invoke<{ health: string }[]>("discover_nodes", { timeoutMs: 2000 }).catch(() => []),
|
||||
invoke<{ running: boolean; http_port: number | null }>("server_status").catch(() => ({
|
||||
running: false,
|
||||
http_port: null,
|
||||
})),
|
||||
]);
|
||||
setLiveStatus({
|
||||
nodeCount: nodes.length,
|
||||
onlineCount: nodes.filter((n) => n.health === "online").length,
|
||||
serverRunning: server.running,
|
||||
serverPort: server.http_port,
|
||||
});
|
||||
} catch {
|
||||
// Tauri not available (browser preview) — leave defaults
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 8000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case "dashboard": return <Dashboard />;
|
||||
case "nodes": return <Nodes />;
|
||||
case "flash": return <FlashFirmware />;
|
||||
case "ota": return <OtaUpdate />;
|
||||
case "wasm": return <EdgeModules />;
|
||||
case "sensing": return <Sensing />;
|
||||
case "mesh": return <MeshView />;
|
||||
case "settings": return <Settings />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
style={{
|
||||
width: 220,
|
||||
minWidth: 220,
|
||||
background: "var(--bg-surface)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 16px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg, var(--accent), #a855f7, #ec4899)",
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient-shift 4s ease infinite",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: "#fff",
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 2px 12px rgba(124, 58, 237, 0.4)",
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
margin: 0,
|
||||
letterSpacing: "-0.01em",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
RuView
|
||||
</h1>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
v0.3.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div style={{ flex: 1, paddingTop: 6, paddingBottom: 6, overflowY: "auto" }}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activePage === item.id;
|
||||
const isHovered = hoveredNav === item.id && !isActive;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigateTo(item.id)}
|
||||
onMouseEnter={() => setHoveredNav(item.id)}
|
||||
onMouseLeave={() => setHoveredNav(null)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "8px 16px",
|
||||
background: isActive
|
||||
? "linear-gradient(90deg, rgba(124, 58, 237, 0.15), transparent)"
|
||||
: isHovered
|
||||
? "var(--bg-hover)"
|
||||
: "transparent",
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: "left",
|
||||
borderLeft: isActive
|
||||
? "3px solid transparent"
|
||||
: "3px solid transparent",
|
||||
fontFamily: "var(--font-sans)",
|
||||
borderRadius: 0,
|
||||
transition: "all 0.15s ease",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Active gradient indicator */}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
width: 3,
|
||||
borderRadius: "0 3px 3px 0",
|
||||
background: "linear-gradient(180deg, var(--accent), #a855f7)",
|
||||
boxShadow: "0 0 8px rgba(124, 58, 237, 0.5)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, var(--accent), #a855f7)"
|
||||
: isHovered
|
||||
? "var(--bg-active)"
|
||||
: "var(--bg-elevated)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
color: isActive ? "#fff" : "var(--text-muted)",
|
||||
transition: "all 0.15s ease",
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive ? "0 2px 8px rgba(124, 58, 237, 0.3)" : "none",
|
||||
transform: isHovered ? "scale(1.1)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Live connection footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span className="status-dot status-dot--online" style={{ width: 6, height: 6 }} />
|
||||
<span>Connected</span>
|
||||
{liveStatus.nodeCount > 0 && (
|
||||
<span style={{ marginLeft: "auto", color: "var(--text-muted)" }}>
|
||||
{liveStatus.onlineCount}/{liveStatus.nodeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
background: "var(--bg-base)",
|
||||
}}
|
||||
>
|
||||
<div key={pageKey} className="page-transition">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<footer
|
||||
style={{
|
||||
height: "var(--statusbar-height)",
|
||||
minHeight: "var(--statusbar-height)",
|
||||
background: "var(--bg-surface)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 16px",
|
||||
gap: 16,
|
||||
fontSize: 11,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--text-muted)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-muted)", fontWeight: 500 }}>
|
||||
Powered by rUv
|
||||
</span>
|
||||
|
||||
<span style={{ color: "var(--border)" }}>{"\u2502"}</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span
|
||||
className={`status-dot ${liveStatus.onlineCount > 0 ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 6, height: 6 }}
|
||||
/>
|
||||
{liveStatus.onlineCount > 0
|
||||
? `${liveStatus.onlineCount} node${liveStatus.onlineCount !== 1 ? "s" : ""} online`
|
||||
: "No nodes"}
|
||||
</span>
|
||||
|
||||
<span style={{ color: "var(--border)" }}>{"\u2502"}</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span
|
||||
className={`status-dot ${liveStatus.serverRunning ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 6, height: 6 }}
|
||||
/>
|
||||
Server: {liveStatus.serverRunning ? "running" : "stopped"}
|
||||
</span>
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
{liveStatus.serverPort && (
|
||||
<span style={{ fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>
|
||||
:{liveStatus.serverPort}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
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 diffMs = Date.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(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
opacity: isOnline ? 1 : 0.6,
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--accent)";
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.background = "var(--bg-elevated)";
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={node.health} />
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "var(--space-2) var(--space-4)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
|
||||
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} mono />
|
||||
<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}`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
/>
|
||||
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} mono />
|
||||
<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)",
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
fontSize: 12,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+152
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: HealthStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<HealthStatus, { color: string; label: string }> = {
|
||||
online: { color: "var(--status-online)", label: "Online" },
|
||||
offline: { color: "var(--status-error)", label: "Offline" },
|
||||
degraded: { color: "var(--status-warning)", label: "Degraded" },
|
||||
unknown: { color: "var(--text-muted)", label: "Unknown" },
|
||||
};
|
||||
|
||||
const SIZE_STYLES: Record<string, { fontSize: number; padding: string; dot: number }> = {
|
||||
sm: { fontSize: 11, padding: "2px 8px", dot: 6 },
|
||||
md: { fontSize: 13, padding: "4px 12px", dot: 8 },
|
||||
lg: { fontSize: 15, padding: "6px 16px", dot: 10 },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
||||
const { color, label } = STATUS_STYLES[status];
|
||||
const s = SIZE_STYLES[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color,
|
||||
fontSize: s.fontSize,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
padding: s.padding,
|
||||
borderRadius: 9999,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: s.dot,
|
||||
height: s.dot,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
boxShadow: status === "online"
|
||||
? `0 0 4px ${color}, 0 0 8px ${color}`
|
||||
: status === "degraded"
|
||||
? `0 0 4px ${color}`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* RuView Design System (ADR-053)
|
||||
* Dark professional + Unity-inspired interface
|
||||
*/
|
||||
|
||||
/* ===== Design Tokens ===== */
|
||||
:root {
|
||||
/* Background layers */
|
||||
--bg-base: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-elevated: #1c2333;
|
||||
--bg-hover: #242d3d;
|
||||
--bg-active: #2d3748;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #484f58;
|
||||
|
||||
/* Status indicators */
|
||||
--status-online: #3fb950;
|
||||
--status-warning: #d29922;
|
||||
--status-error: #f85149;
|
||||
--status-info: #58a6ff;
|
||||
|
||||
/* Accent */
|
||||
--accent: #7c3aed;
|
||||
--accent-hover: #6d28d9;
|
||||
--accent-glow: rgba(124, 58, 237, 0.15);
|
||||
|
||||
/* Borders */
|
||||
--border: #30363d;
|
||||
--border-active: #58a6ff;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-accent: 0 0 0 3px var(--accent-glow);
|
||||
|
||||
/* Fonts */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
/* Spacing (4px base grid) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
--space-8: 48px;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Panel dimensions */
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed: 52px;
|
||||
--statusbar-height: 32px;
|
||||
--toolbar-height: 44px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.1s ease;
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.25s ease;
|
||||
}
|
||||
|
||||
/* ===== Reset ===== */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== Typography Scale ===== */
|
||||
.heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.heading-lg { font: 600 20px/1.3 var(--font-sans); color: var(--text-primary); letter-spacing: -0.01em; }
|
||||
.heading-md { font: 600 16px/1.4 var(--font-sans); color: var(--text-primary); }
|
||||
.heading-sm { font: 600 13px/1.4 var(--font-sans); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.body { font: 400 14px/1.6 var(--font-sans); color: var(--text-primary); }
|
||||
.body-sm { font: 400 12px/1.5 var(--font-sans); color: var(--text-secondary); }
|
||||
.data { font: 400 13px/1.4 var(--font-mono); color: var(--text-secondary); }
|
||||
.data-lg { font: 500 24px/1.2 var(--font-mono); color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-active);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== Form Controls ===== */
|
||||
input, select, textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
input:hover, select:hover, textarea:hover {
|
||||
border-color: var(--bg-active);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
input:disabled, select:disabled, textarea:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input[type="number"] {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e' viewBox='0 0 16 16'%3E%3Cpath d='M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-fast);
|
||||
}
|
||||
button:focus-visible {
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
padding: var(--space-2) 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--bg-active);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
color: var(--status-error);
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(248, 81, 73, 0.2);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--bg-active);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.card-elevated {
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Glassmorphism card variant */
|
||||
.card-glass {
|
||||
background: rgba(22, 27, 34, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(48, 54, 61, 0.6);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
.card-glass:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
/* Accent-glow card for stat highlights */
|
||||
.card-glow {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
.card-glow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), #a855f7, var(--accent));
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
.card-glow:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.card-glow:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 0 20px rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
thead th {
|
||||
padding: 10px var(--space-4);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-surface);
|
||||
z-index: 1;
|
||||
}
|
||||
tbody td {
|
||||
padding: 10px var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ===== Badge ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Divider ===== */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
@keyframes pulse-accent {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from { opacity: 0; transform: scale(0.97) translateY(4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.06; }
|
||||
50% { opacity: 0.12; }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px currentColor; }
|
||||
50% { box-shadow: 0 0 10px currentColor, 0 0 20px currentColor; }
|
||||
}
|
||||
|
||||
@keyframes count-up-pop {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
60% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Page transition wrapper */
|
||||
.page-transition {
|
||||
animation: fade-in-scale 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger children animation */
|
||||
.stagger-children > * {
|
||||
animation: fade-in 0.3s ease-out backwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== Focus ring ===== */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
|
||||
/* ===== Selection ===== */
|
||||
::selection {
|
||||
background: rgba(124, 58, 237, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== Tooltip-style truncation ===== */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Mono data ===== */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===== Status dot with glow ===== */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot--online {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 6px rgba(63, 185, 80, 0.5), 0 0 12px rgba(63, 185, 80, 0.2);
|
||||
}
|
||||
.status-dot--error {
|
||||
background: var(--status-error);
|
||||
box-shadow: 0 0 6px rgba(248, 81, 73, 0.5);
|
||||
}
|
||||
.status-dot--warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
|
||||
}
|
||||
|
||||
/* ===== Gradient button ===== */
|
||||
.btn-gradient {
|
||||
padding: var(--space-2) 20px;
|
||||
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||||
background-size: 200% 200%;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
|
||||
transition: box-shadow var(--transition-normal), background-position 0.4s ease, transform var(--transition-fast);
|
||||
}
|
||||
.btn-gradient:hover:not(:disabled) {
|
||||
background-position: 100% 0;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* ===== Sidebar nav active indicator ===== */
|
||||
.nav-indicator {
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: linear-gradient(180deg, var(--accent), #a855f7);
|
||||
box-shadow: 0 0 8px rgba(124, 58, 237, 0.4);
|
||||
transition: height var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ===== Empty state ===== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.empty-state-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
@@ -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,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./design-system.css";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,326 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: HealthStatus;
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1100 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="heading-lg" style={{ margin: 0 }}>Dashboard</h2>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
System overview and quick actions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="btn-gradient"
|
||||
style={{ opacity: scanning ? 0.6 : 1 }}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Scan Network"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="stagger-children"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: "var(--space-4)",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<StatCard label="Total Nodes" value={nodes.length} />
|
||||
<StatCard label="Online" value={onlineCount} color="var(--status-online)" />
|
||||
<StatCard label="Offline" value={nodes.length - onlineCount} color={nodes.length - onlineCount > 0 ? "var(--status-error)" : "var(--text-muted)"} />
|
||||
<StatCard
|
||||
label="Server"
|
||||
value={serverStatus?.running ? "Running" : "Stopped"}
|
||||
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
|
||||
isText
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-5)" }}>
|
||||
{/* Server panel */}
|
||||
<div className="card">
|
||||
<h3 className="heading-sm" style={{ marginBottom: "var(--space-3)" }}>Sensing Server</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span
|
||||
className={`status-dot ${serverStatus?.running ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 10, height: 10 }}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: "var(--text-primary)", fontWeight: 500 }}>
|
||||
{serverStatus?.running ? "Running" : "Stopped"}
|
||||
</span>
|
||||
{serverStatus?.running && serverStatus.pid && (
|
||||
<span className="data" style={{ marginLeft: "auto" }}>
|
||||
PID {serverStatus.pid}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{serverStatus?.running && serverStatus.http_port && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", gap: "var(--space-4)" }}>
|
||||
<PortTag label="HTTP" port={serverStatus.http_port} />
|
||||
{serverStatus.ws_port && <PortTag label="WS" port={serverStatus.ws_port} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions panel */}
|
||||
<div className="card">
|
||||
<h3 className="heading-sm" style={{ marginBottom: "var(--space-3)" }}>Quick Actions</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
||||
<QuickAction label="Flash Firmware" desc="Flash via serial port" />
|
||||
<QuickAction label="Push OTA Update" desc="Over-the-air to nodes" />
|
||||
<QuickAction label="Upload WASM" desc="Deploy edge modules" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node list */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||
<h3 className="heading-sm">Discovered Nodes ({nodes.length})</h3>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<div className="empty-state-icon">{"\u25C9"}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
|
||||
No nodes discovered
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", maxWidth: 280, textAlign: "center", lineHeight: 1.5 }}>
|
||||
Click "Scan Network" to discover ESP32 devices on your local network.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||
gap: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
{nodes.map((node, i) => (
|
||||
<NodeDashCard key={node.mac || i} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useCountUp(target: number, duration = 600): number {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const prevTarget = useRef(0);
|
||||
useEffect(() => {
|
||||
const start = prevTarget.current;
|
||||
prevTarget.current = target;
|
||||
if (target === start) return;
|
||||
const startTime = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
||||
setCurrent(Math.round(start + (target - start) * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [target, duration]);
|
||||
return current;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
isText = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
color?: string;
|
||||
isText?: boolean;
|
||||
}) {
|
||||
const animatedValue = useCountUp(typeof value === "number" ? value : 0);
|
||||
const displayValue = isText || typeof value === "string" ? value : animatedValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card-glow"
|
||||
style={{ padding: "var(--space-4)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: "var(--space-2)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: isText ? 16 : 28,
|
||||
fontWeight: 600,
|
||||
color: color || "var(--text-primary)",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortTag({ label, port }: { label: string; port: number }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-muted)", fontWeight: 600 }}>{label}</span>
|
||||
<span className="mono" style={{ color: "var(--text-secondary)" }}>:{port}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({ label, desc }: { label: string; desc: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "var(--bg-base)")}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)" }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>{desc}</div>
|
||||
</div>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 14 }}>{"\u203A"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDashCard({ node }: { node: DiscoveredNode }) {
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "var(--space-4)",
|
||||
cursor: "pointer",
|
||||
opacity: node.health === "online" ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start", marginBottom: "var(--space-3)" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 1 }}>
|
||||
{node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={node.health} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 16px", fontSize: 12 }}>
|
||||
<KV label="MAC" value={node.mac || "--"} mono />
|
||||
<KV label="Firmware" value={node.firmware_version || "--"} mono />
|
||||
<KV label="Node ID" value={String(node.node_id)} mono />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>{label}</span>
|
||||
<span className={mono ? "mono" : ""} style={{ color: "var(--text-secondary)", fontSize: 12 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Node, WasmModule, WasmModuleState } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATE_STYLES: Record<WasmModuleState, { color: string; label: string }> = {
|
||||
running: { color: "var(--status-online)", label: "Running" },
|
||||
stopped: { color: "var(--status-warning)", label: "Stopped" },
|
||||
error: { color: "var(--status-error)", label: "Error" },
|
||||
loading: { color: "var(--status-info)", label: "Loading" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EdgeModules page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EdgeModules() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [selectedIp, setSelectedIp] = useState<string>("");
|
||||
const [modules, setModules] = useState<WasmModule[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// ---- Discover nodes on mount ----
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const discovered = await invoke<Node[]>("discover_nodes", {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
setNodes(discovered);
|
||||
if (discovered.length > 0) {
|
||||
setSelectedIp(discovered[0].ip);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ---- Fetch modules when selected node changes ----
|
||||
const fetchModules = useCallback(async (ip: string) => {
|
||||
if (!ip) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await invoke<WasmModule[]>("wasm_list", { nodeIp: ip });
|
||||
setModules(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setModules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIp) {
|
||||
fetchModules(selectedIp);
|
||||
}
|
||||
}, [selectedIp, fetchModules]);
|
||||
|
||||
// ---- Upload .wasm file ----
|
||||
const handleUpload = async () => {
|
||||
if (!selectedIp) return;
|
||||
const filePath = await open({
|
||||
title: "Select WASM Module",
|
||||
filters: [{ name: "WASM Modules", extensions: ["wasm"] }],
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (!filePath) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const result = await invoke<{ success: boolean; module_id: string; message: string }>(
|
||||
"wasm_upload",
|
||||
{ nodeIp: selectedIp, wasmPath: filePath },
|
||||
);
|
||||
if (result.success) {
|
||||
setSuccess(`Module uploaded: ${result.module_id}`);
|
||||
await fetchModules(selectedIp);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Module actions ----
|
||||
const handleAction = async (moduleId: string, action: "start" | "stop" | "unload") => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await invoke("wasm_control", {
|
||||
nodeIp: selectedIp,
|
||||
moduleId,
|
||||
action,
|
||||
});
|
||||
setSuccess(`Module ${moduleId} ${action === "unload" ? "unloaded" : action === "start" ? "started" : "stopped"}`);
|
||||
await fetchModules(selectedIp);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>Edge Modules (WASM)</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
||||
Manage WASM modules deployed to ESP32 nodes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedIp || isUploading}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: !selectedIp || isUploading ? "var(--bg-active)" : "var(--accent)",
|
||||
color: !selectedIp || isUploading ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: !selectedIp || isUploading ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Upload Module"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Node selector */}
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
display: "block",
|
||||
marginBottom: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
Target Node
|
||||
</label>
|
||||
<select
|
||||
value={selectedIp}
|
||||
onChange={(e) => setSelectedIp(e.target.value)}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-elevated)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-mono)",
|
||||
minWidth: 260,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{nodes.length === 0 && <option value="">No nodes discovered</option>}
|
||||
{nodes.map((node) => (
|
||||
<option key={node.ip} value={node.ip}>
|
||||
{node.ip}{node.hostname ? ` (${node.hostname})` : ""}{node.friendly_name ? ` - ${node.friendly_name}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Success banner */}
|
||||
{success && (
|
||||
<Banner
|
||||
type="success"
|
||||
message={success}
|
||||
onDismiss={() => setSuccess(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<Banner
|
||||
type="error"
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Module table */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading modules...
|
||||
</div>
|
||||
) : modules.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{selectedIp
|
||||
? "No WASM modules loaded on this node. Use \"Upload Module\" to deploy one."
|
||||
: "Select a node to view its WASM modules."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
|
||||
<Th>Name</Th>
|
||||
<Th>Size</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Loaded At</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((mod) => (
|
||||
<ModuleRow
|
||||
key={mod.module_id}
|
||||
module={mod}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleStateBadge({ state }: { state: WasmModuleState }) {
|
||||
const { color, label } = STATE_STYLES[state];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 9999,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
variant = "default",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
}) {
|
||||
const isDanger = variant === "danger";
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
border: `1px solid ${isDanger ? "var(--status-error)" : "var(--border)"}`,
|
||||
background: "transparent",
|
||||
color: isDanger ? "var(--status-error)" : "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDanger
|
||||
? "rgba(248, 81, 73, 0.1)"
|
||||
: "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleRow({
|
||||
module: mod,
|
||||
onAction,
|
||||
}: {
|
||||
module: WasmModule;
|
||||
onAction: (moduleId: string, action: "start" | "stop" | "unload") => void;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<Td mono>{mod.name}</Td>
|
||||
<Td mono>{formatBytes(mod.size_bytes)}</Td>
|
||||
<Td><ModuleStateBadge state={mod.state} /></Td>
|
||||
<Td>{formatLoadedAt(mod.loaded_at)}</Td>
|
||||
<td style={{ padding: "10px var(--space-4)", whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
{mod.state === "stopped" && (
|
||||
<ActionButton label="Start" onClick={() => onAction(mod.module_id, "start")} />
|
||||
)}
|
||||
{mod.state === "running" && (
|
||||
<ActionButton label="Stop" onClick={() => onAction(mod.module_id, "stop")} />
|
||||
)}
|
||||
<ActionButton
|
||||
label="Unload"
|
||||
onClick={() => onAction(mod.module_id, "unload")}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({
|
||||
type,
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
type: "error" | "success";
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isError = type === "error";
|
||||
const color = isError ? "var(--status-error)" : "var(--status-online)";
|
||||
const bgAlpha = isError ? "rgba(248, 81, 73, 0.1)" : "rgba(63, 185, 80, 0.1)";
|
||||
const borderAlpha = isError ? "rgba(248, 81, 73, 0.3)" : "rgba(63, 185, 80, 0.3)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgAlpha,
|
||||
border: `1px solid ${borderAlpha}`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
padding: "0 0 0 var(--space-3)",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatLoadedAt(iso: string | null): string {
|
||||
if (!iso) return "--";
|
||||
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 "--";
|
||||
}
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
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() {
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [selectedPort, setSelectedPort] = useState("");
|
||||
const [firmwarePath, setFirmwarePath] = useState("");
|
||||
const [chip, setChip] = useState<Chip>("esp32s3");
|
||||
const [baud, setBaud] = useState(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);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<FlashProgress>("flash-progress", (event) => {
|
||||
setProgress(event.payload);
|
||||
}).then((fn) => { unlisten = fn; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
setFlashResult({ success: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setIsFlashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = (s: WizardStep): boolean => {
|
||||
if (s === 1) return selectedPort !== "";
|
||||
if (s === 2) return firmwarePath !== "";
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 700 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Flash Firmware</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Flash firmware to an ESP32 via serial connection
|
||||
</p>
|
||||
|
||||
<StepIndicator current={step} />
|
||||
|
||||
{error && (
|
||||
<div style={bannerStyle("var(--status-error)")}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select Serial Port */}
|
||||
{step === 1 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 1: Select Serial Port</h2>
|
||||
<p style={stepDescStyle}>Connect your ESP32 via USB and select the serial port.</p>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Serial Port</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<select
|
||||
value={selectedPort}
|
||||
onChange={(e) => setSelectedPort(e.target.value)}
|
||||
style={{ 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={secondaryBtn} disabled={isLoadingPorts}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setStep(2)} disabled={!canProceed(1)} style={canProceed(1) ? primaryBtn : disabledBtn}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select Firmware */}
|
||||
{step === 2 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 2: Select Firmware</h2>
|
||||
<p style={stepDescStyle}>Choose the firmware binary file and chip configuration.</p>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-4)" }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Chip</label>
|
||||
<select value={chip} onChange={(e) => setChip(e.target.value as Chip)}>
|
||||
<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))}>
|
||||
<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={secondaryBtn}>Back</button>
|
||||
<button onClick={() => setStep(3)} disabled={!canProceed(2)} style={canProceed(2) ? primaryBtn : disabledBtn}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Flash */}
|
||||
{step === 3 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 3: Flash</h2>
|
||||
|
||||
{/* Summary */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "var(--space-2)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<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: "var(--space-4)" }}>
|
||||
<ProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{flashResult && (
|
||||
<div style={bannerStyle(flashResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||
{flashResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button
|
||||
onClick={() => { setStep(2); setFlashResult(null); setProgress(null); }}
|
||||
style={secondaryBtn}
|
||||
disabled={isFlashing}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{flashResult ? (
|
||||
<button
|
||||
onClick={() => { setStep(1); setFlashResult(null); setProgress(null); setFirmwarePath(""); setSelectedPort(""); }}
|
||||
style={primaryBtn}
|
||||
>
|
||||
Flash Another
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={startFlash} disabled={isFlashing} style={isFlashing ? disabledBtn : primaryBtn}>
|
||||
{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", marginBottom: "var(--space-5)" }}>
|
||||
{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: "var(--space-2)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-mono)",
|
||||
background: isActive ? "var(--accent)" : isDone ? "rgba(63, 185, 80, 0.2)" : "var(--border)",
|
||||
color: isActive ? "#fff" : isDone ? "var(--status-online)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{isDone ? "\u2713" : n}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div style={{ width: 40, height: 1, background: "var(--border)", margin: "0 var(--space-3)" }} />
|
||||
)}
|
||||
</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: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: "var(--text-secondary)" }}>{PHASE_LABELS[phase]}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
|
||||
{pct.toFixed(1)}%{speedKB && ` | ${speedKB}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: "100%", height: 8, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
height: "100%",
|
||||
background: phase === "error" ? "var(--status-error)" : phase === "done" ? "var(--status-online)" : "var(--accent)",
|
||||
borderRadius: 4,
|
||||
transition: "width 0.3s ease",
|
||||
animation: phase === "writing" ? "pulse-accent 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: 1 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontFamily: "var(--font-mono)", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared styles ---
|
||||
|
||||
function bannerStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
};
|
||||
|
||||
const stepTitleStyle: React.CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 var(--space-1)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const stepDescStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "var(--space-4)",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const disabledBtn: React.CSSProperties = {
|
||||
...primaryBtn,
|
||||
background: "var(--bg-active)",
|
||||
color: "var(--text-muted)",
|
||||
};
|
||||
@@ -0,0 +1,703 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: HealthStatus;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface SimNode {
|
||||
id: number;
|
||||
label: string;
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
firmware: string | null;
|
||||
health: HealthStatus;
|
||||
isCoordinator: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
tdmSlot: number;
|
||||
}
|
||||
|
||||
interface SimEdge {
|
||||
source: number; // index into nodes
|
||||
target: number;
|
||||
strength: number; // 0.3 - 1.0 opacity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANVAS_HEIGHT = 500;
|
||||
const REPULSION = 8000;
|
||||
const SPRING_K = 0.005;
|
||||
const SPRING_REST = 120;
|
||||
const DAMPING = 0.92;
|
||||
const VELOCITY_THRESHOLD = 0.15;
|
||||
const DT = 1;
|
||||
|
||||
const HEALTH_COLORS: Record<HealthStatus, string> = {
|
||||
online: "#3fb950",
|
||||
offline: "#f85149",
|
||||
degraded: "#d29922",
|
||||
unknown: "#8b949e",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGraph(
|
||||
rawNodes: DiscoveredNode[],
|
||||
canvasWidth: number,
|
||||
): { nodes: SimNode[]; edges: SimEdge[] } {
|
||||
const cx = canvasWidth / 2;
|
||||
const cy = CANVAS_HEIGHT / 2;
|
||||
|
||||
const nodes: SimNode[] = rawNodes.map((n, i) => {
|
||||
const isCoord = n.node_id === 0 || i === 0;
|
||||
const angle = (2 * Math.PI * i) / Math.max(rawNodes.length, 1);
|
||||
const spread = Math.min(canvasWidth, CANVAS_HEIGHT) * 0.3;
|
||||
return {
|
||||
id: n.node_id,
|
||||
label: n.hostname || `Node ${n.node_id}`,
|
||||
ip: n.ip,
|
||||
mac: n.mac,
|
||||
firmware: n.firmware_version,
|
||||
health: n.health,
|
||||
isCoordinator: isCoord,
|
||||
x: cx + Math.cos(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
y: cy + Math.sin(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: isCoord ? 30 : 20,
|
||||
tdmSlot: i,
|
||||
};
|
||||
});
|
||||
|
||||
const edges: SimEdge[] = [];
|
||||
const coordIdx = 0;
|
||||
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
// Connect every node to coordinator
|
||||
edges.push({
|
||||
source: coordIdx,
|
||||
target: i,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
// Connect to next neighbor (ring)
|
||||
if (i < nodes.length - 1) {
|
||||
edges.push({
|
||||
source: i,
|
||||
target: i + 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Close the ring if 3+ non-coordinator nodes
|
||||
if (nodes.length > 3) {
|
||||
edges.push({
|
||||
source: nodes.length - 1,
|
||||
target: 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function hitTest(
|
||||
mx: number,
|
||||
my: number,
|
||||
nodes: SimNode[],
|
||||
): SimNode | null {
|
||||
// Iterate in reverse so topmost (last-drawn) wins
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const dx = mx - n.x;
|
||||
const dy = my - n.y;
|
||||
if (dx * dx + dy * dy <= n.radius * n.radius) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MeshView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<SimNode | null>(null);
|
||||
|
||||
// Track simulation data in a ref so the animation loop can read it without
|
||||
// re-renders triggering a new effect.
|
||||
const simRef = useRef<{ nodes: SimNode[]; edges: SimEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch nodes from Rust backend
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
setSelectedNode(null);
|
||||
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);
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Measure container width
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.clientWidth;
|
||||
if (w > 0) setCanvasWidth(w);
|
||||
};
|
||||
measure();
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build graph + run force simulation whenever nodes or width change
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) {
|
||||
simRef.current = { nodes: [], edges: [] };
|
||||
// Clear canvas
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes: simNodes, edges } = buildGraph(nodes, canvasWidth);
|
||||
simRef.current = { nodes: simNodes, edges };
|
||||
|
||||
let settled = false;
|
||||
|
||||
const step = () => {
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
// Coulomb repulsion
|
||||
for (let i = 0; i < sn.length; i++) {
|
||||
for (let j = i + 1; j < sn.length; j++) {
|
||||
let dx = sn[j].x - sn[i].x;
|
||||
let dy = sn[j].y - sn[i].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const force = REPULSION / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
sn[i].vx -= fx;
|
||||
sn[i].vy -= fy;
|
||||
sn[j].vx += fx;
|
||||
sn[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Spring attraction along edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const displacement = dist - SPRING_REST;
|
||||
const force = SPRING_K * displacement;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Integrate + damp + clamp to canvas bounds
|
||||
let maxV = 0;
|
||||
for (const n of sn) {
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx * DT;
|
||||
n.y += n.vy * DT;
|
||||
|
||||
// Keep nodes within canvas with padding
|
||||
const pad = n.radius + 10;
|
||||
if (n.x < pad) { n.x = pad; n.vx = 0; }
|
||||
if (n.x > canvasWidth - pad) { n.x = canvasWidth - pad; n.vx = 0; }
|
||||
if (n.y < pad) { n.y = pad; n.vy = 0; }
|
||||
if (n.y > CANVAS_HEIGHT - pad) { n.y = CANVAS_HEIGHT - pad; n.vy = 0; }
|
||||
|
||||
const v = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
|
||||
if (maxV < VELOCITY_THRESHOLD) settled = true;
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
// Edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
// Coordinator ring
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Selected highlight
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node ID text inside circle
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
// Label below
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (!settled) step();
|
||||
draw();
|
||||
if (!settled) {
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
// selectedNode is intentionally excluded from deps so clicking doesn't
|
||||
// restart the simulation. We redraw via the click handler instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, canvasWidth]);
|
||||
|
||||
// Redraw when selectedNode changes (without restarting simulation)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || simRef.current.nodes.length === 0) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
}, [selectedNode, canvasWidth]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Canvas click handler
|
||||
// -----------------------------------------------------------------------
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const hit = hitTest(mx, my, simRef.current.nodes);
|
||||
setSelectedNode(hit);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Derived stats
|
||||
// -----------------------------------------------------------------------
|
||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>
|
||||
Mesh Topology
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
Force-directed view of the ESP32 mesh network
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchNodes}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: scanning ? "var(--bg-active)" : "var(--accent)",
|
||||
color: scanning ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: "none",
|
||||
cursor: scanning ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
height: CANVAS_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{scanning
|
||||
? "Scanning for nodes..."
|
||||
: "No nodes found. Click Refresh to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasWidth}
|
||||
height={CANVAS_HEIGHT}
|
||||
onClick={handleCanvasClick}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: CANVAS_HEIGHT,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-5)",
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Nodes </span>
|
||||
<span style={{ color: "var(--status-online)" }}>{onlineCount}</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>/{nodes.length} online</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Drift </span>
|
||||
±0.3ms
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Cycle </span>
|
||||
50ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Selected node detail card */}
|
||||
{selectedNode && (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{selectedNode.label}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 10,
|
||||
background:
|
||||
HEALTH_COLORS[selectedNode.health] + "22",
|
||||
color: HEALTH_COLORS[selectedNode.health],
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{selectedNode.health}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
|
||||
gap: "var(--space-3) var(--space-5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailField label="IP Address" value={selectedNode.ip} mono />
|
||||
<DetailField label="MAC" value={selectedNode.mac ?? "--"} mono />
|
||||
<DetailField
|
||||
label="Firmware"
|
||||
value={selectedNode.firmware ?? "--"}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Role"
|
||||
value={selectedNode.isCoordinator ? "Coordinator" : "Node"}
|
||||
/>
|
||||
<DetailField
|
||||
label="TDM Slot"
|
||||
value={`${selectedNode.tdmSlot} / ${nodes.length}`}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Node ID"
|
||||
value={String(selectedNode.id)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>Nodes</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={isScanning}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: isScanning ? "var(--bg-active)" : "var(--accent)",
|
||||
color: isScanning ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", 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;
|
||||
return (
|
||||
<NodeRow
|
||||
key={key}
|
||||
node={node}
|
||||
isExpanded={expandedMac === key}
|
||||
onToggle={() => toggleExpand(node)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{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)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<Td><StatusBadge status={node.health} /></Td>
|
||||
<Td mono>{node.mac ?? "--"}</Td>
|
||||
<Td mono>{node.ip}</Td>
|
||||
<Td mono>{node.firmware_version ?? "--"}</Td>
|
||||
<Td>{node.chip?.toUpperCase() ?? "--"}</Td>
|
||||
<Td>{formatLastSeen(node.last_seen)}</Td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td colSpan={6} style={{ padding: "0 var(--space-4) var(--space-4)" }}>
|
||||
<ExpandedDetails node={node} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDetails({ node }: { node: Node }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-4)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "var(--space-3) var(--space-5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailField label="Hostname" value={node.hostname ?? "--"} />
|
||||
<DetailField label="Node ID" value={String(node.node_id)} mono />
|
||||
<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}`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Uptime"
|
||||
value={
|
||||
node.uptime_secs != null
|
||||
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<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, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)" }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Node,
|
||||
OtaStrategy,
|
||||
BatchNodeState,
|
||||
OtaResult,
|
||||
} from "../types";
|
||||
|
||||
type Mode = "single" | "batch";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<OtaStrategy, string> = {
|
||||
sequential: "Sequential",
|
||||
tdm_safe: "TDM-Safe",
|
||||
parallel: "Parallel",
|
||||
};
|
||||
|
||||
const STATE_CONFIG: Record<BatchNodeState, { label: string; color: string }> = {
|
||||
queued: { label: "Queued", color: "var(--text-muted)" },
|
||||
uploading: { label: "Uploading", color: "var(--status-info)" },
|
||||
rebooting: { label: "Rebooting", color: "var(--status-warning)" },
|
||||
verifying: { label: "Verifying", color: "var(--status-info)" },
|
||||
done: { label: "Done", color: "var(--status-online)" },
|
||||
failed: { label: "Failed", color: "var(--status-error)" },
|
||||
skipped: { label: "Skipped", color: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
export function OtaUpdate() {
|
||||
const [mode, setMode] = useState<Mode>("single");
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [firmwarePath, setFirmwarePath] = useState("");
|
||||
const [psk, setPsk] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Single mode state
|
||||
const [selectedNodeIp, setSelectedNodeIp] = useState("");
|
||||
const [isSingleUpdating, setIsSingleUpdating] = useState(false);
|
||||
const [singleResult, setSingleResult] = useState<OtaResult | null>(null);
|
||||
|
||||
// Batch mode state
|
||||
const [selectedBatchIps, setSelectedBatchIps] = useState<Set<string>>(new Set());
|
||||
const [strategy, setStrategy] = useState<OtaStrategy>("sequential");
|
||||
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
|
||||
const [batchResults, setBatchResults] = useState<OtaResult[]>([]);
|
||||
const [batchNodeStates, setBatchNodeStates] = useState<Map<string, BatchNodeState>>(new Map());
|
||||
|
||||
const discoverNodes = useCallback(async () => {
|
||||
setIsDiscovering(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 5000 });
|
||||
setNodes(result);
|
||||
if (result.length === 0) {
|
||||
setError("No nodes discovered. Ensure ESP32 nodes are online and reachable.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
const startSingleOta = async () => {
|
||||
if (!selectedNodeIp || !firmwarePath) return;
|
||||
setIsSingleUpdating(true);
|
||||
setSingleResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<OtaResult>("ota_update", {
|
||||
nodeIp: selectedNodeIp,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setSingleResult(result);
|
||||
} catch (err) {
|
||||
setSingleResult({
|
||||
node_ip: selectedNodeIp,
|
||||
success: false,
|
||||
previous_version: null,
|
||||
new_version: null,
|
||||
duration_ms: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsSingleUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startBatchOta = async () => {
|
||||
const ips = Array.from(selectedBatchIps);
|
||||
if (ips.length === 0 || !firmwarePath) return;
|
||||
setIsBatchUpdating(true);
|
||||
setBatchResults([]);
|
||||
setError(null);
|
||||
|
||||
// Initialize all nodes as queued
|
||||
const initialStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => initialStates.set(ip, "queued"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
// Mark all as uploading while the batch runs
|
||||
ips.forEach((ip) => initialStates.set(ip, "uploading"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
try {
|
||||
const results = await invoke<OtaResult[]>("batch_ota_update", {
|
||||
nodeIps: ips,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setBatchResults(results);
|
||||
|
||||
// Update per-node states from results
|
||||
const finalStates = new Map<string, BatchNodeState>();
|
||||
results.forEach((r) => {
|
||||
finalStates.set(r.node_ip, r.success ? "done" : "failed");
|
||||
});
|
||||
setBatchNodeStates(finalStates);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// Mark all as failed on total failure
|
||||
const failStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => failStates.set(ip, "failed"));
|
||||
setBatchNodeStates(failStates);
|
||||
} finally {
|
||||
setIsBatchUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBatchNode = (ip: string) => {
|
||||
setSelectedBatchIps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ip)) next.delete(ip);
|
||||
else next.add(ip);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedBatchIps.size === nodes.length) {
|
||||
setSelectedBatchIps(new Set());
|
||||
} else {
|
||||
setSelectedBatchIps(new Set(nodes.map((n) => n.ip)));
|
||||
}
|
||||
};
|
||||
|
||||
const nodeLabel = (n: DiscoveredNode) => {
|
||||
const parts = [n.ip];
|
||||
if (n.hostname) parts.push(n.hostname);
|
||||
if (n.firmware_version) parts.push(`v${n.firmware_version}`);
|
||||
return parts.join(" - ");
|
||||
};
|
||||
|
||||
const canStartSingle = selectedNodeIp !== "" && firmwarePath !== "" && !isSingleUpdating;
|
||||
const canStartBatch = selectedBatchIps.size > 0 && firmwarePath !== "" && !isBatchUpdating;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 800 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>OTA Update</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Push firmware updates to ESP32 nodes over the network
|
||||
</p>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div style={{ display: "flex", gap: 0, marginBottom: "var(--space-5)" }}>
|
||||
<TabButton label="Single Node" active={mode === "single"} onClick={() => setMode("single")} side="left" />
|
||||
<TabButton label="Batch OTA" active={mode === "batch"} onClick={() => setMode("batch")} side="right" />
|
||||
</div>
|
||||
|
||||
{error && <div style={bannerStyle("var(--status-error)")}>{error}</div>}
|
||||
|
||||
{/* Node Discovery Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||
<h2 style={sectionTitleStyle}>Discovered Nodes</h2>
|
||||
<button onClick={discoverNodes} style={secondaryBtn} disabled={isDiscovering}>
|
||||
{isDiscovering ? "Scanning..." : nodes.length > 0 ? "Re-scan" : "Discover Nodes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && !isDiscovering && (
|
||||
<p style={{ fontSize: 13, color: "var(--text-muted)", margin: 0 }}>
|
||||
No nodes discovered yet. Click Discover Nodes to scan the network.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "single" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Target Node</label>
|
||||
<select
|
||||
value={selectedNodeIp}
|
||||
onChange={(e) => setSelectedNodeIp(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">Select a node...</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.ip} value={n.ip}>{nodeLabel(n)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "batch" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", marginBottom: "var(--space-2)" }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>Select Nodes</label>
|
||||
<button onClick={toggleAll} style={{ ...linkBtn, fontSize: 11 }}>
|
||||
{selectedBatchIps.size === nodes.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 6 }}>
|
||||
{nodes.map((n) => (
|
||||
<label
|
||||
key={n.ip}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
background: selectedBatchIps.has(n.ip) ? "var(--bg-hover)" : "transparent",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBatchIps.has(n.ip)}
|
||||
onChange={() => toggleBatchNode(n.ip)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
<span style={{ flex: 1, color: "var(--text-primary)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{n.ip}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{n.hostname ?? "unknown"}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
|
||||
{n.firmware_version ? `v${n.firmware_version}` : ""}
|
||||
</span>
|
||||
<StatusDot health={n.health} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)", marginBottom: 0 }}>
|
||||
{selectedBatchIps.size} of {nodes.length} nodes selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Firmware & Config Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Firmware & Configuration</h2>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: mode === "batch" ? "1fr 1fr" : "1fr", gap: "var(--space-4)", marginBottom: "var(--space-2)" }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Pre-Shared Key (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={psk}
|
||||
onChange={(e) => setPsk(e.target.value)}
|
||||
placeholder="Leave blank if none"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
{mode === "batch" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Update Strategy</label>
|
||||
<select value={strategy} onChange={(e) => setStrategy(e.target.value as OtaStrategy)} style={{ width: "100%" }}>
|
||||
{(Object.keys(STRATEGY_LABELS) as OtaStrategy[]).map((s) => (
|
||||
<option key={s} value={s}>{STRATEGY_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4, marginBottom: 0 }}>
|
||||
{strategy === "sequential" && "Updates nodes one at a time."}
|
||||
{strategy === "tdm_safe" && "Respects TDM slots to avoid overlapping transmissions."}
|
||||
{strategy === "parallel" && "Updates all nodes simultaneously (fastest, highest network load)."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-5)" }}>
|
||||
{mode === "single" ? (
|
||||
<button onClick={startSingleOta} disabled={!canStartSingle} style={canStartSingle ? primaryBtn : disabledBtn}>
|
||||
{isSingleUpdating ? "Pushing Update..." : "Push Update"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={startBatchOta} disabled={!canStartBatch} style={canStartBatch ? primaryBtn : disabledBtn}>
|
||||
{isBatchUpdating ? "Updating..." : `Start Batch Update (${selectedBatchIps.size} node${selectedBatchIps.size !== 1 ? "s" : ""})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single Result */}
|
||||
{mode === "single" && singleResult && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Result</h2>
|
||||
<div style={bannerStyle(singleResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{singleResult.success ? "Update Successful" : "Update Failed"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
Node: {singleResult.node_ip}
|
||||
{singleResult.previous_version && ` | Previous: v${singleResult.previous_version}`}
|
||||
{singleResult.new_version && ` | New: v${singleResult.new_version}`}
|
||||
{singleResult.duration_ms > 0 && ` | Duration: ${(singleResult.duration_ms / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
{singleResult.error && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, fontFamily: "var(--font-mono)" }}>
|
||||
{singleResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Progress & Results */}
|
||||
{mode === "batch" && batchNodeStates.size > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>
|
||||
{isBatchUpdating ? "Update Progress" : "Results"}
|
||||
</h2>
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden" }}>
|
||||
{/* Table header */}
|
||||
<div style={tableHeaderRow}>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Node IP</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Status</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Version</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right" }}>Duration</span>
|
||||
</div>
|
||||
{/* Table rows */}
|
||||
{Array.from(batchNodeStates.entries()).map(([ip, state]) => {
|
||||
const result = batchResults.find((r) => r.node_ip === ip);
|
||||
const cfg = STATE_CONFIG[state];
|
||||
return (
|
||||
<div key={ip} style={tableRow}>
|
||||
<span style={{ ...tableCell, flex: 2, fontFamily: "var(--font-mono)" }}>{ip}</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>
|
||||
<NodeStateBadge state={state} />
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 2, fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{result?.previous_version && result?.new_version
|
||||
? `v${result.previous_version} -> v${result.new_version}`
|
||||
: result?.error
|
||||
? <span style={{ color: "var(--status-error)", fontFamily: "var(--font-mono)", fontSize: 11 }}>{result.error}</span>
|
||||
: "--"}
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right", fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{result && result.duration_ms > 0 ? `${(result.duration_ms / 1000).toFixed(1)}s` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{!isBatchUpdating && batchResults.length > 0 && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", gap: "var(--space-4)", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--status-online)" }}>
|
||||
{batchResults.filter((r) => r.success).length} succeeded
|
||||
</span>
|
||||
<span style={{ color: "var(--status-error)" }}>
|
||||
{batchResults.filter((r) => !r.success).length} failed
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{batchResults.length} total
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabButton({ label, active, onClick, side }: { label: string; active: boolean; onClick: () => void; side: "left" | "right" }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? "var(--text-primary)" : "var(--text-muted)",
|
||||
background: active ? "var(--bg-surface)" : "transparent",
|
||||
border: `1px solid ${active ? "var(--border-active)" : "var(--border)"}`,
|
||||
borderRadius: side === "left" ? "6px 0 0 6px" : "0 6px 6px 0",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ health }: { health: string }) {
|
||||
const color =
|
||||
health === "online" ? "var(--status-online)" :
|
||||
health === "degraded" ? "var(--status-warning)" :
|
||||
health === "offline" ? "var(--status-error)" :
|
||||
"var(--text-muted)";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStateBadge({ state }: { state: BatchNodeState }) {
|
||||
const cfg = STATE_CONFIG[state];
|
||||
const isAnimating = state === "uploading" || state === "rebooting" || state === "verifying";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: cfg.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: cfg.color,
|
||||
animation: isAnimating ? "pulse-accent 1.5s infinite" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bannerStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const disabledBtn: React.CSSProperties = {
|
||||
...primaryBtn,
|
||||
background: "var(--bg-active)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "not-allowed",
|
||||
};
|
||||
|
||||
const linkBtn: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const tableHeaderRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
const tableRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const tableCell: React.CSSProperties = {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
};
|
||||
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useServer } from "../hooks/useServer";
|
||||
import type { SensingUpdate } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log entry model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string; // HH:MM:SS.mmm
|
||||
level: LogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_LOG_TEMPLATES: { level: LogLevel; source: string; message: string }[] = [
|
||||
{ level: "INFO", source: "sensing-server", message: "HTTP listening on 127.0.0.1:8080" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.42" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Low signal quality on node 2" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: walking (confidence: 0.87)" },
|
||||
{ level: "ERROR", source: "ws_session", message: "Client disconnected unexpectedly" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.15" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sitting (confidence: 0.93)" },
|
||||
{ level: "INFO", source: "sensing-server", message: "WebSocket client connected from 127.0.0.1" },
|
||||
{ level: "WARN", source: "mesh_sync", message: "Node 4 heartbeat delayed by 1200ms" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: standing (confidence: 0.91)" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.78" },
|
||||
{ level: "ERROR", source: "udp_receiver", message: "Malformed CSI payload (len=0)" },
|
||||
{ level: "INFO", source: "csi_pipeline", message: "Subcarrier FFT complete (52 bins)" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Breathing rate out of range on node 5" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sleeping (confidence: 0.78)" },
|
||||
];
|
||||
|
||||
const MOCK_ACTIVITIES = [
|
||||
{ activity: "walking", confidence: 0.87 },
|
||||
{ activity: "sitting", confidence: 0.93 },
|
||||
{ activity: "standing", confidence: 0.91 },
|
||||
{ activity: "sleeping", confidence: 0.78 },
|
||||
{ activity: "exercising", confidence: 0.65 },
|
||||
];
|
||||
|
||||
function formatTimestamp(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
let nextLogId = 1;
|
||||
|
||||
function createMockLogEntry(): LogEntry {
|
||||
const template = MOCK_LOG_TEMPLATES[Math.floor(Math.random() * MOCK_LOG_TEMPLATES.length)];
|
||||
return {
|
||||
id: nextLogId++,
|
||||
timestamp: formatTimestamp(new Date()),
|
||||
level: template.level,
|
||||
source: template.source,
|
||||
message: template.message,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSensingUpdate(): SensingUpdate {
|
||||
const act = MOCK_ACTIVITIES[Math.floor(Math.random() * MOCK_ACTIVITIES.length)];
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
node_id: Math.floor(Math.random() * 6) + 1,
|
||||
subcarrier_count: 52,
|
||||
rssi: -(Math.floor(Math.random() * 40) + 30),
|
||||
activity: act.activity,
|
||||
confidence: parseFloat((act.confidence + (Math.random() * 0.1 - 0.05)).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const LOG_INTERVAL_MS = 2000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LogViewer component (ADR-053)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LEVEL_COLOR: Record<LogLevel, string> = {
|
||||
INFO: "var(--text-secondary)",
|
||||
WARN: "var(--status-warning)",
|
||||
ERROR: "var(--status-error)",
|
||||
};
|
||||
|
||||
function LogViewer({
|
||||
entries,
|
||||
onClear,
|
||||
paused,
|
||||
onTogglePause,
|
||||
}: {
|
||||
entries: LogEntry[];
|
||||
onClear: () => void;
|
||||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [entries, paused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--bg-elevated)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Server Log
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: paused ? "var(--status-warning)" : "var(--bg-hover)",
|
||||
color: paused ? "#000" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-hover)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflowY: "auto",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div style={{ color: "var(--text-muted)", padding: "var(--space-4)", textAlign: "center" }}>
|
||||
No log entries yet.
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.id} style={{ whiteSpace: "nowrap" }}>
|
||||
<span style={{ color: "var(--text-muted)" }}>{entry.timestamp}</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
color: LEVEL_COLOR[entry.level],
|
||||
fontWeight: entry.level === "ERROR" ? 700 : 500,
|
||||
display: "inline-block",
|
||||
minWidth: 40,
|
||||
}}
|
||||
>
|
||||
{entry.level}
|
||||
</span>{" "}
|
||||
<span style={{ color: "var(--accent)" }}>{entry.source}</span>{" "}
|
||||
<span style={{ color: LEVEL_COLOR[entry.level] }}>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensing page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Sensing: React.FC = () => {
|
||||
const { status, isRunning, error, start, stop } = useServer({ pollInterval: 5000 });
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
// Log viewer state
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
|
||||
// Activity feed state
|
||||
const [activities, setActivities] = useState<SensingUpdate[]>([]);
|
||||
|
||||
// Simulated log feed
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (pausedRef.current) return;
|
||||
const entry = createMockLogEntry();
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next;
|
||||
});
|
||||
|
||||
// Also push an activity update every ~3rd tick
|
||||
if (Math.random() < 0.35) {
|
||||
setActivities((prev) => {
|
||||
const update = createMockSensingUpdate();
|
||||
const next = [update, ...prev];
|
||||
return next.slice(0, 5);
|
||||
});
|
||||
}
|
||||
}, LOG_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleClearLog = useCallback(() => setLogEntries([]), []);
|
||||
const handleTogglePause = useCallback(() => setPaused((p) => !p), []);
|
||||
|
||||
const handleStart = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await start();
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await stop();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)" }}>
|
||||
{/* Page header */}
|
||||
<h2 className="heading-lg" style={{ marginBottom: "var(--space-5)" }}>
|
||||
Sensing
|
||||
</h2>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 1: Server Control */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Left: status info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: isRunning ? "var(--status-online)" : "var(--status-error)",
|
||||
boxShadow: isRunning ? "0 0 6px var(--status-online)" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||
Sensing Server
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
{isRunning ? "Running" : "Stopped"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running details */}
|
||||
{isRunning && status && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-4)",
|
||||
marginLeft: "var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{status.pid != null && <span>PID {status.pid}</span>}
|
||||
{status.http_port != null && <span>HTTP :{status.http_port}</span>}
|
||||
{status.ws_port != null && <span>WS :{status.ws_port}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: action button */}
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={starting || stopping}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting || stopping ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
background: isRunning ? "var(--status-error)" : "var(--accent)",
|
||||
color: "#fff",
|
||||
opacity: starting || stopping ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "rgba(255,59,48,0.1)",
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: "var(--status-error)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 2: Log Viewer (ADR-053) */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div style={{ marginBottom: "var(--space-5)" }}>
|
||||
<LogViewer
|
||||
entries={logEntries}
|
||||
onClear={handleClearLog}
|
||||
paused={paused}
|
||||
onTogglePause={handleTogglePause}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 3: Activity Feed */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
Activity Feed
|
||||
</h3>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center", padding: "var(--space-4)" }}>
|
||||
Waiting for sensing data...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
{activities.map((update, i) => {
|
||||
const ts = new Date(update.timestamp);
|
||||
const conf = update.confidence ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={`${update.timestamp}-${i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 72,
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(ts)}
|
||||
</span>
|
||||
|
||||
{/* Node ID */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 48,
|
||||
}}
|
||||
>
|
||||
Node {update.node_id}
|
||||
</span>
|
||||
|
||||
{/* Activity */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 80,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{update.activity ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: "var(--bg-hover)",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.round(conf * 100)}%`,
|
||||
height: "100%",
|
||||
background: conf >= 0.8 ? "var(--status-online)" : conf >= 0.6 ? "var(--status-warning)" : "var(--status-error)",
|
||||
borderRadius: 3,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence value */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 36,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sensing;
|
||||
@@ -0,0 +1,276 @@
|
||||
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: "127.0.0.1",
|
||||
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);
|
||||
|
||||
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
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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: "var(--space-5)", maxWidth: 600 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Settings</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Configure server, network, and application preferences
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(63, 185, 80, 0.1)",
|
||||
border: "1px solid rgba(63, 185, 80, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-online)",
|
||||
}}
|
||||
>
|
||||
Settings saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensing Server */}
|
||||
<Section title="Sensing Server">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||
<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">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.bind_address}
|
||||
onChange={(e) => update("bind_address", e.target.value)}
|
||||
placeholder="127.0.0.1"
|
||||
style={{ fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: "var(--space-4)" }}>
|
||||
<Field label="UI Static Files Path">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ui_path}
|
||||
onChange={(e) => update("ui_path", e.target.value)}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Security */}
|
||||
<Section title="Security">
|
||||
<Field label="OTA Pre-Shared Key (PSK)">
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input
|
||||
type={showPsk ? "text" : "password"}
|
||||
value={settings.ota_psk}
|
||||
onChange={(e) => update("ota_psk", e.target.value)}
|
||||
placeholder="Enter PSK for OTA authentication"
|
||||
style={{ flex: 1, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<button onClick={() => setShowPsk((prev) => !prev)} style={secondaryBtn}>
|
||||
{showPsk ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)" }}>
|
||||
Used for authenticating OTA firmware updates to nodes.
|
||||
</p>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Discovery */}
|
||||
<Section title="Network Discovery">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||
<Field label="Auto-Discover">
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.auto_discover}
|
||||
onChange={(e) => update("auto_discover", e.target.checked)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>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: "var(--space-5)" }}>
|
||||
<button onClick={reset} style={secondaryBtn}>Reset to Defaults</button>
|
||||
<button onClick={save} style={primaryBtn}>Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
marginBottom: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 var(--space-4)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared styles ---
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user