merge: resolve icon conflicts — keep RGBA versions (fixes #200)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-09 10:51:49 -04:00
commit d949d9e57b
51 changed files with 18179 additions and 48 deletions

View File

@ -77,6 +77,7 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
| [Architecture Decisions](docs/adr/README.md) | 48 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 7 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [Desktop App](rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
---

View File

@ -0,0 +1,621 @@
# ADR-052 Appendix: DDD Bounded Contexts — Tauri Desktop Frontend
This document maps out the domain model for the RuView Tauri desktop application
described in ADR-052. It defines bounded contexts, their aggregates, entities,
value objects, and the domain events flowing between them.
## Context Map
```
+-------------------+ +---------------------+ +--------------------+
| | | | | |
| Device Discovery |------>| Firmware Management |------>| Configuration / |
| | | | | Provisioning |
+-------------------+ +---------------------+ +--------------------+
| | |
| | |
v v v
+-------------------+ +---------------------+ +--------------------+
| | | | | |
| Sensing Pipeline |<------| Edge Module | | Visualization |
| | | (WASM) | | |
+-------------------+ +---------------------+ +--------------------+
Relationship types:
-----> Upstream/Downstream (upstream publishes events, downstream consumes)
<----- Conformist (downstream conforms to upstream's model)
```
---
## 1. Device Discovery Context
**Purpose**: Find, identify, and monitor ESP32 CSI nodes on the local network.
**Upstream of**: Firmware Management, Configuration, Sensing Pipeline, Visualization
### Aggregates
#### `NodeRegistry` (Aggregate Root)
Maintains the authoritative list of all known nodes. Merges discovery results
from multiple strategies (mDNS, UDP probe, HTTP sweep) and deduplicates by MAC
address.
| Field | Type | Description |
|-------|------|-------------|
| `nodes` | `Map<MacAddress, Node>` | All discovered nodes keyed by MAC |
| `scan_state` | `ScanState` | Idle, Scanning, Error |
| `last_scan` | `DateTime<Utc>` | Timestamp of last completed scan |
**Invariant**: No two nodes may share the same MAC address. If a node is
discovered via multiple strategies, the most recent data wins.
**Persistence**: The registry is persisted to `~/.ruview/nodes.db` (SQLite via
`rusqlite`). On startup, all previously known nodes are loaded as `Offline` and
reconciled against a fresh discovery scan. This means the app **remembers the
mesh** across restarts — critical for field deployments where nodes may be
temporarily powered off.
#### `Node` (Entity)
| Field | Type | Description |
|-------|------|-------------|
| `mac` | `MacAddress` (VO) | IEEE 802.11 MAC address (unique identity) |
| `ip` | `IpAddr` | Current IP address (may change on DHCP renewal) |
| `hostname` | `Option<String>` | mDNS hostname |
| `node_id` | `u8` | NVS-provisioned node ID |
| `firmware_version` | `Option<SemVer>` | Firmware version string |
| `health` | `HealthStatus` (VO) | Online / Offline / Degraded |
| `discovery_method` | `DiscoveryMethod` (VO) | How this node was found |
| `last_seen` | `DateTime<Utc>` | Last successful contact |
| `tdm_config` | `Option<TdmConfig>` (VO) | TDM slot assignment |
| `edge_tier` | `Option<u8>` | Edge processing tier (0/1/2) |
### Value Objects
- `MacAddress` — 6-byte hardware address, formatted as `AA:BB:CC:DD:EE:FF`
- `HealthStatus` — enum: `Online`, `Offline`, `Degraded(reason: String)`
- `DiscoveryMethod` — enum: `Mdns`, `UdpProbe`, `HttpSweep`, `Manual`
- `TdmConfig``{ slot_index: u8, total_nodes: u8 }`
- `SemVer` — semantic version `major.minor.patch`
### Domain Events
| Event | Payload | Consumers |
|-------|---------|-----------|
| `NodeDiscovered` | `{ node: Node }` | Firmware Mgmt (check for updates), Visualization (add to mesh graph) |
| `NodeWentOffline` | `{ mac: MacAddress, last_seen: DateTime }` | Visualization (gray out node), Sensing Pipeline (remove from active set) |
| `NodeCameOnline` | `{ node: Node }` | Visualization (restore node), Sensing Pipeline (re-add) |
| `NodeHealthChanged` | `{ mac: MacAddress, old: HealthStatus, new: HealthStatus }` | Visualization (update indicator) |
| `ScanCompleted` | `{ found: usize, new: usize, lost: usize }` | Dashboard (update summary) |
### Anti-Corruption Layer
When receiving data from the ESP32 OTA status endpoint (`GET /ota/status`), the
response format is owned by the firmware and may change across firmware versions.
The ACL translates the raw JSON response into `Node` entity fields:
```rust
/// ACL: Translate ESP32 OTA status response to Node fields.
fn translate_ota_status(raw: &serde_json::Value) -> Result<NodePatch, AclError> {
NodePatch {
firmware_version: raw["version"].as_str().map(SemVer::parse).transpose()?,
uptime_secs: raw["uptime_s"].as_u64(),
free_heap: raw["free_heap"].as_u64(),
// Firmware may add fields in future versions — unknown fields are ignored
}
}
```
---
## 2. Firmware Management Context
**Purpose**: Flash, update, and verify firmware on ESP32 nodes.
**Upstream of**: Configuration (a fresh flash triggers provisioning)
**Downstream of**: Device Discovery (needs node list and serial port info)
### Aggregates
#### `FlashSession` (Aggregate Root)
Represents a single firmware flashing operation from start to completion. Each
session has a lifecycle: Created -> Connecting -> Erasing -> Writing -> Verifying ->
Completed | Failed.
| Field | Type | Description |
|-------|------|-------------|
| `id` | `Uuid` | Session identifier |
| `port` | `SerialPort` (VO) | Target serial port |
| `firmware` | `FirmwareBinary` (Entity) | The binary being flashed |
| `chip` | `ChipType` (VO) | Target chip (ESP32, ESP32-S3, ESP32-C3) |
| `phase` | `FlashPhase` (VO) | Current phase of the flash operation |
| `progress` | `Progress` (VO) | Bytes written / total, speed |
| `started_at` | `DateTime<Utc>` | When the session started |
| `error` | `Option<String>` | Error message if failed |
**Invariant**: Only one `FlashSession` may be active per serial port at a time.
#### `FirmwareBinary` (Entity)
| Field | Type | Description |
|-------|------|-------------|
| `path` | `PathBuf` | Filesystem path to the `.bin` file |
| `size_bytes` | `u64` | Binary size |
| `version` | `Option<SemVer>` | Extracted from ESP32 image header |
| `chip_type` | `Option<ChipType>` | Detected from image magic bytes |
| `checksum` | `Sha256Hash` (VO) | SHA-256 of the binary |
#### `OtaSession` (Aggregate Root)
Represents an over-the-air firmware update to a running node.
| Field | Type | Description |
|-------|------|-------------|
| `id` | `Uuid` | Session identifier |
| `target_node` | `MacAddress` | Target node MAC |
| `target_ip` | `IpAddr` | Target node IP |
| `firmware` | `FirmwareBinary` | The binary being pushed |
| `psk` | `Option<SecureString>` | PSK for authentication (ADR-050) |
| `phase` | `OtaPhase` | Uploading / Rebooting / Verifying / Done / Failed |
| `progress` | `Progress` | Upload progress |
#### `BatchOtaSession` (Aggregate Root)
Coordinates rolling firmware updates across multiple mesh nodes. Prevents all
nodes from rebooting simultaneously, which would collapse the sensing network.
| Field | Type | Description |
|-------|------|-------------|
| `id` | `Uuid` | Batch session identifier |
| `firmware` | `FirmwareBinary` | The binary being deployed |
| `strategy` | `OtaStrategy` | `Sequential`, `TdmSafe`, `Parallel` |
| `max_concurrent` | `usize` | Max nodes updating at once |
| `batch_delay_secs` | `u64` | Delay between batches |
| `fail_fast` | `bool` | Abort remaining on first failure |
| `node_states` | `Map<MacAddress, BatchNodeState>` | Per-node progress |
**Invariant**: In `TdmSafe` mode, adjacent TDM slots are never updated
concurrently. Even-slot nodes update first, then odd-slot nodes.
**Lifecycle**: `Planning → InProgress → Completed | PartialFailure | Aborted`
- `BatchNodeState` — enum: `Queued`, `Uploading(Progress)`, `Rebooting`, `Verifying`, `Done`, `Failed(String)`, `Skipped`
- `OtaStrategy` — enum:
- `Sequential` — one node at a time, wait for rejoin
- `TdmSafe` — update non-adjacent slots to maintain sensing coverage
- `Parallel` — all at once (development only)
### Value Objects
- `SerialPort``{ name: String, vid: u16, pid: u16, manufacturer: Option<String> }`
- `ChipType` — enum: `Esp32`, `Esp32s3`, `Esp32c3`
- `FlashPhase` — enum: `Connecting`, `Erasing`, `Writing`, `Verifying`, `Completed`, `Failed`
- `OtaPhase` — enum: `Uploading`, `Rebooting`, `Verifying`, `Completed`, `Failed`
- `Progress``{ bytes_done: u64, bytes_total: u64, speed_bps: u64 }`
- `Sha256Hash` — 32-byte hash
- `SecureString` — zeroized-on-drop string for PSK tokens
### Domain Events
| Event | Payload | Consumers |
|-------|---------|-----------|
| `FlashStarted` | `{ session_id, port, firmware_version }` | UI (show progress) |
| `FlashProgress` | `{ session_id, phase, progress }` | UI (update progress bar) |
| `FlashCompleted` | `{ session_id, duration_secs }` | Configuration (trigger provisioning prompt) |
| `FlashFailed` | `{ session_id, error }` | UI (show error) |
| `OtaStarted` | `{ session_id, target_mac, firmware_version }` | Discovery (mark node as updating) |
| `OtaCompleted` | `{ session_id, target_mac, new_version }` | Discovery (refresh node info) |
| `OtaFailed` | `{ session_id, target_mac, error }` | UI (show error) |
| `BatchOtaStarted` | `{ batch_id, strategy, node_count }` | UI (show batch progress) |
| `BatchNodeUpdated` | `{ batch_id, mac, state }` | UI (update per-node status), Discovery (refresh) |
| `BatchOtaCompleted` | `{ batch_id, succeeded, failed, skipped }` | UI (show summary), Discovery (full rescan) |
### Anti-Corruption Layer
The `espflash` crate has its own error types and progress reporting model. The
ACL translates these into domain events:
```rust
/// ACL: Translate espflash progress callbacks to domain FlashProgress events.
impl From<espflash::ProgressCallbackMessage> for FlashProgress {
fn from(msg: espflash::ProgressCallbackMessage) -> Self {
match msg {
espflash::ProgressCallbackMessage::Connecting => FlashProgress {
phase: FlashPhase::Connecting,
progress: Progress::indeterminate(),
},
espflash::ProgressCallbackMessage::Erasing { addr, total } => FlashProgress {
phase: FlashPhase::Erasing,
progress: Progress::new(addr as u64, total as u64),
},
// ... etc
}
}
}
```
---
## 3. Configuration / Provisioning Context
**Purpose**: Manage NVS configuration for ESP32 nodes — WiFi credentials, network
targets, TDM mesh settings, edge intelligence parameters, WASM security keys.
**Downstream of**: Device Discovery (needs serial port), Firmware Management (post-flash provisioning)
### Aggregates
#### `ProvisioningSession` (Aggregate Root)
Represents a single NVS write or read operation on a connected ESP32.
| Field | Type | Description |
|-------|------|-------------|
| `id` | `Uuid` | Session identifier |
| `port` | `SerialPort` (VO) | Target serial port |
| `config` | `NodeConfig` (Entity) | Configuration to write |
| `direction` | `Direction` | Read or Write |
| `phase` | `ProvisionPhase` | Generating / Flashing / Verifying / Done |
#### `NodeConfig` (Entity)
The full set of NVS key-value pairs for a single node. Maps directly to the
firmware's `nvs_config_t` struct (see `firmware/esp32-csi-node/main/nvs_config.h`).
| Field | Type | NVS Key | Description |
|-------|------|---------|-------------|
| `wifi_ssid` | `Option<String>` | `ssid` | WiFi SSID |
| `wifi_password` | `Option<SecureString>` | `password` | WiFi password |
| `target_ip` | `Option<IpAddr>` | `target_ip` | Aggregator IP |
| `target_port` | `Option<u16>` | `target_port` | Aggregator UDP port |
| `node_id` | `Option<u8>` | `node_id` | Node identifier |
| `tdm_slot` | `Option<u8>` | `tdm_slot` | TDM slot index |
| `tdm_total` | `Option<u8>` | `tdm_nodes` | Total TDM nodes |
| `edge_tier` | `Option<u8>` | `edge_tier` | Processing tier |
| `hop_count` | `Option<u8>` | `hop_count` | Channel hop count |
| `channel_list` | `Option<Vec<u8>>` | `chan_list` | Channel sequence |
| `dwell_ms` | `Option<u32>` | `dwell_ms` | Hop dwell time |
| `power_duty` | `Option<u8>` | `power_duty` | Power duty cycle |
| `presence_thresh` | `Option<u16>` | `pres_thresh` | Presence threshold |
| `fall_thresh` | `Option<u16>` | `fall_thresh` | Fall detection threshold |
| `vital_window` | `Option<u16>` | `vital_win` | Vital sign window |
| `vital_interval_ms` | `Option<u16>` | `vital_int` | Vital sign interval |
| `top_k_count` | `Option<u8>` | `subk_count` | Top-K subcarriers |
| `wasm_max_modules` | `Option<u8>` | `wasm_max` | Max WASM modules |
| `wasm_verify` | `Option<bool>` | `wasm_verify` | Require WASM signature |
| `wasm_pubkey` | `Option<[u8; 32]>` | `wasm_pubkey` | Ed25519 public key |
| `ota_psk` | `Option<SecureString>` | `ota_psk` | OTA pre-shared key |
**Invariant**: `tdm_slot < tdm_total` when both are set.
**Invariant**: `channel_list.len() == hop_count` when both are set.
**Invariant**: `10 <= power_duty <= 100`.
#### `MeshConfig` (Entity)
A mesh-level configuration that generates per-node `NodeConfig` instances.
Corresponds to ADR-044 Phase 2 (config file provisioning).
| Field | Type | Description |
|-------|------|-------------|
| `common` | `NodeConfig` | Shared settings (WiFi, target IP, edge tier) |
| `nodes` | `Vec<MeshNodeEntry>` | Per-node overrides (port, node_id, tdm_slot) |
```rust
pub struct MeshNodeEntry {
pub port: String,
pub node_id: u8,
pub tdm_slot: u8,
// All other fields inherited from common
}
```
**Invariant**: `tdm_total` is automatically computed as `nodes.len()`.
### Value Objects
- `ProvisionPhase` — enum: `Generating`, `Flashing`, `Verifying`, `Completed`, `Failed`
- `Direction` — enum: `Read`, `Write`
- `Preset` — enum: `Basic`, `Vitals`, `Mesh3`, `Mesh6Vitals` (ADR-044 Phase 3)
### Domain Events
| Event | Payload | Consumers |
|-------|---------|-----------|
| `NodeProvisioned` | `{ port, node_id, config_summary }` | Discovery (trigger re-scan), UI (show success) |
| `NvsReadCompleted` | `{ port, config: NodeConfig }` | UI (populate form) |
| `ProvisionFailed` | `{ port, error }` | UI (show error) |
| `MeshProvisionStarted` | `{ node_count }` | UI (show batch progress) |
| `MeshProvisionCompleted` | `{ success_count, fail_count }` | UI (show summary) |
---
## 4. Sensing Pipeline Context
**Purpose**: Control the sensing server process, receive real-time CSI data, and
manage the signal processing pipeline.
**Downstream of**: Device Discovery (needs node IPs for data attribution)
### Aggregates
#### `SensingServer` (Aggregate Root)
Represents the managed sensing server child process.
| Field | Type | Description |
|-------|------|-------------|
| `state` | `ServerState` (VO) | Stopped / Starting / Running / Stopping / Crashed |
| `config` | `ServerConfig` (VO) | Port configuration, log level, model paths |
| `pid` | `Option<u32>` | OS process ID when running |
| `started_at` | `Option<DateTime<Utc>>` | Start timestamp |
| `log_buffer` | `RingBuffer<LogEntry>` | Last N log lines |
| `ws_url` | `Option<Url>` | WebSocket URL for live data |
**Invariant**: Only one `SensingServer` process may be managed at a time.
#### `SensingSession` (Entity)
An active connection to the sensing server's WebSocket for receiving real-time data.
| Field | Type | Description |
|-------|------|-------------|
| `connection_state` | `WsState` | Connecting / Connected / Disconnected |
| `frames_received` | `u64` | Total CSI frames received this session |
| `last_frame_at` | `Option<DateTime<Utc>>` | Timestamp of last received frame |
| `subscriptions` | `HashSet<DataChannel>` | Which data streams are active |
### Value Objects
- `ServerState` — enum: `Stopped`, `Starting`, `Running`, `Stopping`, `Crashed(exit_code: i32)`
- `ServerConfig``{ http_port: u16, ws_port: u16, udp_port: u16, model_dir: PathBuf, log_level: Level }`
- `LogEntry``{ timestamp: DateTime, level: Level, target: String, message: String }`
- `DataChannel` — enum: `CsiFrames`, `PoseUpdates`, `VitalSigns`, `ActivityClassification`
- `WsState` — enum: `Connecting`, `Connected`, `Disconnected(reason: String)`
### Domain Events
| Event | Payload | Consumers |
|-------|---------|-----------|
| `ServerStarted` | `{ pid, ports: ServerConfig }` | UI (enable sensing view), Discovery (start health polling via WS) |
| `ServerStopped` | `{ exit_code, uptime_secs }` | UI (disable sensing view) |
| `ServerCrashed` | `{ exit_code, last_log_lines }` | UI (show crash report) |
| `CsiFrameReceived` | `{ node_id, timestamp, subcarrier_count }` | Visualization (update charts) |
| `PoseUpdated` | `{ persons: Vec<PersonPose> }` | Visualization (draw skeletons) |
| `VitalSignUpdate` | `{ node_id, bpm, breath_rate }` | Visualization (update vitals chart) |
| `ActivityDetected` | `{ label, confidence }` | Visualization (show activity) |
---
## 5. Edge Module (WASM) Context
**Purpose**: Upload, manage, and monitor WASM edge processing modules running
on ESP32 nodes.
**Downstream of**: Device Discovery (needs node IPs and WASM capability info)
**Upstream of**: Sensing Pipeline (WASM modules emit edge-processed events)
### Aggregates
#### `ModuleRegistry` (Aggregate Root)
Tracks all WASM modules across all nodes.
| Field | Type | Description |
|-------|------|-------------|
| `modules` | `Map<(MacAddress, ModuleId), WasmModule>` | Per-node module inventory |
#### `WasmModule` (Entity)
| Field | Type | Description |
|-------|------|-------------|
| `id` | `ModuleId` (VO) | Node-assigned module identifier |
| `name` | `String` | Filename of the uploaded `.wasm` |
| `size_bytes` | `u64` | Module size |
| `status` | `ModuleStatus` (VO) | Loaded / Running / Stopped / Error |
| `node_mac` | `MacAddress` | Which node this module runs on |
| `uploaded_at` | `DateTime<Utc>` | Upload timestamp |
| `signed` | `bool` | Whether the module has an Ed25519 signature |
### Value Objects
- `ModuleId` — string identifier assigned by the node firmware
- `ModuleStatus` — enum: `Loaded`, `Running`, `Stopped`, `Error(String)`
### Domain Events
| Event | Payload | Consumers |
|-------|---------|-----------|
| `ModuleUploaded` | `{ node_mac, module_id, name, size }` | UI (refresh list) |
| `ModuleStarted` | `{ node_mac, module_id }` | UI (update status) |
| `ModuleStopped` | `{ node_mac, module_id }` | UI (update status) |
| `ModuleUnloaded` | `{ node_mac, module_id }` | UI (remove from list) |
| `ModuleError` | `{ node_mac, module_id, error }` | UI (show error) |
### Anti-Corruption Layer
The ESP32 WASM management HTTP API (`/wasm/*` on port 8032) returns raw JSON
with firmware-specific field names. The ACL normalizes these:
```rust
/// ACL: Translate ESP32 WASM list response to domain WasmModule entities.
fn translate_wasm_list(raw: &[serde_json::Value]) -> Vec<WasmModule> {
raw.iter().filter_map(|entry| {
Some(WasmModule {
id: ModuleId(entry["id"].as_str()?.to_string()),
name: entry["name"].as_str().unwrap_or("unknown").to_string(),
size_bytes: entry["size"].as_u64().unwrap_or(0),
status: match entry["state"].as_str() {
Some("running") => ModuleStatus::Running,
Some("stopped") => ModuleStatus::Stopped,
Some("loaded") => ModuleStatus::Loaded,
other => ModuleStatus::Error(
format!("Unknown state: {:?}", other)
),
},
// ...
})
}).collect()
}
```
---
## 6. Visualization Context
**Purpose**: Render real-time and historical sensing data — CSI heatmaps, pose
skeletons, vital sign charts, mesh topology graphs.
**Downstream of**: Sensing Pipeline (receives data events), Device Discovery (needs
node metadata for labeling)
This context is **purely presentational** and contains no domain logic. It
transforms domain events from other contexts into visual representations.
### Aggregates
None — this context is a **Query Model** (CQRS read side). It subscribes to
domain events and projects them into view models.
### View Models
#### `DashboardView`
| Field | Source Context | Description |
|-------|---------------|-------------|
| `nodes` | Device Discovery | Node cards with health, version, signal quality |
| `server` | Sensing Pipeline | Server status, uptime, port info |
| `recent_activity` | All contexts | Timeline of recent events |
#### `SignalView`
| Field | Source Context | Description |
|-------|---------------|-------------|
| `csi_heatmap` | Sensing Pipeline | Subcarrier amplitude x time matrix |
| `signal_field` | Sensing Pipeline | 2D signal strength grid |
| `activity_label` | Sensing Pipeline | Current classification |
| `confidence` | Sensing Pipeline | Classification confidence |
#### `PoseView`
| Field | Source Context | Description |
|-------|---------------|-------------|
| `persons` | Sensing Pipeline | Array of detected person skeletons |
| `zones` | Sensing Pipeline | Active zones in the sensing area |
#### `VitalsView`
| Field | Source Context | Description |
|-------|---------------|-------------|
| `breathing_rate_bpm` | Sensing Pipeline | Per-node breathing rate time series |
| `heart_rate_bpm` | Sensing Pipeline | Per-node heart rate time series |
#### `MeshView`
| Field | Source Context | Description |
|-------|---------------|-------------|
| `nodes` | Device Discovery | Positioned nodes for graph layout |
| `edges` | Device Discovery | Inter-node visibility/connectivity |
| `tdm_timeline` | Device Discovery | TDM slot schedule visualization |
| `sync_status` | Sensing Pipeline | Per-node sync status with server |
---
## Cross-Context Event Flow
```
NodeDiscovered
Device Discovery ─────────────────────────────────> Firmware Management
│ │
│ NodeDiscovered │ FlashCompleted
│ NodeHealthChanged │
├──────────────────> Visualization v
│ Configuration
│ NodeDiscovered │
├──────────────────> Sensing Pipeline │ NodeProvisioned
│ │
│ v
│ Device Discovery
│ (re-scan triggered)
│ NodeDiscovered
└──────────────────> Edge Module (WASM)
│ ModuleUploaded, ModuleStarted
v
Sensing Pipeline
│ CsiFrameReceived, PoseUpdated, VitalSignUpdate
v
Visualization
```
## Implementation Notes
1. **Event Bus**: Domain events are dispatched via Tauri's event system
(`app_handle.emit("event-name", payload)`). The frontend subscribes using
`listen("event-name", callback)`. This provides natural cross-context
communication without coupling contexts directly.
2. **State Isolation**: Each bounded context maintains its own `State<'_, T>`
managed by Tauri. Contexts do not share mutable state directly — they
communicate exclusively through events.
3. **Module Organization**: Each bounded context maps to a Rust module under
`src/commands/` and `src/domain/`:
```
src/
commands/ # Tauri command handlers (application layer)
discovery.rs # Device Discovery context commands
flash.rs # Firmware Management context commands
ota.rs # Firmware Management context commands
provision.rs # Configuration context commands
server.rs # Sensing Pipeline context commands
wasm.rs # Edge Module context commands
domain/ # Domain models (pure Rust, no Tauri dependency)
discovery/
mod.rs
node.rs # Node entity, MacAddress VO
registry.rs # NodeRegistry aggregate
events.rs # Discovery domain events
firmware/
mod.rs
binary.rs # FirmwareBinary entity
flash.rs # FlashSession aggregate
ota.rs # OtaSession aggregate
events.rs
config/
mod.rs
nvs.rs # NodeConfig entity
mesh.rs # MeshConfig entity
provision.rs # ProvisioningSession aggregate
events.rs
sensing/
mod.rs
server.rs # SensingServer aggregate
session.rs # SensingSession entity
events.rs
wasm/
mod.rs
module.rs # WasmModule entity
registry.rs # ModuleRegistry aggregate
events.rs
acl/ # Anti-corruption layers
ota_status.rs # ESP32 OTA status response translator
wasm_api.rs # ESP32 WASM API response translator
espflash.rs # espflash crate adapter
```
4. **Testing Strategy**: Domain modules under `src/domain/` have no Tauri
dependency and can be tested with standard `cargo test`. Command handlers
under `src/commands/` require Tauri test utilities for integration testing.
5. **Shared Kernel**: The `MacAddress`, `SemVer`, and `SecureString` value objects
are shared across contexts. They live in a `src/domain/shared.rs` module.
This is acceptable because they are immutable value objects with no behavior
beyond validation and formatting.

View File

@ -0,0 +1,810 @@
# ADR-052: Tauri Desktop Frontend — RuView Hardware Management & Visualization
| Field | Value |
|-------|-------|
| Status | Proposed |
| Date | 2026-03-06 |
| Deciders | ruv |
| Depends on | ADR-012 (ESP32 CSI Mesh), ADR-039 (Edge Intelligence), ADR-040 (WASM Programmable Sensing), ADR-044 (Provisioning Enhancements), ADR-050 (Security Hardening), ADR-051 (Server Decomposition) |
| Issue | [#177](https://github.com/ruvnet/RuView/issues/177) |
## Context
RuView currently requires users to interact with multiple disconnected tools to manage a WiFi DensePose deployment:
| Task | Current Tool | Pain Point |
|------|-------------|------------|
| Flash firmware | `esptool.py` CLI | Requires Python, pip, correct chip/baud flags |
| Provision NVS | `provision.py` CLI | 13+ flags, no GUI, no read-back |
| OTA update | `curl POST :8032/ota` | Manual HTTP, PSK header construction |
| WASM modules | `curl` to `:8032/wasm/*` | No visibility into module state |
| Start sensing server | `cargo run` or binary | Manual port configuration, no log viewer |
| View sensing data | Browser at `localhost:8080` | Separate window, no hardware context |
| Mesh topology | Mental model | No visualization of TDM slots, sync, health |
| Node discovery | Manual IP tracking | No mDNS/UDP broadcast discovery |
There is no single tool that provides a unified view of the entire deployment — from ESP32 hardware through the sensing pipeline to pose visualization. Field operators deploying multi-node meshes must context-switch between terminals, browsers, and serial monitors.
### Why a Desktop App
A browser-based UI cannot access serial ports (for flashing), raw UDP sockets (for node discovery), or the local filesystem (for firmware binaries). A desktop application is required for hardware management. Tauri v2 is the natural choice because:
1. **Rust backend** — integrates directly with the existing Rust workspace (`wifi-densepose-rs`). Crates like `wifi-densepose-hardware` (serial port parsing), `wifi-densepose-config`, and `wifi-densepose-sensing-server` can be linked as library dependencies.
2. **Small binary** — Tauri bundles the system webview rather than shipping Chromium (~150 MB savings vs Electron).
3. **Cross-platform** — Windows, macOS, Linux from the same codebase.
4. **Security model** — Tauri's capability-based permissions system restricts frontend access to explicitly allowed Rust commands.
### Why Not Electron / Flutter / Native
| Option | Rejected Because |
|--------|-----------------|
| Electron | 150+ MB bundle, no Rust integration, duplicates webview |
| Flutter | No serial port plugins, Dart FFI to Rust is awkward |
| Native (GTK/Qt) | Platform-specific UI code, no web component reuse |
| Web-only (PWA) | Cannot access serial ports or raw UDP |
## Decision
Build a Tauri v2 desktop application as a new crate in the Rust workspace. The frontend uses TypeScript with React and Vite. The Rust backend exposes Tauri commands that bridge the frontend to serial ports, UDP sockets, HTTP management endpoints, and the sensing server process.
### 1. Workspace Integration
Add a new crate to the workspace:
```
rust-port/wifi-densepose-rs/
Cargo.toml # Add "crates/wifi-densepose-desktop" to members
crates/
wifi-densepose-desktop/ # NEW — Tauri app crate
Cargo.toml
tauri.conf.json
capabilities/
default.json # Tauri v2 capability permissions
icons/ # App icons (all platforms)
src/
main.rs # Tauri entry point
lib.rs # Command module re-exports
commands/
mod.rs
discovery.rs # Node discovery commands
flash.rs # Firmware flashing commands
ota.rs # OTA update commands
wasm.rs # WASM module management commands
server.rs # Sensing server lifecycle commands
provision.rs # NVS provisioning commands
serial.rs # Serial port enumeration
state.rs # Tauri managed state
discovery/
mod.rs
mdns.rs # mDNS service discovery
udp_broadcast.rs # UDP broadcast probe
flash/
mod.rs
espflash.rs # Rust-native ESP32 flashing (via espflash crate)
esptool.rs # Fallback: bundled esptool.py wrapper
frontend/
package.json
tsconfig.json
vite.config.ts
index.html
src/
main.tsx
App.tsx
routes.tsx
hooks/
useNodes.ts # Node discovery and status polling
useServer.ts # Sensing server state
useWebSocket.ts # WS connection to sensing server
stores/
nodeStore.ts # Zustand store for discovered nodes
serverStore.ts # Sensing server process state
settingsStore.ts # User preferences (dark mode, ports)
pages/
Dashboard.tsx # Hardware management overview
NodeDetail.tsx # Single node detail + config
FlashFirmware.tsx # Firmware flashing wizard
WasmModules.tsx # WASM module manager
SensingView.tsx # Live sensing data visualization
MeshTopology.tsx # Multi-node mesh topology view
Settings.tsx # App settings and preferences
components/
NodeCard.tsx # Node status card (health, version, signal)
NodeList.tsx # Discovered node list
FirmwareProgress.tsx # Flash/OTA progress indicator
LogViewer.tsx # Scrolling log output
SignalChart.tsx # Real-time CSI signal chart
PoseOverlay.tsx # Pose skeleton overlay
MeshGraph.tsx # D3/force-graph mesh topology
SerialPortSelect.tsx # Serial port dropdown
ProvisionForm.tsx # NVS provisioning form
lib/
tauri.ts # Typed Tauri invoke wrappers
types.ts # Shared TypeScript types
```
### 2. Rust Backend — Tauri Commands
#### 2.1 Node Discovery
```rust
// commands/discovery.rs
/// Discover ESP32 CSI nodes on the local network.
/// Strategy 1: mDNS — nodes announce _ruview._tcp service
/// Strategy 2: UDP broadcast probe on port 5005 (CSI aggregator port)
/// Strategy 3: HTTP health check sweep on port 8032 (OTA server)
#[tauri::command]
async fn discover_nodes(timeout_ms: u64) -> Result<Vec<DiscoveredNode>, String>;
/// Get detailed status from a specific node via HTTP.
/// Calls GET /ota/status on port 8032.
#[tauri::command]
async fn get_node_status(ip: String) -> Result<NodeStatus, String>;
/// Subscribe to node health updates (periodic polling).
#[tauri::command]
async fn watch_nodes(interval_ms: u64, state: State<'_, AppState>) -> Result<(), String>;
```
The `DiscoveredNode` struct:
```rust
#[derive(Serialize, Deserialize, Clone)]
pub struct DiscoveredNode {
pub ip: String,
pub mac: Option<String>,
pub hostname: Option<String>,
pub node_id: u8,
pub firmware_version: Option<String>,
pub tdm_slot: Option<u8>,
pub tdm_total: Option<u8>,
pub edge_tier: Option<u8>,
pub uptime_secs: Option<u64>,
pub discovery_method: DiscoveryMethod, // Mdns | UdpProbe | HttpSweep
pub last_seen: chrono::DateTime<chrono::Utc>,
}
```
#### 2.2 Firmware Flashing
```rust
// commands/flash.rs
/// List available serial ports with chip detection.
#[tauri::command]
async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String>;
/// Flash firmware binary to an ESP32 via serial port.
/// Uses the `espflash` crate for Rust-native flashing (no Python dependency).
/// Falls back to bundled esptool.py if espflash fails.
/// Emits progress events via Tauri event system.
#[tauri::command]
async fn flash_firmware(
port: String,
firmware_path: String,
chip: Chip, // Esp32, Esp32s3, Esp32c3
baud: Option<u32>,
app_handle: AppHandle,
) -> Result<FlashResult, String>;
/// Read firmware info from a connected ESP32 (chip type, flash size, MAC).
#[tauri::command]
async fn read_chip_info(port: String) -> Result<ChipInfo, String>;
```
Flash progress is emitted as Tauri events:
```rust
#[derive(Serialize, Clone)]
pub struct FlashProgress {
pub phase: FlashPhase, // Connecting | Erasing | Writing | Verifying
pub progress_pct: f32, // 0.0 - 100.0
pub bytes_written: u64,
pub bytes_total: u64,
pub speed_bps: u64,
}
```
#### 2.3 OTA Updates
```rust
// commands/ota.rs
/// Push firmware to a node via HTTP OTA (port 8032).
/// Includes PSK authentication per ADR-050.
#[tauri::command]
async fn ota_update(
node_ip: String,
firmware_path: String,
psk: Option<String>,
app_handle: AppHandle,
) -> Result<OtaResult, String>;
/// Get OTA status from a node (current version, partition info).
#[tauri::command]
async fn ota_status(node_ip: String, psk: Option<String>) -> Result<OtaStatus, String>;
/// Batch OTA update — push firmware to multiple nodes sequentially.
/// Skips nodes already running the target version.
#[tauri::command]
async fn ota_batch_update(
nodes: Vec<String>, // IPs
firmware_path: String,
psk: Option<String>,
app_handle: AppHandle,
) -> Result<Vec<OtaResult>, String>;
```
#### 2.4 WASM Module Management
```rust
// commands/wasm.rs
/// List WASM modules loaded on a node.
/// Calls GET /wasm/list on port 8032.
#[tauri::command]
async fn wasm_list(node_ip: String) -> Result<Vec<WasmModule>, String>;
/// Upload a WASM module to a node.
/// Calls POST /wasm/upload on port 8032 with binary payload.
#[tauri::command]
async fn wasm_upload(
node_ip: String,
wasm_path: String,
app_handle: AppHandle,
) -> Result<WasmUploadResult, String>;
/// Start/stop a WASM module on a node.
#[tauri::command]
async fn wasm_control(
node_ip: String,
module_id: String,
action: WasmAction, // Start | Stop | Unload
) -> Result<(), String>;
```
#### 2.5 Sensing Server Lifecycle
```rust
// commands/server.rs
/// Start the sensing server as a managed child process.
/// The server binary is either bundled with the Tauri app (sidecar)
/// or discovered on PATH.
#[tauri::command]
async fn start_server(
config: ServerConfig,
state: State<'_, AppState>,
app_handle: AppHandle,
) -> Result<(), String>;
/// Stop the managed sensing server process.
#[tauri::command]
async fn stop_server(state: State<'_, AppState>) -> Result<(), String>;
/// Get sensing server status (running/stopped, PID, ports, uptime).
#[tauri::command]
async fn server_status(state: State<'_, AppState>) -> Result<ServerStatus, String>;
#[derive(Serialize, Deserialize, Clone)]
pub struct ServerConfig {
pub http_port: u16, // Default: 8080
pub ws_port: u16, // Default: 8765
pub udp_port: u16, // Default: 5005
pub static_dir: Option<String>, // Path to UI static files
pub model_dir: Option<String>, // Path to ML models
pub log_level: String, // trace, debug, info, warn, error
}
```
The sensing server is bundled as a Tauri sidecar binary. Tauri v2 supports sidecar binaries via `externalBin` in `tauri.conf.json`:
```json
{
"bundle": {
"externalBin": ["sensing-server"]
}
}
```
#### 2.6 NVS Provisioning
```rust
// commands/provision.rs
/// Provision NVS configuration to an ESP32 via serial port.
/// Replaces the Python provision.py script with a Rust-native implementation.
/// Generates NVS partition binary and flashes it to the NVS partition offset.
#[tauri::command]
async fn provision_node(
port: String,
config: NvsConfig,
app_handle: AppHandle,
) -> Result<ProvisionResult, String>;
/// Read current NVS configuration from a connected ESP32.
/// Reads the NVS partition and parses key-value pairs.
#[tauri::command]
async fn read_nvs(port: String) -> Result<NvsConfig, String>;
#[derive(Serialize, Deserialize, Clone)]
pub struct NvsConfig {
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 wasm_pubkey: Option<Vec<u8>>,
pub ota_psk: Option<String>,
}
```
### 3. Frontend Architecture
#### 3.1 Tech Stack
| Layer | Choice | Rationale |
|-------|--------|-----------|
| Framework | React 19 | Component model, ecosystem, team familiarity |
| Build | Vite 6 | Fast HMR, Tauri plugin support |
| State | Zustand | Lightweight, no boilerplate, works with Tauri events |
| Routing | React Router v7 | File-based routes, type-safe |
| UI Components | shadcn/ui + Tailwind CSS | Accessible, customizable, no runtime CSS-in-JS |
| Charts | Recharts or visx | Real-time signal visualization |
| Topology Graph | D3 force-directed | Mesh network visualization |
| Serial UI | Custom | Tauri command integration |
| Icons | Lucide React | Consistent, tree-shakeable |
#### 3.2 Page Layout
```
+------------------------------------------+
| RuView [Settings] [?] |
+-------+----------------------------------+
| | |
| Nav | Dashboard / Active Page |
| | |
| [D] | +--------+ +--------+ +------+ |
| [F] | | Node 1 | | Node 2 | | +Add | |
| [W] | +--------+ +--------+ +------+ |
| [S] | |
| [M] | Server Status: Running |
| [T] | +--------------------------+ |
| | | Live Signal / Pose View | |
| | +--------------------------+ |
+-------+----------------------------------+
| Status Bar: 3 nodes | Server: :8080 |
+------------------------------------------+
Nav items:
[D] Dashboard — overview of all nodes and server
[F] Flash — firmware flashing wizard
[W] WASM — edge module management
[S] Sensing — live sensing data view
[M] Mesh — topology visualization
[T] Settings — ports, paths, preferences
```
#### 3.3 Dashboard Page
The dashboard is the primary landing page showing:
1. **Node Grid** — cards for each discovered ESP32 node showing:
- IP address and hostname
- Firmware version (with update indicator if newer available)
- Node ID and TDM slot assignment
- Edge processing tier (raw / stats / vitals)
- Signal quality indicator (last CSI frame age)
- Health status (online/offline/degraded)
- Quick actions: OTA update, configure, view logs
2. **Sensing Server Panel** — start/stop button, port configuration, log tail
3. **Discovery Controls** — scan button, auto-discovery toggle, network range filter
#### 3.4 Flash Firmware Page
A wizard-style flow:
1. **Select Port** — dropdown of detected serial ports with chip info
2. **Select Firmware** — file picker for `.bin` files, or select from bundled builds
3. **Configure** — chip type, baud rate, flash mode
4. **Flash** — progress bar with phase indicators (connecting, erasing, writing, verifying)
5. **Provision** — optional NVS provisioning form (WiFi, target IP, TDM, edge tier)
6. **Verify** — serial monitor showing boot log, success/fail indicator
#### 3.5 WASM Module Manager Page
| Column | Content |
|--------|---------|
| Module ID | Auto-assigned by node |
| Name | Filename of uploaded `.wasm` |
| Size | Module size in KB |
| Status | Running / Stopped / Error |
| Node | Which ESP32 node it runs on |
| Actions | Start / Stop / Unload / View Logs |
Upload panel: drag-and-drop `.wasm` file, select target node(s), upload button.
#### 3.6 Sensing View Page
Embeds the existing web UI (`ui/`) via an iframe pointing at the sensing server's static file route, or builds native React components that connect to the same WebSocket API. The native approach is preferred because it allows:
- Tighter integration with the node status sidebar
- Shared state between hardware management and visualization
- Offline access to recorded data
Key visualization components:
- **CSI Heatmap** — subcarrier amplitude over time
- **Signal Field** — 2D signal strength visualization
- **Pose Skeleton** — detected body keypoints and connections
- **Vital Signs** — real-time breathing rate and heart rate charts
- **Activity Classification** — current activity label with confidence
#### 3.7 Mesh Topology Page
A force-directed graph showing:
- Nodes as circles (color = health status, size = edge tier)
- Edges between nodes that can see each other
- TDM slot labels on each node
- Sync status indicators (in-sync / drifting / lost)
- Click a node to navigate to its detail page
### 4. Platform-Specific Considerations
#### 4.1 macOS
- **Serial driver signing**: CP210x and CH340 drivers require user approval in System Preferences > Security
- **App signing**: Tauri apps must be signed and notarized for distribution outside the App Store
- **USB permissions**: No special permissions needed beyond driver installation
- **CoreWLAN**: The sensing server can use CoreWLAN for WiFi scanning (ADR-025); the desktop app inherits this capability
#### 4.2 Windows
- **COM port access**: Windows assigns COM port numbers; the app lists them via the Windows Registry or `SetupDi` API
- **Driver installation**: USB-to-serial drivers (CP210x, CH340, FTDI) must be installed; the app can detect missing drivers and link to downloads
- **Firewall**: The sensing server's UDP listener may trigger Windows Firewall prompts; the app should pre-configure rules or guide the user
- **Code signing**: EV certificate required for SmartScreen trust; unsigned apps trigger warnings
#### 4.3 Linux
- **udev rules**: ESP32 serial ports (`/dev/ttyUSB*`, `/dev/ttyACM*`) require udev rules for non-root access. The app bundles a `99-ruview-esp32.rules` file and offers to install it:
```
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", MODE="0666" # CP210x
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", MODE="0666" # CH340
```
- **AppImage/deb/rpm**: Tauri supports all three packaging formats
- **Wayland vs X11**: Tauri uses webkit2gtk which works on both
### 5. Cargo.toml for the Desktop Crate
```toml
[package]
name = "wifi-densepose-desktop"
version.workspace = true
edition.workspace = true
description = "Tauri 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" # Sidecar process management
tauri-plugin-dialog = "2" # File picker dialogs
tauri-plugin-fs = "2" # Filesystem access
tauri-plugin-process = "2" # Process management
tauri-plugin-notification = "2" # Desktop notifications
# Workspace crates
wifi-densepose-hardware = { workspace = true }
wifi-densepose-config = { workspace = true }
wifi-densepose-core = { workspace = true }
# Serial port access
serialport = { workspace = true }
# ESP32 flashing (Rust-native, replaces esptool.py)
espflash = "3"
# Network discovery
mdns-sd = "0.11" # mDNS/DNS-SD service discovery
# HTTP client for OTA and WASM management
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
# Async runtime
tokio = { workspace = true }
# Serialization
serde = { workspace = true }
serde_json = { workspace = true }
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Time
chrono = { version = "0.4", features = ["serde"] }
```
### 6. Tauri Configuration
```json
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "RuView",
"version": "0.3.0",
"identifier": "net.ruv.ruview",
"build": {
"frontendDist": "../frontend/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd frontend && npm run dev",
"beforeBuildCommand": "cd frontend && npm run build"
},
"app": {
"windows": [
{
"title": "RuView - WiFi DensePose",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": ["sensing-server"],
"linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0"] },
"appimage": { "bundleMediaFramework": true }
},
"windows": {
"wix": { "language": "en-US" }
}
}
}
```
### 7. Tauri v2 Capabilities (Permissions)
```json
{
"identifier": "default",
"description": "RuView default capability set",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-execute",
"shell:allow-open",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-read",
"fs:allow-write",
"process:allow-exit",
"notification:default"
]
}
```
### 8. Development Workflow
```bash
# Prerequisites
cargo install tauri-cli@^2
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/frontend
npm install
# Development (hot-reload frontend + Rust rebuild)
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
cargo tauri dev
# Production build
cargo tauri build
# Build sensing-server sidecar (must be done before tauri build)
cargo build --release -p wifi-densepose-sensing-server
# Copy to sidecar location:
# target/release/sensing-server -> crates/wifi-densepose-desktop/binaries/sensing-server-{arch}
```
### 9. Persistent Node Registry
Discovery alone is transient — nodes appear when they broadcast, disappear when they don't. A persistent local registry transforms discovery into **reconciliation**.
```
~/.ruview/nodes.db (SQLite via rusqlite)
```
**Schema:**
```sql
CREATE TABLE nodes (
mac TEXT PRIMARY KEY, -- e.g. "AA:BB:CC:DD:EE:FF"
last_ip TEXT, -- last known IP
last_seen INTEGER NOT NULL, -- Unix timestamp
firmware TEXT, -- e.g. "0.3.1"
chip TEXT DEFAULT 'esp32s3', -- esp32, esp32s3, esp32c3
mesh_role TEXT DEFAULT 'node', -- 'coordinator' | 'node' | 'aggregator'
tdm_slot INTEGER, -- assigned TDM slot index
capabilities TEXT, -- JSON: {"wasm": true, "ota": true, "csi": true}
friendly_name TEXT, -- user-assigned label
notes TEXT -- free-form notes
);
```
**Behavior:**
- On discovery broadcast, upsert into registry (update `last_ip`, `last_seen`, `firmware`)
- Dashboard shows **all registered nodes**, dimming those not seen recently
- User can manually add nodes by MAC/IP (for networks without mDNS)
- Export/import registry as JSON for fleet management across machines
- Node health history (uptime, last OTA, error count) tracked over time
This means the desktop app **remembers the mesh** across restarts, which is critical for field deployments where nodes may be offline temporarily.
### 10. OTA Safety Gate — Rolling Updates
Mesh deployments cannot tolerate all nodes rebooting simultaneously. The OTA subsystem includes a **rolling update mode** that preserves sensing continuity:
```rust
#[derive(Serialize, Deserialize)]
pub struct BatchOtaConfig {
/// Update strategy
pub strategy: OtaStrategy,
/// Max nodes updating concurrently
pub max_concurrent: usize,
/// Delay between batches (seconds)
pub batch_delay_secs: u64,
/// Abort if any node fails
pub fail_fast: bool,
}
#[derive(Serialize, Deserialize)]
pub enum OtaStrategy {
/// Update one node at a time, wait for it to rejoin mesh
Sequential,
/// Update non-adjacent TDM slots to maintain coverage
TdmSafe,
/// Update all nodes simultaneously (development only)
Parallel,
}
```
**`TdmSafe` strategy:**
1. Sort nodes by TDM slot index
2. Update even-slot nodes first (slots 0, 2, 4...)
3. Wait for each to reboot and rejoin mesh (verified via beacon)
4. Then update odd-slot nodes (slots 1, 3, 5...)
5. At no point are adjacent nodes offline simultaneously
**UI flow:**
- User selects target firmware + target nodes
- App shows pre-update diff (current vs new version per node)
- Progress bar per node with states: `queued → uploading → rebooting → verifying → done`
- Abort button halts remaining updates without rolling back completed ones
- Post-update health check confirms all nodes are sensing
### 11. Plugin Architecture (Future)
This desktop tool is quietly becoming the **control plane for RuView**. Once it manages discovery, firmware, OTA, WASM, sensing, and mesh topology, plugin extensibility becomes inevitable:
- **Firmware management** today → **swarm orchestration** tomorrow
- **WASM upload** today → **edge module marketplace** tomorrow
- **Sensing view** today → **activity classification dashboard** tomorrow
The Tauri command surface should be designed with this trajectory in mind:
- Commands are grouped by bounded context (already done)
- Each context can be extended by loading additional Tauri plugins
- The node registry becomes the source of truth for all plugins
- Event bus (Tauri's `emit`/`listen`) provides cross-plugin communication
This does NOT mean building a plugin system in Phase 1. It means keeping the architecture open to it: no hardcoded views, state flows through the registry, commands are typed and versioned.
### 12. Security Considerations
1. **PSK Storage**: OTA PSK tokens are stored in the OS keychain via `tauri-plugin-stronghold` or the platform's native credential store, never in plaintext config files.
2. **Serial Port Access**: Tauri's capability system restricts which commands the frontend can invoke. Serial port access is only available through the typed `flash_firmware` and `provision_node` commands, not raw serial I/O.
3. **Network Requests**: OTA and WASM management commands only communicate with nodes on the local network. The app does not make external network requests except for update checks (opt-in).
4. **Firmware Validation**: Before flashing, the app validates the firmware binary header (ESP32 image magic bytes, partition table offset) to prevent bricking.
5. **WASM Signature Verification**: The desktop app can sign WASM modules before upload using a locally stored Ed25519 key pair, complementing the node-side verification (ADR-040).
### 13. Implementation Phases
| Phase | Scope | Effort | Priority |
|-------|-------|--------|----------|
| **Phase 1: Skeleton** | Tauri project scaffolding, workspace integration, basic window with React | 1 week | P0 |
| **Phase 2: Discovery** | Serial port listing, UDP/mDNS node discovery, dashboard with node cards | 1 week | P0 |
| **Phase 3: Flash** | espflash integration, firmware flashing wizard with progress events | 1 week | P0 |
| **Phase 4: Server** | Sidecar sensing server start/stop, log viewer, status panel | 1 week | P1 |
| **Phase 5: OTA** | HTTP OTA with PSK auth, batch update, version comparison | 1 week | P1 |
| **Phase 6: Provisioning** | NVS read/write via serial, provisioning form, mesh config file | 1 week | P1 |
| **Phase 7: WASM** | Module upload/list/start/stop, drag-and-drop, per-module logs | 1 week | P2 |
| **Phase 8: Sensing** | WebSocket integration, live signal charts, pose overlay | 2 weeks | P2 |
| **Phase 9: Mesh View** | Force-directed topology graph, TDM slot visualization, sync status | 1 week | P2 |
| **Phase 10: Polish** | App signing, auto-update, udev rules installer, onboarding wizard | 1 week | P3 |
Total estimated effort: ~11 weeks for a single developer.
## Consequences
### Positive
- **Single pane of glass** — all hardware management, sensing, and visualization in one app
- **No Python dependency** — Rust-native `espflash` replaces `esptool.py` for firmware flashing
- **Replaces 6+ CLI tools** — flash, provision, OTA, WASM management, server control, visualization
- **Accessible to non-developers** — GUI replaces CLI flags and curl commands
- **Cross-platform** — one codebase for Windows, macOS, Linux
- **Workspace integration** — shares types, config, and hardware crates with sensing server
- **Small binary** — ~15-20 MB vs ~150 MB for Electron equivalent
### Negative
- **New frontend dependency** — introduces Node.js/npm build step into the Rust workspace
- **Tauri version churn** — Tauri v2 is recent; API stability is not yet proven at scale
- **webkit2gtk on Linux** — depends on system webview version; old distros may have stale webkit
- **espflash limitations** — the `espflash` crate may not support all chip variants or flash modes that `esptool.py` handles; fallback to bundled Python is needed
- **Maintenance surface** — adds ~5,000 lines of TypeScript and ~2,000 lines of Rust
### Risks
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| espflash cannot flash all ESP32 variants | Medium | High | Bundle esptool.py as fallback sidecar |
| Tauri v2 breaking changes | Low | Medium | Pin to specific Tauri version; update in dedicated PRs |
| Serial port access fails on macOS Sequoia+ | Medium | Medium | Test on latest macOS; document driver requirements |
| webkit2gtk version mismatch on Linux | Medium | Low | Set minimum version in deb/rpm dependencies |
| Sidecar sensing server fails to start | Low | Medium | Detect failure and show manual start instructions |
## References
- Tauri v2 documentation: https://v2.tauri.app/
- espflash crate: https://crates.io/crates/espflash
- mdns-sd crate: https://crates.io/crates/mdns-sd
- ADR-012: ESP32 CSI Sensor Mesh
- ADR-039: ESP32 Edge Intelligence
- ADR-040: WASM Programmable Sensing
- ADR-044: Provisioning Tool Enhancements
- ADR-050: Quality Engineering — Security Hardening
- ADR-051: Sensing Server Decomposition
- `firmware/esp32-csi-node/` — ESP32 firmware source
- `firmware/esp32-csi-node/provision.py` — Current provisioning script
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/` — Sensing server
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/` — Hardware crate
- `ui/` — Existing web UI

View File

@ -0,0 +1,274 @@
# ADR-053: UI Design System — Dark Professional + Unity-Inspired Interface
| Field | Value |
|-------|-------|
| Status | Accepted |
| Date | 2026-03-06 |
| Deciders | ruv |
| Depends on | ADR-052 (Tauri Desktop Frontend) |
## Context
RuView Desktop (ADR-052) needs a UI design system that communicates precision and control — befitting a hardware management control plane for embedded sensing infrastructure. The interface must handle dense data (CSI heatmaps, node registries, log streams, mesh topologies) without feeling overwhelming, while remaining usable by both engineers and field operators.
Two design inspirations:
1. **Data-first professional tools** — Dense information displays where data speaks for itself. Clean typography, structured layouts, and deliberate use of color for status. The interface shows what matters and hides what doesn't. Think: network monitoring dashboards, embedded systems IDEs, infrastructure control panels.
2. **Unity Editor** — Dockable panel system, inspector/hierarchy/scene separation, property grids, dark professional theme, and dense-but-organized data display. Unity's UI is purpose-built for managing complex real-time systems — exactly what RuView needs.
The combination yields a professional control panel for WiFi sensing infrastructure. Data is organized into scannable panels with clear hierarchy. Status is communicated through consistent color coding. The layout adapts from high-level overview down to individual node details through progressive disclosure.
## Decision
### Design Principles
1. **Data is the interface** — The system reveals patterns through visualization, not through explanation. Every pixel earns its place.
2. **Precision typography** — Typography is clean and authoritative. Technical values are displayed without ambiguity. Labels are concise.
3. **Panel-based layout** — Dockable regions inspired by Unity's panel system. The operator can see the entire mesh at a glance, then drill into any node.
4. **Status through color** — Deliberate color coding: green (online), amber (degraded), red (offline/failed), blue (scanning/new). No gratuitous color.
5. **Progressive disclosure** — Dashboard shows the overview. Clicking a node reveals its details. Summary first, detail on interaction.
6. **Dual typography** — Monospace for all technical values (MAC addresses, firmware versions, CSI amplitudes). Sans-serif for labels and descriptions. The contrast signals "data vs. context."
7. **Powered by rUv** — Subtle branding: footer tagline, about dialog, splash screen.
### Color System
```css
:root {
/* Background layers */
--bg-base: #0d1117; /* App background */
--bg-surface: #161b22; /* Panel backgrounds */
--bg-elevated: #1c2333; /* Cards, modals, dropdowns */
--bg-hover: #242d3d; /* Hover state */
--bg-active: #2d3748; /* Active/selected state */
/* Text hierarchy */
--text-primary: #e6edf3; /* Headings, primary content */
--text-secondary: #8b949e; /* Labels, descriptions */
--text-muted: #484f58; /* Disabled, hints, placeholders */
/* Status indicators */
--status-online: #3fb950; /* Node online, healthy */
--status-warning: #d29922; /* Degraded, needs attention */
--status-error: #f85149; /* Offline, failed, critical */
--status-info: #58a6ff; /* Scanning, discovering, info */
/* Accent */
--accent: #7c3aed; /* rUv purple — primary actions */
--accent-hover: #6d28d9;
/* Borders */
--border: #30363d;
--border-active: #58a6ff;
/* Data display */
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
```
### Typography Scale
```css
/* Typographic hierarchy */
.heading-xl { font: 600 28px/1.2 var(--font-sans); } /* Page titles */
.heading-lg { font: 600 20px/1.3 var(--font-sans); } /* Section titles */
.heading-md { font: 600 16px/1.4 var(--font-sans); } /* Card titles */
.heading-sm { font: 600 13px/1.4 var(--font-sans); } /* Panel labels */
.body { font: 400 14px/1.6 var(--font-sans); } /* Body text */
.body-sm { font: 400 12px/1.5 var(--font-sans); } /* Captions */
.data { font: 400 13px/1.4 var(--font-mono); } /* Technical values */
.data-lg { font: 500 18px/1.2 var(--font-mono); } /* Key metrics */
```
### Layout System
Three-region layout: navigation sidebar, node list, and detail inspector. Unity's docking system provides the mechanical framework.
```
+--[ Sidebar ]--+--[ Main ]-------------------------------------+
| | |
| [Nav Items] | +--[ Command Bar ]---------------------------+ |
| | | Breadcrumb | Actions | Search | |
| Dashboard | +-------+-----------------------------------+ |
| Nodes | | | | |
| Flash | | Node | Detail Inspector | |
| OTA | | List | (selected node properties) | |
| Edge Modules | | | | |
| Sensing | | | [Property Grid] | |
| Mesh View | | | [Status Indicators] | |
| Settings | | | [Action Buttons] | |
| | | | | |
+-[ Status Bar ]+--+-------+-----------------------------------+ |
| rUv | 3 nodes online | Server: running | Port: 8080 |
+---------------------------------------------------------------+
```
**Panel behaviors:**
- Sidebar collapses to icon-only on narrow windows
- Node List / Inspector split is resizable via drag handle
- Inspector scrolls independently — drill into any node without losing the list
- Status Bar shows global system state at a glance (node count, server status, port)
### Component Library
#### 1. NodeCard
```
+-- NodeCard -----------------------------------------------+
| [●] ESP32-S3 Node #2 firmware: 0.3.1 |
| MAC: AA:BB:CC:DD:EE:FF TDM Slot: 2/4 |
| IP: 192.168.1.42 Edge Tier: 1 |
| Last seen: 3s ago [Flash] [OTA] [···] |
+-----------------------------------------------------------+
```
Status dot uses `--status-online/warning/error`. Card background shifts on hover.
#### 2. FlashProgress
```
+-- Flash Progress -----------------------------------------+
| Flashing firmware to COM3 (ESP32-S3) |
| |
| Phase: Writing |
| [████████████████████░░░░░░░░░░] 67.3% |
| 412 KB / 612 KB • 38.2 KB/s • ~5s remaining |
+-----------------------------------------------------------+
```
Progress bar uses `--accent` fill with subtle pulse animation during active writes.
#### 3. Mesh Topology View (Three.js)
Interactive 3D visualization of the sensing network. Each node is a sphere. Edges are lines representing signal paths. The coordinator node is visually distinct (larger, outlined ring). Built with **Three.js**, consistent with the existing visualization stack in `ui/observatory/js/` and `ui/components/`.
```
+-- Mesh Topology ------------------------------------------+
| |
| [Node 0]----[Node 1] |
| | \ / | |
| | [Coordinator] | Coordinator = TDM master |
| | / \ | |
| [Node 2]----[Node 3] |
| |
| Drift: ±0.3ms | Cycle: 50ms | 4/4 nodes online |
+-----------------------------------------------------------+
```
**Three.js implementation details:**
- Force-directed layout computed on CPU, rendered as `THREE.Group` with `THREE.Mesh` (spheres) and `THREE.Line` (edges)
- Node spheres use `THREE.MeshPhongMaterial` with emissive color matching `--status-online/warning/error`
- Edge lines use `THREE.LineBasicMaterial` with opacity mapped to signal strength
- Coordinator node rendered with `THREE.RingGeometry` outline
- Camera: `OrbitControls` for pan/zoom/rotate, reset button returns to default view
- Follows existing patterns: `BufferGeometry` + `BufferAttribute` for dynamic updates (see `ui/observatory/js/subcarrier-manifold.js`)
- Raycasting for node click → opens detail in Inspector panel
- Real-time updates as nodes join, leave, or change status — geometry attributes updated per frame
#### 4. PropertyGrid (Unity Inspector-style)
```
+-- Node Inspector -----------------------------------------+
| General [▼] |
| MAC Address AA:BB:CC:DD:EE:FF |
| IP Address 192.168.1.42 |
| Firmware 0.3.1 |
| Chip ESP32-S3 |
| TDM Configuration [▼] |
| Slot Index 2 |
| Total Nodes 4 |
| Cycle Period 50 ms |
| Sync Drift +0.12 ms |
| WASM Modules [▼] |
| [0] activity_detect running 12.4 KB 83 us/f |
| [1] vital_monitor stopped 8.1 KB — us/f |
+-----------------------------------------------------------+
```
Collapsible sections with alternating row backgrounds for scanability.
#### 5. StatusBadge
```
[● Online] [◐ Degraded] [○ Offline] [↻ Updating]
```
Small inline badges with status dot, label, and optional tooltip.
#### 6. LogViewer
```
+-- Server Log (auto-scroll) -----------[ Clear ] [ ⏸ ]---+
| 19:42:01.234 INFO sensing-server HTTP on 127.0.0.1:8080|
| 19:42:01.235 INFO sensing-server WS on 127.0.0.1:8765 |
| 19:42:01.890 INFO udp_receiver CSI frame from .42 |
| 19:42:02.003 WARN vital_signs Low signal quality |
+-----------------------------------------------------------+
```
Monospace, color-coded by log level (INFO=text, WARN=amber, ERROR=red). Virtual scrolling for performance.
### Spacing and Grid
```css
/* 4px base grid */
--space-1: 4px; /* Tight spacing (within components) */
--space-2: 8px; /* Component internal padding */
--space-3: 12px; /* Between related elements */
--space-4: 16px; /* Card padding, section gaps */
--space-5: 24px; /* Between sections */
--space-6: 32px; /* Page-level spacing */
--space-8: 48px; /* Major section breaks */
/* Panel dimensions */
--sidebar-width: 220px;
--sidebar-collapsed: 52px;
--statusbar-height: 28px;
--toolbar-height: 44px;
```
### Animations
Minimal and purposeful:
- Panel collapse/expand: 200ms ease-out
- Node card health transition: 300ms (color fade, not flash)
- Progress bar fill: smooth 60fps CSS transition
- Mesh graph: Three.js render loop at 60fps, force simulation on requestAnimationFrame
- No loading spinners — use skeleton placeholders instead
### Branding
- **Splash screen**: rUv logo + "RuView Desktop" + version, 1.5s duration
- **Status bar**: "Powered by rUv" in `--text-muted`, left-aligned
- **About dialog**: rUv logo, version, license, links to GitHub and docs
- **App icon**: Stylized WiFi signal + human silhouette in rUv purple (#7c3aed)
## Consequences
### Positive
- Professional, data-dense UI suitable for hardware management
- Consistent design language across all 7 pages
- Dual typography (mono + sans-serif) ensures readability at all information densities
- Unity-inspired panels feel natural to engineers familiar with IDE/editor tools
- Dark theme reduces eye strain for extended monitoring sessions
### Negative
- Custom design system means no off-the-shelf component library (shadcn/ui partially usable)
- Dockable panels add complexity to the layout system
- Dark-only theme may not suit all users (could add light mode later)
### Neutral
- The design system is CSS-only with React components — no heavy UI framework dependency
- Component library can be extracted as a separate package if other rUv projects need it
## References
- ADR-052: Tauri Desktop Frontend
- Unity Editor UI Guidelines: https://docs.unity3d.com/Manual/UIE-USS.html
- Three.js (existing project dependency): `ui/observatory/js/`, `ui/components/`
- Inter font: https://rsms.me/inter/
- JetBrains Mono: https://www.jetbrains.com/lp/mono/

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ members = [
"crates/wifi-densepose-wifiscan",
"crates/wifi-densepose-vitals",
"crates/wifi-densepose-ruvector",
"crates/wifi-densepose-desktop",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.

View File

@ -0,0 +1,25 @@
[package]
name = "wifi-densepose-desktop"
version.workspace = true
edition.workspace = true
description = "Tauri v2 desktop frontend for RuView WiFi DensePose"
license.workspace = true
authors.workspace = true
[lib]
name = "wifi_densepose_desktop"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }

View File

@ -0,0 +1,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.

View File

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

View File

@ -0,0 +1,12 @@
{
"identifier": "default",
"description": "RuView default capability set",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-execute",
"shell:allow-open",
"dialog:allow-open",
"dialog:allow-save"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save"]}}

View File

@ -0,0 +1,34 @@
use serde::Serialize;
use crate::domain::node::DiscoveredNode;
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
#[tauri::command]
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
let _timeout = timeout_ms.unwrap_or(3000);
// Stub: return placeholder data
Ok(vec![DiscoveredNode {
ip: "192.168.1.100".into(),
mac: Some("AA:BB:CC:DD:EE:FF".into()),
hostname: Some("ruview-node-1".into()),
node_id: 1,
firmware_version: Some("0.3.0".into()),
health: crate::domain::node::HealthStatus::Online,
last_seen: chrono::Utc::now().to_rfc3339(),
}])
}
/// List available serial ports on this machine.
#[tauri::command]
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
// Stub: return empty list
Ok(vec![])
}
#[derive(Debug, Clone, Serialize)]
pub struct SerialPortInfo {
pub name: String,
pub vid: Option<u16>,
pub pid: Option<u16>,
pub manufacturer: Option<String>,
}

View File

@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
/// Flash firmware binary to an ESP32 via serial port.
#[tauri::command]
pub async fn flash_firmware(
port: String,
firmware_path: String,
chip: Option<String>,
baud: Option<u32>,
) -> Result<FlashResult, String> {
let _ = (port, firmware_path, chip, baud);
// Stub: return placeholder result
Ok(FlashResult {
success: true,
message: "Stub: flash not yet implemented".into(),
duration_secs: 0.0,
})
}
/// Get current flash progress (stub for polling-based approach).
#[tauri::command]
pub async fn flash_progress() -> Result<FlashProgress, String> {
Ok(FlashProgress {
phase: "idle".into(),
progress_pct: 0.0,
bytes_written: 0,
bytes_total: 0,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashResult {
pub success: bool,
pub message: String,
pub duration_secs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashProgress {
pub phase: String,
pub progress_pct: f32,
pub bytes_written: u64,
pub bytes_total: u64,
}

View File

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

View File

@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
/// Push firmware to a single node via HTTP OTA (port 8032).
#[tauri::command]
pub async fn ota_update(
node_ip: String,
firmware_path: String,
psk: Option<String>,
) -> Result<OtaResult, String> {
let _ = (node_ip, firmware_path, psk);
Ok(OtaResult {
success: true,
node_ip: "stub".into(),
message: "Stub: OTA not yet implemented".into(),
})
}
/// Push firmware to multiple nodes with rolling update strategy.
#[tauri::command]
pub async fn batch_ota_update(
node_ips: Vec<String>,
firmware_path: String,
psk: Option<String>,
) -> Result<Vec<OtaResult>, String> {
let _ = (firmware_path, psk);
Ok(node_ips
.into_iter()
.map(|ip| OtaResult {
success: true,
node_ip: ip,
message: "Stub: batch OTA not yet implemented".into(),
})
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OtaResult {
pub success: bool,
pub node_ip: String,
pub message: String,
}

View File

@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use crate::domain::config::ProvisioningConfig;
/// Provision NVS configuration to an ESP32 via serial port.
#[tauri::command]
pub async fn provision_node(
port: String,
config: ProvisioningConfig,
) -> Result<ProvisionResult, String> {
let _ = (port, config);
Ok(ProvisionResult {
success: true,
message: "Stub: provisioning not yet implemented".into(),
})
}
/// Read current NVS configuration from a connected ESP32.
#[tauri::command]
pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
let _ = port;
Ok(ProvisioningConfig::default())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvisionResult {
pub success: bool,
pub message: String,
}

View File

@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::AppState;
/// Start the sensing server as a managed child process.
#[tauri::command]
pub async fn start_server(
config: ServerConfig,
state: State<'_, AppState>,
) -> Result<(), String> {
let _ = config;
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = true;
srv.pid = Some(0); // Stub PID
Ok(())
}
/// Stop the managed sensing server process.
#[tauri::command]
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = false;
srv.pid = None;
Ok(())
}
/// Get sensing server status.
#[tauri::command]
pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusResponse, String> {
let srv = state.server.lock().map_err(|e| e.to_string())?;
Ok(ServerStatusResponse {
running: srv.running,
pid: srv.pid,
http_port: None,
ws_port: None,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub http_port: Option<u16>,
pub ws_port: Option<u16>,
pub udp_port: Option<u16>,
pub log_level: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServerStatusResponse {
pub running: bool,
pub pid: Option<u32>,
pub http_port: Option<u16>,
pub ws_port: Option<u16>,
}

View File

@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
/// List WASM modules loaded on a specific node.
#[tauri::command]
pub async fn wasm_list(node_ip: String) -> Result<Vec<WasmModuleInfo>, String> {
let _ = node_ip;
Ok(vec![])
}
/// Upload a WASM module to a node.
#[tauri::command]
pub async fn wasm_upload(
node_ip: String,
wasm_path: String,
) -> Result<WasmUploadResult, String> {
let _ = (node_ip, wasm_path);
Ok(WasmUploadResult {
success: true,
module_id: "stub-module-0".into(),
message: "Stub: WASM upload not yet implemented".into(),
})
}
/// Start, stop, or unload a WASM module on a node.
#[tauri::command]
pub async fn wasm_control(
node_ip: String,
module_id: String,
action: String,
) -> Result<(), String> {
let _ = (node_ip, module_id, action);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmModuleInfo {
pub id: String,
pub name: String,
pub size_bytes: u64,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmUploadResult {
pub success: bool,
pub module_id: String,
pub message: String,
}

View File

@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
/// NVS provisioning configuration for a single ESP32 node.
/// Maps to the firmware's nvs_config_t struct.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProvisioningConfig {
pub wifi_ssid: Option<String>,
pub wifi_password: Option<String>,
pub target_ip: Option<String>,
pub target_port: Option<u16>,
pub node_id: Option<u8>,
pub tdm_slot: Option<u8>,
pub tdm_total: Option<u8>,
pub edge_tier: Option<u8>,
pub presence_thresh: Option<u16>,
pub fall_thresh: Option<u16>,
pub vital_window: Option<u16>,
pub vital_interval_ms: Option<u16>,
pub top_k_count: Option<u8>,
pub hop_count: Option<u8>,
pub channel_list: Option<Vec<u8>>,
pub dwell_ms: Option<u32>,
pub power_duty: Option<u8>,
pub wasm_max_modules: Option<u8>,
pub wasm_verify: Option<bool>,
pub ota_psk: Option<String>,
}
impl ProvisioningConfig {
/// Validate invariants:
/// - tdm_slot < tdm_total when both set
/// - channel_list.len() == hop_count when both set
/// - 10 <= power_duty <= 100
pub fn validate(&self) -> Result<(), String> {
if let (Some(slot), Some(total)) = (self.tdm_slot, self.tdm_total) {
if slot >= total {
return Err(format!(
"tdm_slot ({}) must be less than tdm_total ({})",
slot, total
));
}
}
if let (Some(ref channels), Some(hops)) = (&self.channel_list, self.hop_count) {
if channels.len() != hops as usize {
return Err(format!(
"channel_list length ({}) must equal hop_count ({})",
channels.len(),
hops
));
}
}
if let Some(duty) = self.power_duty {
if !(10..=100).contains(&duty) {
return Err(format!(
"power_duty ({}) must be between 10 and 100",
duty
));
}
}
Ok(())
}
}
/// Mesh-level configuration that generates per-node ProvisioningConfig instances.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
pub common: ProvisioningConfig,
pub nodes: Vec<MeshNodeEntry>,
}
/// Per-node override within a mesh configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshNodeEntry {
pub port: String,
pub node_id: u8,
pub tdm_slot: u8,
}
impl MeshConfig {
/// Generate a ProvisioningConfig for a specific mesh node,
/// merging common settings with per-node overrides.
pub fn config_for_node(&self, entry: &MeshNodeEntry) -> ProvisioningConfig {
let mut cfg = self.common.clone();
cfg.node_id = Some(entry.node_id);
cfg.tdm_slot = Some(entry.tdm_slot);
cfg.tdm_total = Some(self.nodes.len() as u8);
cfg
}
}

View File

@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
/// A firmware binary to be flashed or OTA-pushed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FirmwareBinary {
pub path: String,
pub size_bytes: u64,
pub version: Option<String>,
pub chip_type: Option<String>,
}
/// Lifecycle of a serial flash operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashPhase {
Connecting,
Erasing,
Writing,
Verifying,
Completed,
Failed,
}
/// A serial flash session aggregate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashSession {
pub id: String,
pub port: String,
pub firmware: FirmwareBinary,
pub phase: FlashPhase,
pub bytes_written: u64,
pub bytes_total: u64,
}
/// Lifecycle of an OTA update.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OtaPhase {
Uploading,
Rebooting,
Verifying,
Completed,
Failed,
}
/// An OTA update session aggregate.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OtaSession {
pub id: String,
pub target_ip: String,
pub target_mac: Option<String>,
pub firmware: FirmwareBinary,
pub phase: OtaPhase,
pub bytes_uploaded: u64,
pub bytes_total: u64,
}
/// Strategy for batch OTA updates across a mesh.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OtaStrategy {
Sequential,
TdmSafe,
Parallel,
}
/// A batch OTA session coordinating updates across multiple nodes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchOtaSession {
pub id: String,
pub firmware: FirmwareBinary,
pub strategy: OtaStrategy,
pub max_concurrent: usize,
pub node_count: usize,
pub completed: usize,
pub failed: usize,
}

View File

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

View File

@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
/// MAC address value object (e.g., "AA:BB:CC:DD:EE:FF").
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MacAddress(pub String);
impl MacAddress {
pub fn new(addr: impl Into<String>) -> Self {
Self(addr.into())
}
}
impl std::fmt::Display for MacAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Node health status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HealthStatus {
Online,
Offline,
Degraded,
}
impl Default for HealthStatus {
fn default() -> Self {
Self::Offline
}
}
/// A discovered ESP32 CSI node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredNode {
pub ip: String,
pub mac: Option<String>,
pub hostname: Option<String>,
pub node_id: u8,
pub firmware_version: Option<String>,
pub health: HealthStatus,
pub last_seen: String,
}
/// Aggregate root: maintains the set of all known nodes, keyed by MAC.
#[derive(Debug, Default)]
pub struct NodeRegistry {
nodes: std::collections::HashMap<MacAddress, DiscoveredNode>,
}
impl NodeRegistry {
pub fn new() -> Self {
Self::default()
}
/// Insert or update a node. Deduplicates by MAC address.
pub fn upsert(&mut self, mac: MacAddress, node: DiscoveredNode) {
self.nodes.insert(mac, node);
}
/// Get a node by MAC address.
pub fn get(&self, mac: &MacAddress) -> Option<&DiscoveredNode> {
self.nodes.get(mac)
}
/// List all known nodes.
pub fn all(&self) -> Vec<&DiscoveredNode> {
self.nodes.values().collect()
}
/// Number of registered nodes.
pub fn len(&self) -> usize {
self.nodes.len()
}
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}

View File

@ -0,0 +1,36 @@
pub mod commands;
pub mod domain;
pub mod state;
use commands::{discovery, flash, ota, provision, server, wasm};
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(state::AppState::default())
.invoke_handler(tauri::generate_handler![
// Discovery
discovery::discover_nodes,
discovery::list_serial_ports,
// Flash
flash::flash_firmware,
flash::flash_progress,
// OTA
ota::ota_update,
ota::batch_ota_update,
// WASM
wasm::wasm_list,
wasm::wasm_upload,
wasm::wasm_control,
// Server
server::start_server,
server::stop_server,
server::server_status,
// Provision
provision::provision_node,
provision::read_nvs,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

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

View File

@ -0,0 +1,23 @@
use std::sync::Mutex;
use crate::domain::node::DiscoveredNode;
/// Sub-state for discovered nodes.
#[derive(Default)]
pub struct DiscoveryState {
pub nodes: Vec<DiscoveredNode>,
}
/// Sub-state for the managed sensing server process.
#[derive(Default)]
pub struct ServerState {
pub running: bool,
pub pid: Option<u32>,
}
/// Top-level application state managed by Tauri.
#[derive(Default)]
pub struct AppState {
pub discovery: Mutex<DiscoveryState>,
pub server: Mutex<ServerState>,
}

View File

@ -0,0 +1,35 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "RuView Desktop",
"version": "0.3.0",
"identifier": "net.ruv.ruview",
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../ui && npm run dev",
"beforeBuildCommand": "cd ../ui && npm run build"
},
"app": {
"windows": [
{
"title": "RuView Desktop",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"resizable": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -0,0 +1,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>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,152 @@
import { type ReactNode } from "react";
export interface NavItem {
id: string;
label: string;
icon: ReactNode;
}
interface SidebarProps {
items: NavItem[];
activeId: string;
onNavigate: (id: string) => void;
}
// Minimal SVG icons to avoid external dependency
const ICONS: Record<string, ReactNode> = {
dashboard: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</svg>
),
nodes: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="3" />
<circle cx="5" cy="19" r="3" />
<circle cx="19" cy="19" r="3" />
<line x1="12" y1="8" x2="5" y2="16" />
<line x1="12" y1="8" x2="19" y2="16" />
</svg>
),
flash: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
),
server: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
),
settings: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
),
};
export const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: "dashboard", label: "Dashboard", icon: ICONS.dashboard },
{ id: "nodes", label: "Nodes", icon: ICONS.nodes },
{ id: "flash", label: "Flash", icon: ICONS.flash },
{ id: "server", label: "Server", icon: ICONS.server },
{ id: "settings", label: "Settings", icon: ICONS.settings },
];
export function Sidebar({ items, activeId, onNavigate }: SidebarProps) {
return (
<nav
style={{
width: "200px",
minWidth: "200px",
height: "100%",
background: "var(--sidebar-bg, #12121a)",
borderRight: "1px solid var(--border, #2e2e3e)",
display: "flex",
flexDirection: "column",
padding: "16px 0",
}}
>
{/* App title */}
<div
style={{
padding: "0 20px 20px",
fontSize: "18px",
fontWeight: 800,
color: "var(--text-primary, #e2e8f0)",
letterSpacing: "-0.02em",
}}
>
RuView
</div>
{/* Nav items */}
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1 }}>
{items.map((item) => {
const isActive = item.id === activeId;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "10px 20px",
border: "none",
background: isActive
? "var(--accent-muted, rgba(99, 102, 241, 0.12))"
: "transparent",
color: isActive
? "var(--accent, #6366f1)"
: "var(--text-secondary, #94a3b8)",
cursor: "pointer",
fontSize: "13px",
fontWeight: isActive ? 600 : 400,
textAlign: "left",
borderLeft: isActive
? "3px solid var(--accent, #6366f1)"
: "3px solid transparent",
transition: "background 0.1s, color 0.1s",
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background =
"var(--hover-bg, rgba(255,255,255,0.04))";
e.currentTarget.style.color = "var(--text-primary, #e2e8f0)";
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.color = "var(--text-secondary, #94a3b8)";
}
}}
>
{item.icon}
{item.label}
</button>
);
})}
</div>
{/* Version footer */}
<div
style={{
padding: "12px 20px",
fontSize: "10px",
color: "var(--text-muted, #64748b)",
}}
>
v0.3.0
</div>
</nav>
);
}

