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 Dashboard from "./pages/Dashboard";
|
||||||
import { Nodes } from "./pages/Nodes";
|
import { Nodes } from "./pages/Nodes";
|
||||||
import { FlashFirmware } from "./pages/FlashFirmware";
|
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";
|
import { Settings } from "./pages/Settings";
|
||||||
|
|
||||||
type Page =
|
type Page =
|
||||||
|
|
@ -17,107 +21,226 @@ type Page =
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: Page;
|
id: Page;
|
||||||
label: string;
|
label: string;
|
||||||
shortcut: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ id: "dashboard", label: "Dashboard", shortcut: "D" },
|
{ id: "dashboard", label: "Dashboard", icon: "\u25A6" },
|
||||||
{ id: "nodes", label: "Nodes", shortcut: "N" },
|
{ id: "nodes", label: "Nodes", icon: "\u25C9" },
|
||||||
{ id: "flash", label: "Flash", shortcut: "F" },
|
{ id: "flash", label: "Flash", icon: "\u26A1" },
|
||||||
{ id: "ota", label: "OTA", shortcut: "O" },
|
{ id: "ota", label: "OTA", icon: "\u2B06" },
|
||||||
{ id: "wasm", label: "Edge Modules", shortcut: "W" },
|
{ id: "wasm", label: "Edge Modules", icon: "\u2B21" },
|
||||||
{ id: "sensing", label: "Sensing", shortcut: "S" },
|
{ id: "sensing", label: "Sensing", icon: "\u2248" },
|
||||||
{ id: "mesh", label: "Mesh View", shortcut: "M" },
|
{ id: "mesh", label: "Mesh View", icon: "\u2B2F" },
|
||||||
{ id: "settings", label: "Settings", shortcut: "G" },
|
{ id: "settings", label: "Settings", icon: "\u2699" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface LiveStatus {
|
||||||
|
nodeCount: number;
|
||||||
|
onlineCount: number;
|
||||||
|
serverRunning: boolean;
|
||||||
|
serverPort: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
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 (
|
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" }}>
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<nav
|
<nav
|
||||||
style={{
|
style={{
|
||||||
width: "var(--sidebar-width)",
|
width: 220,
|
||||||
minWidth: "var(--sidebar-width)",
|
minWidth: 220,
|
||||||
background: "var(--bg-surface)",
|
background: "var(--bg-surface)",
|
||||||
borderRight: "1px solid var(--border)",
|
borderRight: "1px solid var(--border)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "var(--space-4)",
|
padding: "20px 16px 16px",
|
||||||
borderBottom: "1px solid var(--border)",
|
borderBottom: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||||
style={{
|
<div
|
||||||
fontSize: 18,
|
style={{
|
||||||
fontWeight: 700,
|
width: 30,
|
||||||
color: "var(--accent)",
|
height: 30,
|
||||||
fontFamily: "var(--font-sans)",
|
borderRadius: 8,
|
||||||
margin: 0,
|
background: "linear-gradient(135deg, var(--accent), #a855f7, #ec4899)",
|
||||||
}}
|
backgroundSize: "200% 200%",
|
||||||
>
|
animation: "gradient-shift 4s ease infinite",
|
||||||
RuView
|
display: "flex",
|
||||||
</h1>
|
alignItems: "center",
|
||||||
<span
|
justifyContent: "center",
|
||||||
style={{
|
fontSize: 15,
|
||||||
fontSize: 11,
|
fontWeight: 800,
|
||||||
color: "var(--text-muted)",
|
color: "#fff",
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
boxShadow: "0 2px 12px rgba(124, 58, 237, 0.4)",
|
||||||
>
|
}}
|
||||||
WiFi DensePose Desktop
|
>
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<div style={{ flex: 1, paddingTop: "var(--space-2)" }}>
|
<div style={{ flex: 1, paddingTop: 6, paddingBottom: 6, overflowY: "auto" }}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive = activePage === item.id;
|
const isActive = activePage === item.id;
|
||||||
|
const isHovered = hoveredNav === item.id && !isActive;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActivePage(item.id)}
|
onClick={() => navigateTo(item.id)}
|
||||||
|
onMouseEnter={() => setHoveredNav(item.id)}
|
||||||
|
onMouseLeave={() => setHoveredNav(null)}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "var(--space-2)",
|
gap: 10,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "10px var(--space-4)",
|
padding: "8px 16px",
|
||||||
background: isActive ? "var(--bg-active)" : "transparent",
|
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)",
|
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderLeft: isActive
|
borderLeft: isActive
|
||||||
? "3px solid var(--accent)"
|
? "3px solid transparent"
|
||||||
: "3px solid transparent",
|
: "3px solid transparent",
|
||||||
fontFamily: "var(--font-sans)",
|
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 20,
|
width: 24,
|
||||||
height: 20,
|
height: 24,
|
||||||
borderRadius: 4,
|
borderRadius: 6,
|
||||||
background: isActive ? "var(--accent)" : "var(--bg-hover)",
|
background: isActive
|
||||||
|
? "linear-gradient(135deg, var(--accent), #a855f7)"
|
||||||
|
: isHovered
|
||||||
|
? "var(--bg-active)"
|
||||||
|
: "var(--bg-elevated)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: 10,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
color: isActive ? "#fff" : "var(--text-muted)",
|
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>
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -125,17 +248,26 @@ const App: React.FC = () => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Live connection footer */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "var(--space-2) var(--space-4)",
|
padding: "10px 16px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
borderTop: "1px solid var(--border)",
|
borderTop: "1px solid var(--border)",
|
||||||
fontFamily: "var(--font-mono)",
|
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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -147,26 +279,9 @@ const App: React.FC = () => {
|
||||||
background: "var(--bg-base)",
|
background: "var(--bg-base)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activePage === "dashboard" && <Dashboard />}
|
<div key={pageKey} className="page-transition">
|
||||||
{activePage === "nodes" && <Nodes />}
|
{renderPage()}
|
||||||
{activePage === "flash" && <FlashFirmware />}
|
</div>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -179,33 +294,58 @@ const App: React.FC = () => {
|
||||||
borderTop: "1px solid var(--border)",
|
borderTop: "1px solid var(--border)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "0 var(--space-4)",
|
padding: "0 16px",
|
||||||
gap: "var(--space-4)",
|
gap: 16,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--text-muted)" }}>Powered by rUv</span>
|
<span style={{ color: "var(--text-muted)", fontWeight: 500 }}>
|
||||||
<span style={{ color: "var(--border)" }}>|</span>
|
Powered by rUv
|
||||||
<span>
|
</span>
|
||||||
<span
|
|
||||||
style={{
|
<span style={{ color: "var(--border)" }}>{"\u2502"}</span>
|
||||||
display: "inline-block",
|
|
||||||
width: 6,
|
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
height: 6,
|
<span
|
||||||
borderRadius: "50%",
|
className={`status-dot ${liveStatus.onlineCount > 0 ? "status-dot--online" : "status-dot--error"}`}
|
||||||
background: "var(--status-online)",
|
style={{ width: 6, height: 6 }}
|
||||||
marginRight: 4,
|
/>
|
||||||
verticalAlign: "middle",
|
{liveStatus.onlineCount > 0
|
||||||
}}
|
? `${liveStatus.onlineCount} node${liveStatus.onlineCount !== 1 ? "s" : ""} online`
|
||||||
/>
|
: "No nodes"}
|
||||||
0 nodes online
|
</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>
|
||||||
<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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxShadow: status === "online"
|
||||||
|
? `0 0 4px ${color}, 0 0 8px ${color}`
|
||||||
|
: status === "degraded"
|
||||||
|
? `0 0 4px ${color}`
|
||||||
|
: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,18 @@
|
||||||
/* Accent */
|
/* Accent */
|
||||||
--accent: #7c3aed;
|
--accent: #7c3aed;
|
||||||
--accent-hover: #6d28d9;
|
--accent-hover: #6d28d9;
|
||||||
|
--accent-glow: rgba(124, 58, 237, 0.15);
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--border: #30363d;
|
--border: #30363d;
|
||||||
--border-active: #58a6ff;
|
--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 */
|
/* Fonts */
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
|
@ -44,11 +51,23 @@
|
||||||
--space-6: 32px;
|
--space-6: 32px;
|
||||||
--space-8: 48px;
|
--space-8: 48px;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Panel dimensions */
|
/* Panel dimensions */
|
||||||
--sidebar-width: 220px;
|
--sidebar-width: 220px;
|
||||||
--sidebar-collapsed: 52px;
|
--sidebar-collapsed: 52px;
|
||||||
--statusbar-height: 28px;
|
--statusbar-height: 32px;
|
||||||
--toolbar-height: 44px;
|
--toolbar-height: 44px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 0.1s ease;
|
||||||
|
--transition-normal: 0.15s ease;
|
||||||
|
--transition-slow: 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Reset ===== */
|
/* ===== Reset ===== */
|
||||||
|
|
@ -73,14 +92,14 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Typography Scale ===== */
|
/* ===== Typography Scale ===== */
|
||||||
.heading-xl { font: 600 28px/1.2 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); }
|
.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-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 { 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); }
|
.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 { 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 ===== */
|
/* ===== Scrollbar ===== */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|
@ -88,14 +107,21 @@ body {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--bg-base);
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-full);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Form Controls ===== */
|
/* ===== Form Controls ===== */
|
||||||
|
|
@ -105,38 +131,240 @@ input, select, textarea {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
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 {
|
input:focus, select:focus, textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-accent);
|
||||||
}
|
}
|
||||||
input:disabled, select:disabled, textarea:disabled {
|
input:disabled, select:disabled, textarea:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
cursor: pointer;
|
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 ===== */
|
/* ===== Buttons ===== */
|
||||||
button {
|
button {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
outline: 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 {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
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 ===== */
|
/* ===== Animations ===== */
|
||||||
|
|
@ -145,8 +373,160 @@ button:disabled {
|
||||||
50% { opacity: 0.7; }
|
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 ===== */
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(124, 58, 237, 0.3);
|
background: rgba(124, 58, 237, 0.3);
|
||||||
color: var(--text-primary);
|
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 { StatusBadge } from "../components/StatusBadge";
|
||||||
import type { HealthStatus } from "../types";
|
import type { HealthStatus } from "../types";
|
||||||
|
|
||||||
|
|
@ -28,9 +28,7 @@ const Dashboard: React.FC = () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
|
const found = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 3000 });
|
||||||
timeoutMs: 3000,
|
|
||||||
});
|
|
||||||
setNodes(found);
|
setNodes(found);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Discovery failed:", err);
|
console.error("Discovery failed:", err);
|
||||||
|
|
@ -57,7 +55,7 @@ const Dashboard: React.FC = () => {
|
||||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "var(--space-5)" }}>
|
<div style={{ padding: "var(--space-5)", maxWidth: 1100 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -67,18 +65,17 @@ const Dashboard: React.FC = () => {
|
||||||
marginBottom: "var(--space-5)",
|
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
|
<button
|
||||||
onClick={handleScan}
|
onClick={handleScan}
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
style={{
|
className="btn-gradient"
|
||||||
padding: "var(--space-2) var(--space-4)",
|
style={{ opacity: scanning ? 0.6 : 1 }}
|
||||||
background: scanning ? "var(--bg-active)" : "var(--accent)",
|
|
||||||
color: scanning ? "var(--text-muted)" : "#fff",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{scanning ? "Scanning..." : "Scan Network"}
|
{scanning ? "Scanning..." : "Scan Network"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -86,156 +83,88 @@ const Dashboard: React.FC = () => {
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div
|
<div
|
||||||
|
className="stagger-children"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
gridTemplateColumns: "repeat(4, 1fr)",
|
||||||
gap: "var(--space-4)",
|
gap: "var(--space-4)",
|
||||||
marginBottom: "var(--space-5)",
|
marginBottom: "var(--space-5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StatCard label="Total Nodes" value={String(nodes.length)} />
|
<StatCard label="Total Nodes" value={nodes.length} />
|
||||||
<StatCard label="Online" value={String(onlineCount)} color="var(--status-online)" />
|
<StatCard label="Online" value={onlineCount} color="var(--status-online)" />
|
||||||
<StatCard label="Offline" value={String(nodes.length - onlineCount)} color="var(--status-error)" />
|
<StatCard label="Offline" value={nodes.length - onlineCount} color={nodes.length - onlineCount > 0 ? "var(--status-error)" : "var(--text-muted)"} />
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Server"
|
label="Server"
|
||||||
value={serverStatus?.running ? "Running" : "Stopped"}
|
value={serverStatus?.running ? "Running" : "Stopped"}
|
||||||
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
|
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
|
||||||
|
isText
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Server status panel */}
|
{/* Two-column layout */}
|
||||||
<div
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-5)" }}>
|
||||||
style={{
|
{/* Server panel */}
|
||||||
background: "var(--bg-surface)",
|
<div className="card">
|
||||||
borderRadius: 8,
|
<h3 className="heading-sm" style={{ marginBottom: "var(--space-3)" }}>Sensing Server</h3>
|
||||||
padding: "var(--space-4)",
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
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 && (
|
|
||||||
<span
|
<span
|
||||||
style={{
|
className={`status-dot ${serverStatus?.running ? "status-dot--online" : "status-dot--error"}`}
|
||||||
fontSize: 12,
|
style={{ width: 10, height: 10 }}
|
||||||
color: "var(--text-muted)",
|
/>
|
||||||
fontFamily: "var(--font-mono)",
|
<span style={{ fontSize: 14, color: "var(--text-primary)", fontWeight: 500 }}>
|
||||||
marginLeft: "var(--space-2)",
|
{serverStatus?.running ? "Running" : "Stopped"}
|
||||||
}}
|
|
||||||
>
|
|
||||||
:{serverStatus.http_port}
|
|
||||||
</span>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Node list */}
|
{/* Node list */}
|
||||||
<h3
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||||
className="heading-sm"
|
<h3 className="heading-sm">Discovered Nodes ({nodes.length})</h3>
|
||||||
style={{
|
</div>
|
||||||
marginBottom: "var(--space-3)",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Discovered Nodes ({nodes.length})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<div
|
<div className="card empty-state">
|
||||||
style={{
|
<div className="empty-state-icon">{"\u25C9"}</div>
|
||||||
background: "var(--bg-surface)",
|
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
|
||||||
borderRadius: 8,
|
No nodes discovered
|
||||||
padding: "var(--space-6)",
|
</div>
|
||||||
textAlign: "center",
|
<div style={{ fontSize: 13, color: "var(--text-muted)", maxWidth: 280, textAlign: "center", lineHeight: 1.5 }}>
|
||||||
color: "var(--text-muted)",
|
Click "Scan Network" to discover ESP32 devices on your local network.
|
||||||
border: "1px solid var(--border)",
|
</div>
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No nodes discovered. Click "Scan Network" to search.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||||
gap: "var(--space-4)",
|
gap: "var(--space-4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node, i) => (
|
{nodes.map((node, i) => (
|
||||||
<div
|
<NodeDashCard key={node.mac || i} node={node} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
color,
|
color,
|
||||||
|
isText = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: number | string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
isText?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const animatedValue = useCountUp(typeof value === "number" ? value : 0);
|
||||||
|
const displayValue = isText || typeof value === "string" ? value : animatedValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="card-glow"
|
||||||
background: "var(--bg-surface)",
|
style={{ padding: "var(--space-4)" }}
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "var(--space-4)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.06em",
|
||||||
color: "var(--text-muted)",
|
color: "var(--text-muted)",
|
||||||
marginBottom: "var(--space-1)",
|
marginBottom: "var(--space-2)",
|
||||||
fontFamily: "var(--font-sans)",
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="data-lg"
|
style={{
|
||||||
style={{ color: color || "var(--text-primary)" }}
|
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>
|
||||||
</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;
|
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