feat: add OTA, Edge Modules, Sensing, Mesh View pages with enhanced design system

Implement all 4 remaining pages (OtaUpdate, EdgeModules, Sensing, MeshView)
and enhance the design system with glassmorphism cards, count-up animations,
page transitions, gradient accents, live status bar, and consistent status
dot glows across the UI.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-08 23:20:59 -04:00
parent 4a48564c37
commit ad013902fb
8 changed files with 3148 additions and 250 deletions

View File

@ -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<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" }}>
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Sidebar */}
<nav
style={{
width: "var(--sidebar-width)",
minWidth: "var(--sidebar-width)",
width: 220,
minWidth: 220,
background: "var(--bg-surface)",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
userSelect: "none",
}}
>
{/* Brand */}
<div
style={{
padding: "var(--space-4)",
padding: "20px 16px 16px",
borderBottom: "1px solid var(--border)",
}}
>
<h1
style={{
fontSize: 18,
fontWeight: 700,
color: "var(--accent)",
fontFamily: "var(--font-sans)",
margin: 0,
}}
>
RuView
</h1>
<span
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "var(--font-sans)",
}}
>
WiFi DensePose Desktop
</span>
<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: "var(--space-2)" }}>
<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={() => setActivePage(item.id)}
onClick={() => navigateTo(item.id)}
onMouseEnter={() => setHoveredNav(item.id)}
onMouseLeave={() => setHoveredNav(null)}
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-2)",
gap: 10,
width: "100%",
padding: "10px var(--space-4)",
background: isActive ? "var(--bg-active)" : "transparent",
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 var(--accent)"
? "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: 20,
height: 20,
borderRadius: 4,
background: isActive ? "var(--accent)" : "var(--bg-hover)",
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: 10,
fontWeight: 700,
fontFamily: "var(--font-mono)",
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.shortcut}
{item.icon}
</span>
{item.label}
</button>
@ -125,17 +248,26 @@ const App: React.FC = () => {
})}
</div>
{/* Version */}
{/* Live connection footer */}
<div
style={{
padding: "var(--space-2) var(--space-4)",
padding: "10px 16px",
fontSize: 11,
color: "var(--text-muted)",
borderTop: "1px solid var(--border)",
fontFamily: "var(--font-mono)",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
v0.3.0
<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>
@ -147,26 +279,9 @@ const App: React.FC = () => {
background: "var(--bg-base)",
}}
>
{activePage === "dashboard" && <Dashboard />}
{activePage === "nodes" && <Nodes />}
{activePage === "flash" && <FlashFirmware />}
{activePage === "settings" && <Settings />}
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
<div style={{ padding: "var(--space-5)" }}>
<h2
style={{
fontSize: 20,
fontWeight: 600,
marginBottom: "var(--space-2)",
}}
>
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
</h2>
<p style={{ color: "var(--text-secondary)", fontSize: 13 }}>
This page is not yet implemented.
</p>
</div>
)}
<div key={pageKey} className="page-transition">
{renderPage()}
</div>
</main>
</div>
@ -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",
}}
>
<span style={{ color: "var(--text-muted)" }}>Powered by rUv</span>
<span style={{ color: "var(--border)" }}>|</span>
<span>
<span
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: "var(--status-online)",
marginRight: 4,
verticalAlign: "middle",
}}
/>
0 nodes online
<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>
<span style={{ color: "var(--border)" }}>|</span>
<span>Server: stopped</span>
<span style={{ color: "var(--border)" }}>|</span>
<span style={{ fontFamily: "var(--font-mono)" }}>Port: 8080</span>
</footer>
</div>
);

View File

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

View File

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

View File