View File

@ -0,0 +1,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>
);
}

View File

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

View File

@ -0,0 +1,91 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Node } from "../types";
interface UseNodesOptions {
/** Auto-poll interval in milliseconds. Set to 0 to disable. Default: 10000 */
pollInterval?: number;
/** Whether to start scanning on mount. Default: false */
autoScan?: boolean;
}
interface UseNodesReturn {
nodes: Node[];
isScanning: boolean;
error: string | null;
scan: () => Promise<void>;
/** Total nodes discovered */
total: number;
/** Nodes currently online */
onlineCount: number;
/** Nodes currently offline */
offlineCount: number;
}
export function useNodes(options: UseNodesOptions = {}): UseNodesReturn {
const { pollInterval = 10_000, autoScan = false } = options;
const [nodes, setNodes] = useState<Node[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const scan = useCallback(async () => {
if (isScanning) return;
setIsScanning(true);
setError(null);
try {
const discovered = await invoke<Node[]>("discover_nodes", {
timeoutMs: 5000,
});
setNodes(discovered);
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setIsScanning(false);
}
}, [isScanning]);
// Auto-scan on mount if requested
useEffect(() => {
if (autoScan) {
scan();
}
}, [autoScan]); // eslint-disable-line react-hooks/exhaustive-deps
// Polling interval
useEffect(() => {
if (pollInterval <= 0) return;
intervalRef.current = setInterval(() => {
scan();
}, pollInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [pollInterval]); // eslint-disable-line react-hooks/exhaustive-deps
const onlineCount = nodes.filter(
(n) => n.health === "online"
).length;
const offlineCount = nodes.filter(
(n) => n.health === "offline"
).length;
return {
nodes,
isScanning,
error,
scan,
total: nodes.length,
onlineCount,
offlineCount,
};
}

View File

@ -0,0 +1,107 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { ServerConfig, ServerStatus } from "../types";
const DEFAULT_CONFIG: ServerConfig = {
http_port: 8080,
ws_port: 8765,
udp_port: 5005,
static_dir: null,
model_dir: null,
log_level: "info",
};
interface UseServerOptions {
/** Poll interval for status checks in ms. Default: 5000 */
pollInterval?: number;
}
interface UseServerReturn {
status: ServerStatus | null;
isRunning: boolean;
error: string | null;
start: (config?: Partial<ServerConfig>) => Promise<void>;
stop: () => Promise<void>;
refresh: () => Promise<void>;
}
export function useServer(options: UseServerOptions = {}): UseServerReturn {
const { pollInterval = 5000 } = options;
const [status, setStatus] = useState<ServerStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = useCallback(async () => {
try {
const s = await invoke<ServerStatus>("server_status");
setStatus(s);
setError(null);
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
setError(message);
}
}, []);
const start = useCallback(
async (overrides: Partial<ServerConfig> = {}) => {
setError(null);
const config: ServerConfig = { ...DEFAULT_CONFIG, ...overrides };
try {
await invoke("start_server", { config });
// Allow the server a moment to start, then refresh status
await new Promise((r) => setTimeout(r, 500));
await refresh();
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
setError(message);
}
},
[refresh]
);
const stop = useCallback(async () => {
setError(null);
try {
await invoke("stop_server");
await new Promise((r) => setTimeout(r, 300));
await refresh();
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
setError(message);
}
}, [refresh]);
// Initial status check
useEffect(() => {
refresh();
}, [refresh]);
// Polling
useEffect(() => {
if (pollInterval <= 0) return;
intervalRef.current = setInterval(refresh, pollInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [pollInterval, refresh]);
const isRunning = status?.running ?? false;
return {
status,
isRunning,
error,
start,
stop,
refresh,
};
}

View File

@ -0,0 +1,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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,225 @@
// =============================================================================
// types.ts — TypeScript types matching the Rust domain model for RuView
// =============================================================================
// ---------------------------------------------------------------------------
// Node Discovery & Registry
// ---------------------------------------------------------------------------
export type MacAddress = string; // "AA:BB:CC:DD:EE:FF"
export type HealthStatus = "online" | "offline" | "degraded" | "unknown";
export type DiscoveryMethod = "mdns" | "udp_probe" | "http_sweep" | "manual";
export type MeshRole = "coordinator" | "node" | "aggregator";
export type Chip = "esp32" | "esp32s3" | "esp32c3";
export interface TdmConfig {
slot: number;
total: number;
}
export interface NodeCapabilities {
wasm: boolean;
ota: boolean;
csi: boolean;
}
export interface Node {
ip: string;
mac: MacAddress | null;
hostname: string | null;
node_id: number;
firmware_version: string | null;
tdm_slot: number | null;
tdm_total: number | null;
edge_tier: number | null;
uptime_secs: number | null;
discovery_method: DiscoveryMethod;
last_seen: string; // ISO 8601 datetime
health: HealthStatus;
chip: Chip;
mesh_role: MeshRole;
capabilities: NodeCapabilities | null;
friendly_name: string | null;
notes: string | null;
}
// ---------------------------------------------------------------------------
// Firmware Flashing
// ---------------------------------------------------------------------------
export type FlashPhase =
| "connecting"
| "erasing"
| "writing"
| "verifying"
| "done"
| "error";
export interface FlashProgress {
phase: FlashPhase;
progress_pct: number; // 0.0 - 100.0
bytes_written: number;
bytes_total: number;
speed_bps: number;
}
export interface FirmwareBinary {
path: string;
filename: string;
size_bytes: number;
chip: Chip | null;
}
export interface FlashSession {
port: string;
firmware: FirmwareBinary;
chip: Chip;
baud: number;
progress: FlashProgress | null;
started_at: string | null;
finished_at: string | null;
error: string | null;
}
export interface FlashResult {
success: boolean;
duration_ms: number;
bytes_written: number;
error: string | null;
}
export interface ChipInfo {
chip: Chip;
mac: MacAddress;
flash_size_bytes: number;
crystal_freq_mhz: number;
}
// ---------------------------------------------------------------------------
// OTA Updates
// ---------------------------------------------------------------------------
export type OtaStrategy = "sequential" | "tdm_safe" | "parallel";
export type BatchNodeState =
| "queued"
| "uploading"
| "rebooting"
| "verifying"
| "done"
| "failed"
| "skipped";
export interface OtaSession {
node_ip: string;
firmware_path: string;
progress_pct: number;
state: BatchNodeState;
error: string | null;
}
export interface BatchOtaSession {
strategy: OtaStrategy;
max_concurrent: number;
batch_delay_secs: number;
fail_fast: boolean;
nodes: OtaSession[];
started_at: string | null;
finished_at: string | null;
}
export interface OtaResult {
node_ip: string;
success: boolean;
previous_version: string | null;
new_version: string | null;
duration_ms: number;
error: string | null;
}
export interface OtaStatus {
current_version: string;
partition: string;
update_available: boolean;
}
// ---------------------------------------------------------------------------
// WASM Modules
// ---------------------------------------------------------------------------
export type WasmModuleState = "running" | "stopped" | "error" | "loading";
export interface WasmModule {
module_id: string;
name: string;
size_bytes: number;
state: WasmModuleState;
node_ip: string;
loaded_at: string | null;
error: string | null;
}
// ---------------------------------------------------------------------------
// Sensing Server
// ---------------------------------------------------------------------------
export interface ServerConfig {
http_port: number;
ws_port: number;
udp_port: number;
static_dir: string | null;
model_dir: string | null;
log_level: string;
}
export interface ServerStatus {
running: boolean;
pid: number | null;
http_port: number | null;
ws_port: number | null;
udp_port: number | null;
uptime_secs: number | null;
error: string | null;
}
export interface SensingUpdate {
timestamp: string;
node_id: number;
subcarrier_count: number;
rssi: number;
activity: string | null;
confidence: number | null;
}
// ---------------------------------------------------------------------------
// Serial Port
// ---------------------------------------------------------------------------
export interface SerialPort {
name: string; // e.g. "COM3" or "/dev/ttyUSB0"
description: string; // e.g. "Silicon Labs CP210x"
chip: Chip | null; // detected chip type, if any
manufacturer: string | null;
vid: number | null; // USB vendor ID
pid: number | null; // USB product ID
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
export interface AppSettings {
server_http_port: number;
server_ws_port: number;
server_udp_port: number;
bind_address: string;
ui_path: string;
ota_psk: string;
auto_discover: boolean;
discover_interval_ms: number;
theme: "dark" | "light";
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 5173,
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: "esnext",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_DEBUG,
outDir: "dist",
},
});