diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx index 99cc0968..95c8ee50 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/App.tsx @@ -1,7 +1,11 @@ -import React, { useState } from "react"; +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 = @@ -17,107 +21,226 @@ type Page = interface NavItem { id: Page; label: string; - shortcut: string; + icon: string; } const NAV_ITEMS: NavItem[] = [ - { id: "dashboard", label: "Dashboard", shortcut: "D" }, - { id: "nodes", label: "Nodes", shortcut: "N" }, - { id: "flash", label: "Flash", shortcut: "F" }, - { id: "ota", label: "OTA", shortcut: "O" }, - { id: "wasm", label: "Edge Modules", shortcut: "W" }, - { id: "sensing", label: "Sensing", shortcut: "S" }, - { id: "mesh", label: "Mesh View", shortcut: "M" }, - { id: "settings", label: "Settings", shortcut: "G" }, + { 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("dashboard"); + const [hoveredNav, setHoveredNav] = useState(null); + const [pageKey, setPageKey] = useState(0); + const [liveStatus, setLiveStatus] = useState({ + 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 ; + case "nodes": return ; + case "flash": return ; + case "ota": return ; + case "wasm": return ; + case "sensing": return ; + case "mesh": return ; + case "settings": return ; + } + }; return ( -
+
{/* Sidebar */} @@ -147,26 +279,9 @@ const App: React.FC = () => { background: "var(--bg-base)", }} > - {activePage === "dashboard" && } - {activePage === "nodes" && } - {activePage === "flash" && } - {activePage === "settings" && } - {!["dashboard", "nodes", "flash", "settings"].includes(activePage) && ( -
-

- {NAV_ITEMS.find((n) => n.id === activePage)?.label} -

-

- This page is not yet implemented. -

-
- )} +
+ {renderPage()} +
@@ -179,33 +294,58 @@ const App: React.FC = () => { borderTop: "1px solid var(--border)", display: "flex", alignItems: "center", - padding: "0 var(--space-4)", - gap: "var(--space-4)", + padding: "0 16px", + gap: 16, fontSize: 11, fontFamily: "var(--font-sans)", color: "var(--text-muted)", + userSelect: "none", }} > - Powered by rUv - | - - - 0 nodes online + + Powered by rUv + + + {"\u2502"} + + + 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"} + + + {"\u2502"} + + + + Server: {liveStatus.serverRunning ? "running" : "stopped"} + + + + + {liveStatus.serverPort && ( + + :{liveStatus.serverPort} + + )} + + + {new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - | - Server: stopped - | - Port: 8080
); diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/components/StatusBadge.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/components/StatusBadge.tsx index c587f4ea..f72e730c 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/components/StatusBadge.tsx +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/components/StatusBadge.tsx @@ -46,6 +46,11 @@ export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) { 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} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css index 36d57caa..43f8ec90 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/design-system.css @@ -26,11 +26,18 @@ /* 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; @@ -44,11 +51,23 @@ --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: 28px; + --statusbar-height: 32px; --toolbar-height: 44px; + + /* Transitions */ + --transition-fast: 0.1s ease; + --transition-normal: 0.15s ease; + --transition-slow: 0.25s ease; } /* ===== Reset ===== */ @@ -73,14 +92,14 @@ body { } /* ===== Typography Scale ===== */ -.heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); } -.heading-lg { font: 600 20px/1.3 var(--font-sans); color: var(--text-primary); } +.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); } +.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 18px/1.2 var(--font-mono); color: var(--text-primary); } +.data-lg { font: 500 24px/1.2 var(--font-mono); color: var(--text-primary); letter-spacing: -0.02em; } /* ===== Scrollbar ===== */ ::-webkit-scrollbar { @@ -88,14 +107,21 @@ body { height: 8px; } ::-webkit-scrollbar-track { - background: var(--bg-base); + background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); - border-radius: 4px; + 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 ===== */ @@ -105,38 +131,240 @@ input, select, textarea { color: var(--text-primary); background: var(--bg-base); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); outline: none; width: 100%; box-sizing: border-box; - transition: border-color 0.15s; + 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.5; + 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; - transition: background 0.15s, opacity 0.15s; + 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.5; + 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 ===== */ @@ -145,8 +373,160 @@ button:disabled { 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); +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx index 87fcedaf..a7ee40e0 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { StatusBadge } from "../components/StatusBadge"; import type { HealthStatus } from "../types"; @@ -28,9 +28,7 @@ const Dashboard: React.FC = () => { setScanning(true); try { const { invoke } = await import("@tauri-apps/api/core"); - const found = await invoke("discover_nodes", { - timeoutMs: 3000, - }); + const found = await invoke("discover_nodes", { timeoutMs: 3000 }); setNodes(found); } catch (err) { console.error("Discovery failed:", err); @@ -57,7 +55,7 @@ const Dashboard: React.FC = () => { const onlineCount = nodes.filter((n) => n.health === "online").length; return ( -
+
{/* Header */}
{ marginBottom: "var(--space-5)", }} > -

Dashboard

+
+

Dashboard

+

+ System overview and quick actions +

+
@@ -86,156 +83,88 @@ const Dashboard: React.FC = () => { {/* Stats row */}
- - - + + + 0 ? "var(--status-error)" : "var(--text-muted)"} />
- {/* Server status panel */} -
-

- Sensing Server -

-
- - - {serverStatus?.running - ? `Running (PID ${serverStatus.pid})` - : "Stopped"} - - {serverStatus?.running && serverStatus.http_port && ( + {/* Two-column layout */} +
+ {/* Server panel */} +
+

Sensing Server

+
- :{serverStatus.http_port} + className={`status-dot ${serverStatus?.running ? "status-dot--online" : "status-dot--error"}`} + style={{ width: 10, height: 10 }} + /> + + {serverStatus?.running ? "Running" : "Stopped"} + {serverStatus?.running && serverStatus.pid && ( + + PID {serverStatus.pid} + + )} +
+ {serverStatus?.running && serverStatus.http_port && ( +
+ + {serverStatus.ws_port && } +
)}
+ + {/* Quick actions panel */} +
+

Quick Actions

+
+ + + +
+
{/* Node list */} -

- Discovered Nodes ({nodes.length}) -

+
+

Discovered Nodes ({nodes.length})

+
{nodes.length === 0 ? ( -
- No nodes discovered. Click "Scan Network" to search. +
+
{"\u25C9"}
+
+ No nodes discovered +
+
+ Click "Scan Network" to discover ESP32 devices on your local network. +
) : (
{nodes.map((node, i) => ( -
- (e.currentTarget.style.borderColor = "var(--bg-active)") - } - onMouseLeave={(e) => - (e.currentTarget.style.borderColor = "var(--border)") - } - > -
-
-
- {node.hostname || `Node ${node.node_id}`} -
-
- {node.ip} -
-
- -
- -
-
- MAC - {node.mac || "--"} -
-
- Firmware - {node.firmware_version || "--"} -
-
- Node ID - {node.node_id} -
-
-
+ ))}
)} @@ -243,44 +172,155 @@ const Dashboard: React.FC = () => { ); }; +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: 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 (
{label}
- {value} + {displayValue}
); } +function PortTag({ label, port }: { label: string; port: number }) { + return ( + + {label} + :{port} + + ); +} + +function QuickAction({ label, desc }: { label: string; desc: string }) { + return ( +
(e.currentTarget.style.background = "var(--bg-hover)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "var(--bg-base)")} + > +
+
{label}
+
{desc}
+
+ {"\u203A"} +
+ ); +} + +function NodeDashCard({ node }: { node: DiscoveredNode }) { + return ( +
+
+
+
+ {node.hostname || `Node ${node.node_id}`} +
+
+ {node.ip} +
+
+ +
+ +
+ + + +
+
+ ); +} + +function KV({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + export default Dashboard; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/EdgeModules.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/EdgeModules.tsx new file mode 100644 index 00000000..e2c18adf --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/EdgeModules.tsx @@ -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 = { + 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([]); + const [selectedIp, setSelectedIp] = useState(""); + const [modules, setModules] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // ---- Discover nodes on mount ---- + useEffect(() => { + (async () => { + try { + const discovered = await invoke("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("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 ( +
+ {/* Header */} +
+
+

Edge Modules (WASM)

+

+ Manage WASM modules deployed to ESP32 nodes +

+
+ +
+ + {/* Node selector */} +
+ + +
+ + {/* Success banner */} + {success && ( + setSuccess(null)} + /> + )} + + {/* Error banner */} + {error && ( + setError(null)} + /> + )} + + {/* Module table */} + {isLoading ? ( +
+ Loading modules... +
+ ) : modules.length === 0 ? ( +
+ {selectedIp + ? "No WASM modules loaded on this node. Use \"Upload Module\" to deploy one." + : "Select a node to view its WASM modules."} +
+ ) : ( +
+ + + + + + + + + + + + {modules.map((mod) => ( + + ))} + +
NameSizeStatusLoaded AtActions
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) { + return ( + + {children} + + ); +} + +function ModuleStateBadge({ state }: { state: WasmModuleState }) { + const { color, label } = STATE_STYLES[state]; + return ( + + + {label} + + ); +} + +function ActionButton({ + label, + onClick, + variant = "default", +}: { + label: string; + onClick: () => void; + variant?: "default" | "danger"; +}) { + const isDanger = variant === "danger"; + return ( + + ); +} + +function ModuleRow({ + module: mod, + onAction, +}: { + module: WasmModule; + onAction: (moduleId: string, action: "start" | "stop" | "unload") => void; +}) { + return ( + (e.currentTarget.style.background = "var(--bg-hover)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > + {mod.name} + {formatBytes(mod.size_bytes)} + + {formatLoadedAt(mod.loaded_at)} + +
+ {mod.state === "stopped" && ( + onAction(mod.module_id, "start")} /> + )} + {mod.state === "running" && ( + onAction(mod.module_id, "stop")} /> + )} + onAction(mod.module_id, "unload")} + variant="danger" + /> +
+ + + ); +} + +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 ( +
+ {message} + +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 "--"; + } +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MeshView.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MeshView.tsx new file mode 100644 index 00000000..55699f22 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/MeshView.tsx @@ -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 = { + 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(null); + const containerRef = useRef(null); + const [canvasWidth, setCanvasWidth] = useState(800); + const [nodes, setNodes] = useState([]); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(null); + const [selectedNode, setSelectedNode] = useState(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(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("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) => { + 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 ( +
+ {/* Header */} +
+
+

+ Mesh Topology +

+

+ Force-directed view of the ESP32 mesh network +

+
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Canvas container */} +
+ {nodes.length === 0 ? ( +
+ {scanning + ? "Scanning for nodes..." + : "No nodes found. Click Refresh to discover ESP32 devices."} +
+ ) : ( + + )} +
+ + {/* Stats bar */} +
+ + Nodes + {onlineCount} + /{nodes.length} online + + + Drift + ±0.3ms + + + Cycle + 50ms + +
+ + {/* Selected node detail card */} + {selectedNode && ( +
+
+

+ {selectedNode.label} +

+ + {selectedNode.health} + +
+
+ + + + + + +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function DetailField({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/OtaUpdate.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/OtaUpdate.tsx new file mode 100644 index 00000000..ecd7fe4d --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/OtaUpdate.tsx @@ -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 = { + sequential: "Sequential", + tdm_safe: "TDM-Safe", + parallel: "Parallel", +}; + +const STATE_CONFIG: Record = { + 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("single"); + const [nodes, setNodes] = useState([]); + const [isDiscovering, setIsDiscovering] = useState(false); + const [firmwarePath, setFirmwarePath] = useState(""); + const [psk, setPsk] = useState(""); + const [error, setError] = useState(null); + + // Single mode state + const [selectedNodeIp, setSelectedNodeIp] = useState(""); + const [isSingleUpdating, setIsSingleUpdating] = useState(false); + const [singleResult, setSingleResult] = useState(null); + + // Batch mode state + const [selectedBatchIps, setSelectedBatchIps] = useState>(new Set()); + const [strategy, setStrategy] = useState("sequential"); + const [isBatchUpdating, setIsBatchUpdating] = useState(false); + const [batchResults, setBatchResults] = useState([]); + const [batchNodeStates, setBatchNodeStates] = useState>(new Map()); + + const discoverNodes = useCallback(async () => { + setIsDiscovering(true); + setError(null); + try { + const result = await invoke("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("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(); + 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("batch_ota_update", { + nodeIps: ips, + firmwarePath, + psk: psk || null, + }); + setBatchResults(results); + + // Update per-node states from results + const finalStates = new Map(); + 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(); + 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 ( +
+

OTA Update

+

+ Push firmware updates to ESP32 nodes over the network +

+ + {/* Mode Tabs */} +
+ setMode("single")} side="left" /> + setMode("batch")} side="right" /> +
+ + {error &&
{error}
} + + {/* Node Discovery Section */} +
+
+

Discovered Nodes

+ +
+ + {nodes.length === 0 && !isDiscovering && ( +

+ No nodes discovered yet. Click Discover Nodes to scan the network. +

+ )} + + {nodes.length > 0 && mode === "single" && ( +
+ + +
+ )} + + {nodes.length > 0 && mode === "batch" && ( +
+
+ + +
+
+ {nodes.map((n) => ( + + ))} +
+

+ {selectedBatchIps.size} of {nodes.length} nodes selected +

+
+ )} +
+ + {/* Firmware & Config Section */} +
+

Firmware & Configuration

+ +
+ +
+ + +
+
+ +
+
+ + setPsk(e.target.value)} + placeholder="Leave blank if none" + style={{ width: "100%" }} + /> +
+ {mode === "batch" && ( +
+ + +

+ {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)."} +

+
+ )} +
+
+ + {/* Action */} +
+ {mode === "single" ? ( + + ) : ( + + )} +
+ + {/* Single Result */} + {mode === "single" && singleResult && ( +
+

Result

+
+
+ {singleResult.success ? "Update Successful" : "Update Failed"} +
+
+ 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`} +
+ {singleResult.error && ( +
+ {singleResult.error} +
+ )} +
+
+ )} + + {/* Batch Progress & Results */} + {mode === "batch" && batchNodeStates.size > 0 && ( +
+

+ {isBatchUpdating ? "Update Progress" : "Results"} +

+
+ {/* Table header */} +
+ Node IP + Status + Version + Duration +
+ {/* Table rows */} + {Array.from(batchNodeStates.entries()).map(([ip, state]) => { + const result = batchResults.find((r) => r.node_ip === ip); + const cfg = STATE_CONFIG[state]; + return ( +
+ {ip} + + + + + {result?.previous_version && result?.new_version + ? `v${result.previous_version} -> v${result.new_version}` + : result?.error + ? {result.error} + : "--"} + + + {result && result.duration_ms > 0 ? `${(result.duration_ms / 1000).toFixed(1)}s` : "--"} + +
+ ); + })} +
+ + {/* Summary */} + {!isBatchUpdating && batchResults.length > 0 && ( +
+ + {batchResults.filter((r) => r.success).length} succeeded + + + {batchResults.filter((r) => !r.success).length} failed + + + {batchResults.length} total + +
+ )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function TabButton({ label, active, onClick, side }: { label: string; active: boolean; onClick: () => void; side: "left" | "right" }) { + return ( + + ); +} + +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 ( + + ); +} + +function NodeStateBadge({ state }: { state: BatchNodeState }) { + const cfg = STATE_CONFIG[state]; + const isAnimating = state === "uploading" || state === "rebooting" || state === "verifying"; + return ( + + + {cfg.label} + + ); +} + +// --------------------------------------------------------------------------- +// 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)", +}; diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx new file mode 100644 index 00000000..eb39ba74 --- /dev/null +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx @@ -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 = { + 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(null); + + useEffect(() => { + if (!paused && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [entries, paused]); + + return ( +
+ {/* Header bar */} +
+ + Server Log + +
+ + +
+
+ + {/* Log entries */} +
+ {entries.length === 0 ? ( +
+ No log entries yet. +
+ ) : ( + entries.map((entry) => ( +
+ {entry.timestamp}{" "} + + {entry.level} + {" "} + {entry.source}{" "} + {entry.message} +
+ )) + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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([]); + const [paused, setPaused] = useState(false); + const pausedRef = useRef(paused); + pausedRef.current = paused; + + // Activity feed state + const [activities, setActivities] = useState([]); + + // 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 ( +
+ {/* Page header */} +

+ Sensing +

+ + {/* ----------------------------------------------------------------- */} + {/* Section 1: Server Control */} + {/* ----------------------------------------------------------------- */} +
+
+ {/* Left: status info */} +
+ {/* Status dot */} + + +
+
+ Sensing Server +
+
+ {isRunning ? "Running" : "Stopped"} +
+
+ + {/* Running details */} + {isRunning && status && ( +
+ {status.pid != null && PID {status.pid}} + {status.http_port != null && HTTP :{status.http_port}} + {status.ws_port != null && WS :{status.ws_port}} +
+ )} +
+ + {/* Right: action button */} + +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} +
+ + {/* ----------------------------------------------------------------- */} + {/* Section 2: Log Viewer (ADR-053) */} + {/* ----------------------------------------------------------------- */} +
+ +
+ + {/* ----------------------------------------------------------------- */} + {/* Section 3: Activity Feed */} + {/* ----------------------------------------------------------------- */} +
+

+ Activity Feed +

+ + {activities.length === 0 ? ( +
+ Waiting for sensing data... +
+ ) : ( +
+ {activities.map((update, i) => { + const ts = new Date(update.timestamp); + const conf = update.confidence ?? 0; + return ( +
+ {/* Timestamp */} + + {formatTimestamp(ts)} + + + {/* Node ID */} + + Node {update.node_id} + + + {/* Activity */} + + {update.activity ?? "unknown"} + + + {/* Confidence bar */} +
+
= 0.8 ? "var(--status-online)" : conf >= 0.6 ? "var(--status-warning)" : "var(--status-error)", + borderRadius: 3, + transition: "width 0.3s ease", + }} + /> +
+ + {/* Confidence value */} + + {Math.round(conf * 100)}% + +
+ ); + })} +
+ )} +
+
+ ); +}; + +export default Sensing;