@ -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<DiscoveredNode[]>("discover_nodes", {
timeoutMs: 3000,
});
const found = await invoke<DiscoveredNode[]>("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 (
<div style={{ padding: "var(--space-5)" }}>
<div style={{ padding: "var(--space-5)", maxWidth: 1100 }}>
{/* Header */}
<div
style={{
@ -67,18 +65,17 @@ const Dashboard: React.FC = () => {
marginBottom: "var(--space-5)",
}}
>
<h2 className="heading-lg">Dashboard</h2>
<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}
style={{
padding: "var(--space-2) var(--space-4)",
background: scanning ? "var(--bg-active)" : "var(--accent)",
color: scanning ? "var(--text-muted)" : "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
}}
className="btn-gradient"
style={{ opacity: scanning ? 0.6 : 1 }}
>
{scanning ? "Scanning..." : "Scan Network"}
</button>
@ -86,156 +83,88 @@ const Dashboard: React.FC = () => {
{/* Stats row */}
<div
className="stagger-children"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard label="Total Nodes" value={String(nodes.length)} />
<StatCard label="Online" value={String(onlineCount)} color="var(--status-online)" />
<StatCard label="Offline" value={String(nodes.length - onlineCount)} color="var(--status-error)" />
<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>
{/* Server status panel */}
<div
style={{
background: "var(--bg-surface)",
borderRadius: 8,
padding: "var(--space-4)",
marginBottom: "var(--space-5)",
border: "1px solid var(--border)",
}}
>
<h3 className="heading-sm" style={{ marginBottom: "var(--space-2)" }}>
Sensing Server
</h3>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: serverStatus?.running ? "var(--status-online)" : "var(--status-error)",
}}
/>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>
{serverStatus?.running
? `Running (PID ${serverStatus.pid})`
: "Stopped"}
</span>
{serverStatus?.running && serverStatus.http_port && (
{/* 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
style={{
fontSize: 12,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
marginLeft: "var(--space-2)",
}}
>
:{serverStatus.http_port}
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 */}
<h3
className="heading-sm"
style={{
marginBottom: "var(--space-3)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Discovered Nodes ({nodes.length})
</h3>
<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
style={{
background: "var(--bg-surface)",
borderRadius: 8,
padding: "var(--space-6)",
textAlign: "center",
color: "var(--text-muted)",
border: "1px solid var(--border)",
fontSize: 13,
}}
>
No nodes discovered. Click "Scan Network" to search.
<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(280px, 1fr))",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "var(--space-4)",
}}
>
{nodes.map((node, i) => (
<div
key={node.mac || i}
style={{
background: "var(--bg-surface)",
borderRadius: 8,
padding: "var(--space-4)",
border: "1px solid var(--border)",
transition: "border-color 0.15s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.borderColor = "var(--bg-active)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.borderColor = "var(--border)")
}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
marginBottom: "var(--space-3)",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{node.hostname || `Node ${node.node_id}`}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
}}
>
{node.ip}
</div>
</div>
<StatusBadge status={node.health} />
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
<span style={{ color: "var(--text-muted)" }}>MAC</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{node.mac || "--"}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
<span style={{ color: "var(--text-muted)" }}>Firmware</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{node.firmware_version || "--"}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ color: "var(--text-muted)" }}>Node ID</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{node.node_id}</span>
</div>
</div>
</div>
<NodeDashCard key={node.mac || i} node={node} />
))}
</div>
)}
@ -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 (
<div
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-4)",
}}
className="card-glow"
style={{ padding: "var(--space-4)" }}
>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.05em",
letterSpacing: "0.06em",
color: "var(--text-muted)",
marginBottom: "var(--space-1)",
fontFamily: "var(--font-sans)",
marginBottom: "var(--space-2)",
fontWeight: 600,
}}
>
{label}
</div>
<div
className="data-lg"
style={{ color: color || "var(--text-primary)" }}
style={{
fontFamily: "var(--font-mono)",
fontSize: isText ? 16 : 28,
fontWeight: 600,
color: color || "var(--text-primary)",
letterSpacing: "-0.02em",
lineHeight: 1.1,
}}
>
{value}
{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,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,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;