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:
parent
4a48564c37
commit
ad013902fb
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "--";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,703 @@
|
|||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: HealthStatus;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface SimNode {
|
||||
id: number;
|
||||
label: string;
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
firmware: string | null;
|
||||
health: HealthStatus;
|
||||
isCoordinator: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
tdmSlot: number;
|
||||
}
|
||||
|
||||
interface SimEdge {
|
||||
source: number; // index into nodes
|
||||
target: number;
|
||||
strength: number; // 0.3 - 1.0 opacity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANVAS_HEIGHT = 500;
|
||||
const REPULSION = 8000;
|
||||
const SPRING_K = 0.005;
|
||||
const SPRING_REST = 120;
|
||||
const DAMPING = 0.92;
|
||||
const VELOCITY_THRESHOLD = 0.15;
|
||||
const DT = 1;
|
||||
|
||||
const HEALTH_COLORS: Record<HealthStatus, string> = {
|
||||
online: "#3fb950",
|
||||
offline: "#f85149",
|
||||
degraded: "#d29922",
|
||||
unknown: "#8b949e",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGraph(
|
||||
rawNodes: DiscoveredNode[],
|
||||
canvasWidth: number,
|
||||
): { nodes: SimNode[]; edges: SimEdge[] } {
|
||||
const cx = canvasWidth / 2;
|
||||
const cy = CANVAS_HEIGHT / 2;
|
||||
|
||||
const nodes: SimNode[] = rawNodes.map((n, i) => {
|
||||
const isCoord = n.node_id === 0 || i === 0;
|
||||
const angle = (2 * Math.PI * i) / Math.max(rawNodes.length, 1);
|
||||
const spread = Math.min(canvasWidth, CANVAS_HEIGHT) * 0.3;
|
||||
return {
|
||||
id: n.node_id,
|
||||
label: n.hostname || `Node ${n.node_id}`,
|
||||
ip: n.ip,
|
||||
mac: n.mac,
|
||||
firmware: n.firmware_version,
|
||||
health: n.health,
|
||||
isCoordinator: isCoord,
|
||||
x: cx + Math.cos(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
y: cy + Math.sin(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: isCoord ? 30 : 20,
|
||||
tdmSlot: i,
|
||||
};
|
||||
});
|
||||
|
||||
const edges: SimEdge[] = [];
|
||||
const coordIdx = 0;
|
||||
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
// Connect every node to coordinator
|
||||
edges.push({
|
||||
source: coordIdx,
|
||||
target: i,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
// Connect to next neighbor (ring)
|
||||
if (i < nodes.length - 1) {
|
||||
edges.push({
|
||||
source: i,
|
||||
target: i + 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Close the ring if 3+ non-coordinator nodes
|
||||
if (nodes.length > 3) {
|
||||
edges.push({
|
||||
source: nodes.length - 1,
|
||||
target: 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function hitTest(
|
||||
mx: number,
|
||||
my: number,
|
||||
nodes: SimNode[],
|
||||
): SimNode | null {
|
||||
// Iterate in reverse so topmost (last-drawn) wins
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const dx = mx - n.x;
|
||||
const dy = my - n.y;
|
||||
if (dx * dx + dy * dy <= n.radius * n.radius) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MeshView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<SimNode | null>(null);
|
||||
|
||||
// Track simulation data in a ref so the animation loop can read it without
|
||||
// re-renders triggering a new effect.
|
||||
const simRef = useRef<{ nodes: SimNode[]; edges: SimEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch nodes from Rust backend
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
setSelectedNode(null);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
setNodes(found);
|
||||
} catch (err) {
|
||||
console.error("Discovery failed:", err);
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Measure container width
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.clientWidth;
|
||||
if (w > 0) setCanvasWidth(w);
|
||||
};
|
||||
measure();
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build graph + run force simulation whenever nodes or width change
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) {
|
||||
simRef.current = { nodes: [], edges: [] };
|
||||
// Clear canvas
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes: simNodes, edges } = buildGraph(nodes, canvasWidth);
|
||||
simRef.current = { nodes: simNodes, edges };
|
||||
|
||||
let settled = false;
|
||||
|
||||
const step = () => {
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
// Coulomb repulsion
|
||||
for (let i = 0; i < sn.length; i++) {
|
||||
for (let j = i + 1; j < sn.length; j++) {
|
||||
let dx = sn[j].x - sn[i].x;
|
||||
let dy = sn[j].y - sn[i].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const force = REPULSION / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
sn[i].vx -= fx;
|
||||
sn[i].vy -= fy;
|
||||
sn[j].vx += fx;
|
||||
sn[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Spring attraction along edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const displacement = dist - SPRING_REST;
|
||||
const force = SPRING_K * displacement;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Integrate + damp + clamp to canvas bounds
|
||||
let maxV = 0;
|
||||
for (const n of sn) {
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx * DT;
|
||||
n.y += n.vy * DT;
|
||||
|
||||
// Keep nodes within canvas with padding
|
||||
const pad = n.radius + 10;
|
||||
if (n.x < pad) { n.x = pad; n.vx = 0; }
|
||||
if (n.x > canvasWidth - pad) { n.x = canvasWidth - pad; n.vx = 0; }
|
||||
if (n.y < pad) { n.y = pad; n.vy = 0; }
|
||||
if (n.y > CANVAS_HEIGHT - pad) { n.y = CANVAS_HEIGHT - pad; n.vy = 0; }
|
||||
|
||||
const v = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
|
||||
if (maxV < VELOCITY_THRESHOLD) settled = true;
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
// Edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
// Coordinator ring
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Selected highlight
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node ID text inside circle
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
// Label below
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (!settled) step();
|
||||
draw();
|
||||
if (!settled) {
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
// selectedNode is intentionally excluded from deps so clicking doesn't
|
||||
// restart the simulation. We redraw via the click handler instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, canvasWidth]);
|
||||
|
||||
// Redraw when selectedNode changes (without restarting simulation)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || simRef.current.nodes.length === 0) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
}, [selectedNode, canvasWidth]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Canvas click handler
|
||||
// -----------------------------------------------------------------------
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const hit = hitTest(mx, my, simRef.current.nodes);
|
||||
setSelectedNode(hit);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Derived stats
|
||||
// -----------------------------------------------------------------------
|
||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>
|
||||
Mesh Topology
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
Force-directed view of the ESP32 mesh network
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchNodes}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: scanning ? "var(--bg-active)" : "var(--accent)",
|
||||
color: scanning ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: "none",
|
||||
cursor: scanning ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
height: CANVAS_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{scanning
|
||||
? "Scanning for nodes..."
|
||||
: "No nodes found. Click Refresh to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasWidth}
|
||||
height={CANVAS_HEIGHT}
|
||||
onClick={handleCanvasClick}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: CANVAS_HEIGHT,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-5)",
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Nodes </span>
|
||||
<span style={{ color: "var(--status-online)" }}>{onlineCount}</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>/{nodes.length} online</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Drift </span>
|
||||
±0.3ms
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Cycle </span>
|
||||
50ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Selected node detail card */}
|
||||
{selectedNode && (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{selectedNode.label}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 10,
|
||||
background:
|
||||
HEALTH_COLORS[selectedNode.health] + "22",
|
||||
color: HEALTH_COLORS[selectedNode.health],
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{selectedNode.health}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
|
||||
gap: "var(--space-3) var(--space-5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailField label="IP Address" value={selectedNode.ip} mono />
|
||||
<DetailField label="MAC" value={selectedNode.mac ?? "--"} mono />
|
||||
<DetailField
|
||||
label="Firmware"
|
||||
value={selectedNode.firmware ?? "--"}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Role"
|
||||
value={selectedNode.isCoordinator ? "Coordinator" : "Node"}
|
||||
/>
|
||||
<DetailField
|
||||
label="TDM Slot"
|
||||
value={`${selectedNode.tdmSlot} / ${nodes.length}`}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Node ID"
|
||||
value={String(selectedNode.id)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Node,
|
||||
OtaStrategy,
|
||||
BatchNodeState,
|
||||
OtaResult,
|
||||
} from "../types";
|
||||
|
||||
type Mode = "single" | "batch";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<OtaStrategy, string> = {
|
||||
sequential: "Sequential",
|
||||
tdm_safe: "TDM-Safe",
|
||||
parallel: "Parallel",
|
||||
};
|
||||
|
||||
const STATE_CONFIG: Record<BatchNodeState, { label: string; color: string }> = {
|
||||
queued: { label: "Queued", color: "var(--text-muted)" },
|
||||
uploading: { label: "Uploading", color: "var(--status-info)" },
|
||||
rebooting: { label: "Rebooting", color: "var(--status-warning)" },
|
||||
verifying: { label: "Verifying", color: "var(--status-info)" },
|
||||
done: { label: "Done", color: "var(--status-online)" },
|
||||
failed: { label: "Failed", color: "var(--status-error)" },
|
||||
skipped: { label: "Skipped", color: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
export function OtaUpdate() {
|
||||
const [mode, setMode] = useState<Mode>("single");
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [firmwarePath, setFirmwarePath] = useState("");
|
||||
const [psk, setPsk] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Single mode state
|
||||
const [selectedNodeIp, setSelectedNodeIp] = useState("");
|
||||
const [isSingleUpdating, setIsSingleUpdating] = useState(false);
|
||||
const [singleResult, setSingleResult] = useState<OtaResult | null>(null);
|
||||
|
||||
// Batch mode state
|
||||
const [selectedBatchIps, setSelectedBatchIps] = useState<Set<string>>(new Set());
|
||||
const [strategy, setStrategy] = useState<OtaStrategy>("sequential");
|
||||
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
|
||||
const [batchResults, setBatchResults] = useState<OtaResult[]>([]);
|
||||
const [batchNodeStates, setBatchNodeStates] = useState<Map<string, BatchNodeState>>(new Map());
|
||||
|
||||
const discoverNodes = useCallback(async () => {
|
||||
setIsDiscovering(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 5000 });
|
||||
setNodes(result);
|
||||
if (result.length === 0) {
|
||||
setError("No nodes discovered. Ensure ESP32 nodes are online and reachable.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickFirmware = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Firmware Binary", extensions: ["bin"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") setFirmwarePath(selected);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const startSingleOta = async () => {
|
||||
if (!selectedNodeIp || !firmwarePath) return;
|
||||
setIsSingleUpdating(true);
|
||||
setSingleResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<OtaResult>("ota_update", {
|
||||
nodeIp: selectedNodeIp,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setSingleResult(result);
|
||||
} catch (err) {
|
||||
setSingleResult({
|
||||
node_ip: selectedNodeIp,
|
||||
success: false,
|
||||
previous_version: null,
|
||||
new_version: null,
|
||||
duration_ms: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsSingleUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startBatchOta = async () => {
|
||||
const ips = Array.from(selectedBatchIps);
|
||||
if (ips.length === 0 || !firmwarePath) return;
|
||||
setIsBatchUpdating(true);
|
||||
setBatchResults([]);
|
||||
setError(null);
|
||||
|
||||
// Initialize all nodes as queued
|
||||
const initialStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => initialStates.set(ip, "queued"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
// Mark all as uploading while the batch runs
|
||||
ips.forEach((ip) => initialStates.set(ip, "uploading"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
try {
|
||||
const results = await invoke<OtaResult[]>("batch_ota_update", {
|
||||
nodeIps: ips,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setBatchResults(results);
|
||||
|
||||
// Update per-node states from results
|
||||
const finalStates = new Map<string, BatchNodeState>();
|
||||
results.forEach((r) => {
|
||||
finalStates.set(r.node_ip, r.success ? "done" : "failed");
|
||||
});
|
||||
setBatchNodeStates(finalStates);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// Mark all as failed on total failure
|
||||
const failStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => failStates.set(ip, "failed"));
|
||||
setBatchNodeStates(failStates);
|
||||
} finally {
|
||||
setIsBatchUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBatchNode = (ip: string) => {
|
||||
setSelectedBatchIps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ip)) next.delete(ip);
|
||||
else next.add(ip);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedBatchIps.size === nodes.length) {
|
||||
setSelectedBatchIps(new Set());
|
||||
} else {
|
||||
setSelectedBatchIps(new Set(nodes.map((n) => n.ip)));
|
||||
}
|
||||
};
|
||||
|
||||
const nodeLabel = (n: DiscoveredNode) => {
|
||||
const parts = [n.ip];
|
||||
if (n.hostname) parts.push(n.hostname);
|
||||
if (n.firmware_version) parts.push(`v${n.firmware_version}`);
|
||||
return parts.join(" - ");
|
||||
};
|
||||
|
||||
const canStartSingle = selectedNodeIp !== "" && firmwarePath !== "" && !isSingleUpdating;
|
||||
const canStartBatch = selectedBatchIps.size > 0 && firmwarePath !== "" && !isBatchUpdating;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 800 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>OTA Update</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Push firmware updates to ESP32 nodes over the network
|
||||
</p>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div style={{ display: "flex", gap: 0, marginBottom: "var(--space-5)" }}>
|
||||
<TabButton label="Single Node" active={mode === "single"} onClick={() => setMode("single")} side="left" />
|
||||
<TabButton label="Batch OTA" active={mode === "batch"} onClick={() => setMode("batch")} side="right" />
|
||||
</div>
|
||||
|
||||
{error && <div style={bannerStyle("var(--status-error)")}>{error}</div>}
|
||||
|
||||
{/* Node Discovery Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||
<h2 style={sectionTitleStyle}>Discovered Nodes</h2>
|
||||
<button onClick={discoverNodes} style={secondaryBtn} disabled={isDiscovering}>
|
||||
{isDiscovering ? "Scanning..." : nodes.length > 0 ? "Re-scan" : "Discover Nodes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && !isDiscovering && (
|
||||
<p style={{ fontSize: 13, color: "var(--text-muted)", margin: 0 }}>
|
||||
No nodes discovered yet. Click Discover Nodes to scan the network.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "single" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Target Node</label>
|
||||
<select
|
||||
value={selectedNodeIp}
|
||||
onChange={(e) => setSelectedNodeIp(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">Select a node...</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.ip} value={n.ip}>{nodeLabel(n)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "batch" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", marginBottom: "var(--space-2)" }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>Select Nodes</label>
|
||||
<button onClick={toggleAll} style={{ ...linkBtn, fontSize: 11 }}>
|
||||
{selectedBatchIps.size === nodes.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 6 }}>
|
||||
{nodes.map((n) => (
|
||||
<label
|
||||
key={n.ip}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
background: selectedBatchIps.has(n.ip) ? "var(--bg-hover)" : "transparent",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBatchIps.has(n.ip)}
|
||||
onChange={() => toggleBatchNode(n.ip)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
<span style={{ flex: 1, color: "var(--text-primary)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{n.ip}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{n.hostname ?? "unknown"}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
|
||||
{n.firmware_version ? `v${n.firmware_version}` : ""}
|
||||
</span>
|
||||
<StatusDot health={n.health} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)", marginBottom: 0 }}>
|
||||
{selectedBatchIps.size} of {nodes.length} nodes selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Firmware & Config Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Firmware & Configuration</h2>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: mode === "batch" ? "1fr 1fr" : "1fr", gap: "var(--space-4)", marginBottom: "var(--space-2)" }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Pre-Shared Key (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={psk}
|
||||
onChange={(e) => setPsk(e.target.value)}
|
||||
placeholder="Leave blank if none"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
{mode === "batch" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Update Strategy</label>
|
||||
<select value={strategy} onChange={(e) => setStrategy(e.target.value as OtaStrategy)} style={{ width: "100%" }}>
|
||||
{(Object.keys(STRATEGY_LABELS) as OtaStrategy[]).map((s) => (
|
||||
<option key={s} value={s}>{STRATEGY_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4, marginBottom: 0 }}>
|
||||
{strategy === "sequential" && "Updates nodes one at a time."}
|
||||
{strategy === "tdm_safe" && "Respects TDM slots to avoid overlapping transmissions."}
|
||||
{strategy === "parallel" && "Updates all nodes simultaneously (fastest, highest network load)."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-5)" }}>
|
||||
{mode === "single" ? (
|
||||
<button onClick={startSingleOta} disabled={!canStartSingle} style={canStartSingle ? primaryBtn : disabledBtn}>
|
||||
{isSingleUpdating ? "Pushing Update..." : "Push Update"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={startBatchOta} disabled={!canStartBatch} style={canStartBatch ? primaryBtn : disabledBtn}>
|
||||
{isBatchUpdating ? "Updating..." : `Start Batch Update (${selectedBatchIps.size} node${selectedBatchIps.size !== 1 ? "s" : ""})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single Result */}
|
||||
{mode === "single" && singleResult && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Result</h2>
|
||||
<div style={bannerStyle(singleResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{singleResult.success ? "Update Successful" : "Update Failed"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
Node: {singleResult.node_ip}
|
||||
{singleResult.previous_version && ` | Previous: v${singleResult.previous_version}`}
|
||||
{singleResult.new_version && ` | New: v${singleResult.new_version}`}
|
||||
{singleResult.duration_ms > 0 && ` | Duration: ${(singleResult.duration_ms / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
{singleResult.error && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, fontFamily: "var(--font-mono)" }}>
|
||||
{singleResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Progress & Results */}
|
||||
{mode === "batch" && batchNodeStates.size > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>
|
||||
{isBatchUpdating ? "Update Progress" : "Results"}
|
||||
</h2>
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden" }}>
|
||||
{/* Table header */}
|
||||
<div style={tableHeaderRow}>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Node IP</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Status</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Version</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right" }}>Duration</span>
|
||||
</div>
|
||||
{/* Table rows */}
|
||||
{Array.from(batchNodeStates.entries()).map(([ip, state]) => {
|
||||
const result = batchResults.find((r) => r.node_ip === ip);
|
||||
const cfg = STATE_CONFIG[state];
|
||||
return (
|
||||
<div key={ip} style={tableRow}>
|
||||
<span style={{ ...tableCell, flex: 2, fontFamily: "var(--font-mono)" }}>{ip}</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>
|
||||
<NodeStateBadge state={state} />
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 2, fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{result?.previous_version && result?.new_version
|
||||
? `v${result.previous_version} -> v${result.new_version}`
|
||||
: result?.error
|
||||
? <span style={{ color: "var(--status-error)", fontFamily: "var(--font-mono)", fontSize: 11 }}>{result.error}</span>
|
||||
: "--"}
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right", fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{result && result.duration_ms > 0 ? `${(result.duration_ms / 1000).toFixed(1)}s` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{!isBatchUpdating && batchResults.length > 0 && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", gap: "var(--space-4)", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--status-online)" }}>
|
||||
{batchResults.filter((r) => r.success).length} succeeded
|
||||
</span>
|
||||
<span style={{ color: "var(--status-error)" }}>
|
||||
{batchResults.filter((r) => !r.success).length} failed
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{batchResults.length} total
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabButton({ label, active, onClick, side }: { label: string; active: boolean; onClick: () => void; side: "left" | "right" }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? "var(--text-primary)" : "var(--text-muted)",
|
||||
background: active ? "var(--bg-surface)" : "transparent",
|
||||
border: `1px solid ${active ? "var(--border-active)" : "var(--border)"}`,
|
||||
borderRadius: side === "left" ? "6px 0 0 6px" : "0 6px 6px 0",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ health }: { health: string }) {
|
||||
const color =
|
||||
health === "online" ? "var(--status-online)" :
|
||||
health === "degraded" ? "var(--status-warning)" :
|
||||
health === "offline" ? "var(--status-error)" :
|
||||
"var(--text-muted)";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStateBadge({ state }: { state: BatchNodeState }) {
|
||||
const cfg = STATE_CONFIG[state];
|
||||
const isAnimating = state === "uploading" || state === "rebooting" || state === "verifying";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: cfg.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: cfg.color,
|
||||
animation: isAnimating ? "pulse-accent 1.5s infinite" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bannerStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const disabledBtn: React.CSSProperties = {
|
||||
...primaryBtn,
|
||||
background: "var(--bg-active)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "not-allowed",
|
||||
};
|
||||
|
||||
const linkBtn: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const tableHeaderRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
const tableRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const tableCell: React.CSSProperties = {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
};
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useServer } from "../hooks/useServer";
|
||||
import type { SensingUpdate } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log entry model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string; // HH:MM:SS.mmm
|
||||
level: LogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_LOG_TEMPLATES: { level: LogLevel; source: string; message: string }[] = [
|
||||
{ level: "INFO", source: "sensing-server", message: "HTTP listening on 127.0.0.1:8080" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.42" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Low signal quality on node 2" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: walking (confidence: 0.87)" },
|
||||
{ level: "ERROR", source: "ws_session", message: "Client disconnected unexpectedly" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.15" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sitting (confidence: 0.93)" },
|
||||
{ level: "INFO", source: "sensing-server", message: "WebSocket client connected from 127.0.0.1" },
|
||||
{ level: "WARN", source: "mesh_sync", message: "Node 4 heartbeat delayed by 1200ms" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: standing (confidence: 0.91)" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.78" },
|
||||
{ level: "ERROR", source: "udp_receiver", message: "Malformed CSI payload (len=0)" },
|
||||
{ level: "INFO", source: "csi_pipeline", message: "Subcarrier FFT complete (52 bins)" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Breathing rate out of range on node 5" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sleeping (confidence: 0.78)" },
|
||||
];
|
||||
|
||||
const MOCK_ACTIVITIES = [
|
||||
{ activity: "walking", confidence: 0.87 },
|
||||
{ activity: "sitting", confidence: 0.93 },
|
||||
{ activity: "standing", confidence: 0.91 },
|
||||
{ activity: "sleeping", confidence: 0.78 },
|
||||
{ activity: "exercising", confidence: 0.65 },
|
||||
];
|
||||
|
||||
function formatTimestamp(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
let nextLogId = 1;
|
||||
|
||||
function createMockLogEntry(): LogEntry {
|
||||
const template = MOCK_LOG_TEMPLATES[Math.floor(Math.random() * MOCK_LOG_TEMPLATES.length)];
|
||||
return {
|
||||
id: nextLogId++,
|
||||
timestamp: formatTimestamp(new Date()),
|
||||
level: template.level,
|
||||
source: template.source,
|
||||
message: template.message,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSensingUpdate(): SensingUpdate {
|
||||
const act = MOCK_ACTIVITIES[Math.floor(Math.random() * MOCK_ACTIVITIES.length)];
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
node_id: Math.floor(Math.random() * 6) + 1,
|
||||
subcarrier_count: 52,
|
||||
rssi: -(Math.floor(Math.random() * 40) + 30),
|
||||
activity: act.activity,
|
||||
confidence: parseFloat((act.confidence + (Math.random() * 0.1 - 0.05)).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const LOG_INTERVAL_MS = 2000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LogViewer component (ADR-053)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LEVEL_COLOR: Record<LogLevel, string> = {
|
||||
INFO: "var(--text-secondary)",
|
||||
WARN: "var(--status-warning)",
|
||||
ERROR: "var(--status-error)",
|
||||
};
|
||||
|
||||
function LogViewer({
|
||||
entries,
|
||||
onClear,
|
||||
paused,
|
||||
onTogglePause,
|
||||
}: {
|
||||
entries: LogEntry[];
|
||||
onClear: () => void;
|
||||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [entries, paused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--bg-elevated)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Server Log
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: paused ? "var(--status-warning)" : "var(--bg-hover)",
|
||||
color: paused ? "#000" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-hover)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflowY: "auto",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div style={{ color: "var(--text-muted)", padding: "var(--space-4)", textAlign: "center" }}>
|
||||
No log entries yet.
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.id} style={{ whiteSpace: "nowrap" }}>
|
||||
<span style={{ color: "var(--text-muted)" }}>{entry.timestamp}</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
color: LEVEL_COLOR[entry.level],
|
||||
fontWeight: entry.level === "ERROR" ? 700 : 500,
|
||||
display: "inline-block",
|
||||
minWidth: 40,
|
||||
}}
|
||||
>
|
||||
{entry.level}
|
||||
</span>{" "}
|
||||
<span style={{ color: "var(--accent)" }}>{entry.source}</span>{" "}
|
||||
<span style={{ color: LEVEL_COLOR[entry.level] }}>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensing page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Sensing: React.FC = () => {
|
||||
const { status, isRunning, error, start, stop } = useServer({ pollInterval: 5000 });
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
// Log viewer state
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
|
||||
// Activity feed state
|
||||
const [activities, setActivities] = useState<SensingUpdate[]>([]);
|
||||
|
||||
// Simulated log feed
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (pausedRef.current) return;
|
||||
const entry = createMockLogEntry();
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next;
|
||||
});
|
||||
|
||||
// Also push an activity update every ~3rd tick
|
||||
if (Math.random() < 0.35) {
|
||||
setActivities((prev) => {
|
||||
const update = createMockSensingUpdate();
|
||||
const next = [update, ...prev];
|
||||
return next.slice(0, 5);
|
||||
});
|
||||
}
|
||||
}, LOG_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleClearLog = useCallback(() => setLogEntries([]), []);
|
||||
const handleTogglePause = useCallback(() => setPaused((p) => !p), []);
|
||||
|
||||
const handleStart = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await start();
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await stop();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)" }}>
|
||||
{/* Page header */}
|
||||
<h2 className="heading-lg" style={{ marginBottom: "var(--space-5)" }}>
|
||||
Sensing
|
||||
</h2>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 1: Server Control */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Left: status info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: isRunning ? "var(--status-online)" : "var(--status-error)",
|
||||
boxShadow: isRunning ? "0 0 6px var(--status-online)" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||
Sensing Server
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
{isRunning ? "Running" : "Stopped"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running details */}
|
||||
{isRunning && status && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-4)",
|
||||
marginLeft: "var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{status.pid != null && <span>PID {status.pid}</span>}
|
||||
{status.http_port != null && <span>HTTP :{status.http_port}</span>}
|
||||
{status.ws_port != null && <span>WS :{status.ws_port}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: action button */}
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={starting || stopping}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting || stopping ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
background: isRunning ? "var(--status-error)" : "var(--accent)",
|
||||
color: "#fff",
|
||||
opacity: starting || stopping ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "rgba(255,59,48,0.1)",
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: "var(--status-error)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 2: Log Viewer (ADR-053) */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div style={{ marginBottom: "var(--space-5)" }}>
|
||||
<LogViewer
|
||||
entries={logEntries}
|
||||
onClear={handleClearLog}
|
||||
paused={paused}
|
||||
onTogglePause={handleTogglePause}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 3: Activity Feed */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
Activity Feed
|
||||
</h3>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center", padding: "var(--space-4)" }}>
|
||||
Waiting for sensing data...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
{activities.map((update, i) => {
|
||||
const ts = new Date(update.timestamp);
|
||||
const conf = update.confidence ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={`${update.timestamp}-${i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 72,
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(ts)}
|
||||
</span>
|
||||
|
||||
{/* Node ID */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 48,
|
||||
}}
|
||||
>
|
||||
Node {update.node_id}
|
||||
</span>
|
||||
|
||||
{/* Activity */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 80,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{update.activity ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: "var(--bg-hover)",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.round(conf * 100)}%`,
|
||||
height: "100%",
|
||||
background: conf >= 0.8 ? "var(--status-online)" : conf >= 0.6 ? "var(--status-warning)" : "var(--status-error)",
|
||||
borderRadius: 3,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence value */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 36,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sensing;
|
||||
Loading…
Reference in New Issue