feat: implement ADR-053 design system across all frontend components

Create design-system.css with all ADR-053 tokens:
- CSS custom properties: colors, spacing, fonts, panel dimensions
- Typography scale classes (heading-xl through data-lg)
- Form control and button base styles
- Custom scrollbar, selection highlight, animations

Update all components to use design system tokens:
- Replace hardcoded colors with var(--bg-surface), var(--border), etc.
- Replace generic monospace with var(--font-mono) (JetBrains Mono)
- Replace system font stack with var(--font-sans) (Inter)
- Replace spacing values with var(--space-N) tokens
- StatusBadge: use var(--status-online/warning/error/info)
- Dashboard: add stat cards with data-lg class, use StatusBadge
- FlashFirmware: pulse animation on progress bar during writes
- Settings: default bind_address 127.0.0.1 (matches ADR-050)

Add status bar footer with "Powered by rUv", node count, server status.
Load Inter + JetBrains Mono from Google Fonts in index.html.
Update ADR-053 status from Proposed to Accepted.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-06 16:20:46 -05:00
parent cab98df34a
commit e75a3acacb
11 changed files with 760 additions and 874 deletions

View File

@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Status | Proposed |
| Status | Accepted |
| Date | 2026-03-06 |
| Deciders | ruv |
| Depends on | ADR-052 (Tauri Desktop Frontend) |

View File

@ -4,19 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView Desktop</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@ -25,9 +25,9 @@ const NAV_ITEMS: NavItem[] = [
{ id: "nodes", label: "Nodes", shortcut: "N" },
{ id: "flash", label: "Flash", shortcut: "F" },
{ id: "ota", label: "OTA", shortcut: "O" },
{ id: "wasm", label: "WASM", shortcut: "W" },
{ id: "wasm", label: "Edge Modules", shortcut: "W" },
{ id: "sensing", label: "Sensing", shortcut: "S" },
{ id: "mesh", label: "Mesh", shortcut: "M" },
{ id: "mesh", label: "Mesh View", shortcut: "M" },
{ id: "settings", label: "Settings", shortcut: "G" },
];
@ -35,115 +35,178 @@ const App: React.FC = () => {
const [activePage, setActivePage] = useState<Page>("dashboard");
return (
<div style={{ display: "flex", height: "100vh" }}>
{/* Sidebar */}
<nav
style={{
width: 200,
background: "#1e293b",
borderRight: "1px solid #334155",
display: "flex",
flexDirection: "column",
padding: "16px 0",
}}
>
<div
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Sidebar */}
<nav
style={{
padding: "0 16px 16px",
borderBottom: "1px solid #334155",
marginBottom: 8,
width: "var(--sidebar-width)",
minWidth: "var(--sidebar-width)",
background: "var(--bg-surface)",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
}}
>
<h1
{/* Brand */}
<div
style={{
fontSize: 18,
fontWeight: 700,
color: "#38bdf8",
padding: "var(--space-4)",
borderBottom: "1px solid var(--border)",
}}
>
RuView
</h1>
<span style={{ fontSize: 11, color: "#64748b" }}>
WiFi DensePose Desktop
</span>
</div>
{NAV_ITEMS.map((item) => (
<button
key={item.id}
onClick={() => setActivePage(item.id)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px 16px",
border: "none",
background:
activePage === item.id ? "#334155" : "transparent",
color:
activePage === item.id ? "#f1f5f9" : "#94a3b8",
cursor: "pointer",
fontSize: 14,
textAlign: "left",
borderLeft:
activePage === item.id
? "3px solid #38bdf8"
: "3px solid transparent",
}}
>
<span
<h1
style={{
width: 20,
height: 20,
borderRadius: 4,
background:
activePage === item.id ? "#38bdf8" : "#475569",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontSize: 18,
fontWeight: 700,
color:
activePage === item.id ? "#0f172a" : "#94a3b8",
color: "var(--accent)",
fontFamily: "var(--font-sans)",
margin: 0,
}}
>
{item.shortcut}
RuView
</h1>
<span
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "var(--font-sans)",
}}
>
WiFi DensePose Desktop
</span>
{item.label}
</button>
))}
</div>
<div style={{ flex: 1 }} />
<div
{/* Nav items */}
<div style={{ flex: 1, paddingTop: "var(--space-2)" }}>
{NAV_ITEMS.map((item) => {
const isActive = activePage === item.id;
return (
<button
key={item.id}
onClick={() => setActivePage(item.id)}
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-2)",
width: "100%",
padding: "10px var(--space-4)",
background: isActive ? "var(--bg-active)" : "transparent",
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: isActive ? 600 : 400,
textAlign: "left",
borderLeft: isActive
? "3px solid var(--accent)"
: "3px solid transparent",
fontFamily: "var(--font-sans)",
}}
>
<span
style={{
width: 20,
height: 20,
borderRadius: 4,
background: isActive ? "var(--accent)" : "var(--bg-hover)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
fontFamily: "var(--font-mono)",
color: isActive ? "#fff" : "var(--text-muted)",
}}
>
{item.shortcut}
</span>
{item.label}
</button>
);
})}
</div>
{/* Version */}
<div
style={{
padding: "var(--space-2) var(--space-4)",
fontSize: 11,
color: "var(--text-muted)",
borderTop: "1px solid var(--border)",
fontFamily: "var(--font-mono)",
}}
>
v0.3.0
</div>
</nav>
{/* Main content */}
<main
style={{
padding: "8px 16px",
fontSize: 11,
color: "#475569",
borderTop: "1px solid #334155",
flex: 1,
overflow: "auto",
background: "var(--bg-base)",
}}
>
v0.3.0
</div>
</nav>
{activePage === "dashboard" && <Dashboard />}
{activePage === "nodes" && <Nodes />}
{activePage === "flash" && <FlashFirmware />}
{activePage === "settings" && <Settings />}
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
<div style={{ padding: "var(--space-5)" }}>
<h2
style={{
fontSize: 20,
fontWeight: 600,
marginBottom: "var(--space-2)",
}}
>
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
</h2>
<p style={{ color: "var(--text-secondary)", fontSize: 13 }}>
This page is not yet implemented.
</p>
</div>
)}
</main>
</div>
{/* Main content */}
<main style={{ flex: 1, overflow: "auto", padding: 24 }}>
{activePage === "dashboard" && <Dashboard />}
{activePage === "nodes" && <Nodes />}
{activePage === "flash" && <FlashFirmware />}
{activePage === "settings" && <Settings />}
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
<div>
<h2 style={{ fontSize: 24, marginBottom: 8 }}>
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
</h2>
<p style={{ color: "#64748b" }}>
This page is not yet implemented.
</p>
</div>
)}
</main>
{/* Status Bar */}
<footer
style={{
height: "var(--statusbar-height)",
minHeight: "var(--statusbar-height)",
background: "var(--bg-surface)",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
padding: "0 var(--space-4)",
gap: "var(--space-4)",
fontSize: 11,
fontFamily: "var(--font-sans)",
color: "var(--text-muted)",
}}
>
<span style={{ color: "var(--text-muted)" }}>Powered by rUv</span>
<span style={{ color: "var(--border)" }}>|</span>
<span>
<span
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: "var(--status-online)",
marginRight: 4,
verticalAlign: "middle",
}}
/>
0 nodes online
</span>
<span style={{ color: "var(--border)" }}>|</span>
<span>Server: stopped</span>
<span style={{ color: "var(--border)" }}>|</span>
<span style={{ fontFamily: "var(--font-mono)" }}>Port: 8080</span>
</footer>
</div>
);
};

View File

@ -17,8 +17,7 @@ function formatUptime(secs: number | null): string {
function formatLastSeen(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
const diffMs = now - d.getTime();
const diffMs = Date.now() - d.getTime();
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
@ -35,21 +34,21 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
<div
onClick={() => onClick?.(node)}
style={{
background: "var(--card-bg, #1e1e2e)",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "8px",
padding: "16px",
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-4)",
cursor: onClick ? "pointer" : "default",
opacity: isOnline ? 1 : 0.6,
transition: "border-color 0.15s, box-shadow 0.15s",
transition: "border-color 0.15s, background 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent, #6366f1)";
e.currentTarget.style.boxShadow = "0 0 0 1px var(--accent, #6366f1)";
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border, #2e2e3e)";
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.background = "var(--bg-elevated)";
}}
>
{/* Header */}
@ -58,25 +57,26 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "12px",
marginBottom: "var(--space-3)",
}}
>
<div>
<div
style={{
fontSize: "14px",
fontWeight: 700,
color: "var(--text-primary, #e2e8f0)",
marginBottom: "2px",
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
fontFamily: "var(--font-sans)",
marginBottom: 2,
}}
>
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary, #94a3b8)",
fontFamily: "monospace",
fontSize: 12,
color: "var(--text-secondary)",
fontFamily: "var(--font-mono)",
}}
>
{node.ip}
@ -90,12 +90,12 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "8px 16px",
fontSize: "12px",
gap: "var(--space-2) var(--space-4)",
fontSize: 12,
}}
>
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} />
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} mono />
<DetailRow label="Chip" value={node.chip.toUpperCase()} />
<DetailRow label="Role" value={node.mesh_role} />
<DetailRow
@ -105,12 +105,13 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
? `${node.tdm_slot}/${node.tdm_total}`
: "--"
}
mono
/>
<DetailRow
label="Edge Tier"
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
/>
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} />
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} mono />
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
</div>
</div>
@ -130,20 +131,21 @@ function DetailRow({
<div>
<div
style={{
color: "var(--text-muted, #64748b)",
fontSize: "10px",
color: "var(--text-muted)",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "1px",
marginBottom: 1,
fontFamily: "var(--font-sans)",
}}
>
{label}
</div>
<div
style={{
color: "var(--text-secondary, #94a3b8)",
fontFamily: mono ? "monospace" : "inherit",
fontSize: "12px",
color: "var(--text-secondary)",
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",

View File

@ -2,69 +2,53 @@ import type { HealthStatus } from "../types";
interface StatusBadgeProps {
status: HealthStatus;
/** Optional size variant. Default: "sm" */
size?: "sm" | "md" | "lg";
}
const STATUS_STYLES: Record<HealthStatus, { bg: string; text: string; label: string }> = {
online: {
bg: "rgba(34, 197, 94, 0.15)",
text: "#22c55e",
label: "Online",
},
offline: {
bg: "rgba(239, 68, 68, 0.15)",
text: "#ef4444",
label: "Offline",
},
degraded: {
bg: "rgba(234, 179, 8, 0.15)",
text: "#eab308",
label: "Degraded",
},
unknown: {
bg: "rgba(148, 163, 184, 0.15)",
text: "#94a3b8",
label: "Unknown",
},
const STATUS_STYLES: Record<HealthStatus, { color: string; label: string }> = {
online: { color: "var(--status-online)", label: "Online" },
offline: { color: "var(--status-error)", label: "Offline" },
degraded: { color: "var(--status-warning)", label: "Degraded" },
unknown: { color: "var(--text-muted)", label: "Unknown" },
};
const SIZE_STYLES: Record<string, { fontSize: string; padding: string; dot: string }> = {
sm: { fontSize: "11px", padding: "2px 8px", dot: "6px" },
md: { fontSize: "13px", padding: "4px 12px", dot: "8px" },
lg: { fontSize: "15px", padding: "6px 16px", dot: "10px" },
const SIZE_STYLES: Record<string, { fontSize: number; padding: string; dot: number }> = {
sm: { fontSize: 11, padding: "2px 8px", dot: 6 },
md: { fontSize: 13, padding: "4px 12px", dot: 8 },
lg: { fontSize: 15, padding: "6px 16px", dot: 10 },
};
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
const style = STATUS_STYLES[status];
const sizeStyle = SIZE_STYLES[size];
const { color, label } = STATUS_STYLES[status];
const s = SIZE_STYLES[size];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
backgroundColor: style.bg,
color: style.text,
fontSize: sizeStyle.fontSize,
gap: 6,
color,
fontSize: s.fontSize,
fontWeight: 600,
padding: sizeStyle.padding,
borderRadius: "9999px",
fontFamily: "var(--font-sans)",
padding: s.padding,
borderRadius: 9999,
lineHeight: 1,
whiteSpace: "nowrap",
background: "rgba(255, 255, 255, 0.04)",
}}
>
<span
style={{
width: sizeStyle.dot,
height: sizeStyle.dot,
width: s.dot,
height: s.dot,
borderRadius: "50%",
backgroundColor: style.text,
backgroundColor: color,
flexShrink: 0,
}}
/>
{style.label}
{label}
</span>
);
}

View File

@ -0,0 +1,152 @@
/*
* RuView Design System (ADR-053)
* Dark professional + Unity-inspired interface
*/
/* ===== Design Tokens ===== */
:root {
/* Background layers */
--bg-base: #0d1117;
--bg-surface: #161b22;
--bg-elevated: #1c2333;
--bg-hover: #242d3d;
--bg-active: #2d3748;
/* Text hierarchy */
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
/* Status indicators */
--status-online: #3fb950;
--status-warning: #d29922;
--status-error: #f85149;
--status-info: #58a6ff;
/* Accent */
--accent: #7c3aed;
--accent-hover: #6d28d9;
/* Borders */
--border: #30363d;
--border-active: #58a6ff;
/* Fonts */
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Spacing (4px base grid) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-8: 48px;
/* Panel dimensions */
--sidebar-width: 220px;
--sidebar-collapsed: 52px;
--statusbar-height: 28px;
--toolbar-height: 44px;
}
/* ===== Reset ===== */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
background: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===== Typography Scale ===== */
.heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); }
.heading-lg { font: 600 20px/1.3 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); }
.body { font: 400 14px/1.6 var(--font-sans); color: var(--text-primary); }
.body-sm { font: 400 12px/1.5 var(--font-sans); color: var(--text-secondary); }
.data { font: 400 13px/1.4 var(--font-mono); color: var(--text-secondary); }
.data-lg { font: 500 18px/1.2 var(--font-mono); color: var(--text-primary); }
/* ===== Scrollbar ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-base);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--bg-active);
}
/* ===== Form Controls ===== */
input, select, textarea {
font-family: var(--font-sans);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
outline: none;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
input:disabled, select:disabled, textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="number"] {
font-family: var(--font-mono);
}
select {
cursor: pointer;
}
/* ===== Buttons ===== */
button {
font-family: var(--font-sans);
cursor: pointer;
border: none;
outline: none;
transition: background 0.15s, opacity 0.15s;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* ===== Animations ===== */
@keyframes pulse-accent {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ===== Selection ===== */
::selection {
background: rgba(124, 58, 237, 0.3);
color: var(--text-primary);
}

View File

@ -1,5 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./design-system.css";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { StatusBadge } from "../components/StatusBadge";
import type { HealthStatus } from "../types";
interface DiscoveredNode {
ip: string;
@ -6,7 +8,7 @@ interface DiscoveredNode {
hostname: string | null;
node_id: number;
firmware_version: string | null;
health: string;
health: HealthStatus;
last_seen: string;
}
@ -52,27 +54,29 @@ const Dashboard: React.FC = () => {
fetchServerStatus();
}, []);
const onlineCount = nodes.filter((n) => n.health === "online").length;
return (
<div>
<div style={{ padding: "var(--space-5)" }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
marginBottom: "var(--space-5)",
}}
>
<h2 style={{ fontSize: 24 }}>Dashboard</h2>
<h2 className="heading-lg">Dashboard</h2>
<button
onClick={handleScan}
disabled={scanning}
style={{
padding: "8px 16px",
background: scanning ? "#475569" : "#38bdf8",
color: "#0f172a",
border: "none",
padding: "var(--space-2) var(--space-4)",
background: scanning ? "var(--bg-active)" : "var(--accent)",
color: scanning ? "var(--text-muted)" : "#fff",
borderRadius: 6,
cursor: scanning ? "not-allowed" : "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
@ -80,45 +84,74 @@ const Dashboard: React.FC = () => {
</button>
</div>
{/* Stats row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard label="Total Nodes" value={String(nodes.length)} />
<StatCard label="Online" value={String(onlineCount)} color="var(--status-online)" />
<StatCard label="Offline" value={String(nodes.length - onlineCount)} color="var(--status-error)" />
<StatCard
label="Server"
value={serverStatus?.running ? "Running" : "Stopped"}
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
/>
</div>
{/* Server status panel */}
<div
style={{
background: "#1e293b",
background: "var(--bg-surface)",
borderRadius: 8,
padding: 16,
marginBottom: 24,
border: "1px solid #334155",
padding: "var(--space-4)",
marginBottom: "var(--space-5)",
border: "1px solid var(--border)",
}}
>
<h3 style={{ fontSize: 14, color: "#94a3b8", marginBottom: 8 }}>
<h3 className="heading-sm" style={{ marginBottom: "var(--space-2)" }}>
Sensing Server
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<span
style={{
width: 10,
height: 10,
width: 8,
height: 8,
borderRadius: "50%",
background: serverStatus?.running ? "#22c55e" : "#ef4444",
display: "inline-block",
background: serverStatus?.running ? "var(--status-online)" : "var(--status-error)",
}}
/>
<span>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>
{serverStatus?.running
? `Running (PID ${serverStatus.pid})`
: "Stopped"}
</span>
{serverStatus?.running && serverStatus.http_port && (
<span
style={{
fontSize: 12,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
marginLeft: "var(--space-2)",
}}
>
:{serverStatus.http_port}
</span>
)}
</div>
</div>
{/* Node grid */}
{/* Node list */}
<h3
className="heading-sm"
style={{
fontSize: 14,
color: "#94a3b8",
marginBottom: 12,
marginBottom: "var(--space-3)",
textTransform: "uppercase",
letterSpacing: 1,
letterSpacing: "0.05em",
}}
>
Discovered Nodes ({nodes.length})
@ -127,12 +160,13 @@ const Dashboard: React.FC = () => {
{nodes.length === 0 ? (
<div
style={{
background: "#1e293b",
background: "var(--bg-surface)",
borderRadius: 8,
padding: 32,
padding: "var(--space-6)",
textAlign: "center",
color: "#64748b",
border: "1px solid #334155",
color: "var(--text-muted)",
border: "1px solid var(--border)",
fontSize: 13,
}}
>
No nodes discovered. Click "Scan Network" to search.
@ -142,55 +176,64 @@ const Dashboard: React.FC = () => {
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 16,
gap: "var(--space-4)",
}}
>
{nodes.map((node, i) => (
<div
key={node.mac || i}
style={{
background: "#1e293b",
background: "var(--bg-surface)",
borderRadius: 8,
padding: 16,
border: "1px solid #334155",
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: 12,
marginBottom: "var(--space-3)",
}}
>
<div>
<div style={{ fontWeight: 600 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{node.hostname || `Node ${node.node_id}`}
</div>
<div style={{ fontSize: 12, color: "#64748b" }}>
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
}}
>
{node.ip}
</div>
</div>
<span
style={{
padding: "2px 8px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background:
node.health === "online" ? "#064e3b" : "#7f1d1d",
color:
node.health === "online" ? "#34d399" : "#fca5a5",
}}
>
{node.health}
</span>
<StatusBadge status={node.health} />
</div>
<div style={{ fontSize: 13, color: "#94a3b8" }}>
<div>MAC: {node.mac || "unknown"}</div>
<div>Firmware: {node.firmware_version || "unknown"}</div>
<div>Node ID: {node.node_id}</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>
))}
@ -200,4 +243,44 @@ const Dashboard: React.FC = () => {
);
};
function StatCard({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) {
return (
<div
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-4)",
}}
>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted)",
marginBottom: "var(--space-1)",
fontFamily: "var(--font-sans)",
}}
>
{label}
</div>
<div
className="data-lg"
style={{ color: color || "var(--text-primary)" }}
>
{value}
</div>
</div>
);
}
export default Dashboard;

View File

@ -6,32 +6,25 @@ import type { SerialPort, Chip, FlashProgress, FlashPhase } from "../types";
type WizardStep = 1 | 2 | 3;
export function FlashFirmware() {
// --- State ---
const [step, setStep] = useState<WizardStep>(1);
const [ports, setPorts] = useState<SerialPort[]>([]);
const [selectedPort, setSelectedPort] = useState<string>("");
const [firmwarePath, setFirmwarePath] = useState<string>("");
const [selectedPort, setSelectedPort] = useState("");
const [firmwarePath, setFirmwarePath] = useState("");
const [chip, setChip] = useState<Chip>("esp32s3");
const [baud, setBaud] = useState<number>(460800);
const [baud, setBaud] = useState(460800);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [progress, setProgress] = useState<FlashProgress | null>(null);
const [isFlashing, setIsFlashing] = useState(false);
const [flashResult, setFlashResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [flashResult, setFlashResult] = useState<{ success: boolean; message: string } | null>(null);
const [error, setError] = useState<string | null>(null);
// --- Load serial ports ---
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
setError(null);
try {
const result = await invoke<SerialPort[]>("list_serial_ports");
setPorts(result);
if (result.length === 1) {
setSelectedPort(result[0].name);
}
if (result.length === 1) setSelectedPort(result[0].name);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@ -39,26 +32,16 @@ export function FlashFirmware() {
}
}, []);
useEffect(() => {
loadPorts();
}, [loadPorts]);
useEffect(() => { loadPorts(); }, [loadPorts]);
// --- Listen for flash progress events ---
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<FlashProgress>("flash-progress", (event) => {
setProgress(event.payload);
}).then((fn) => {
unlisten = fn;
});
return () => {
unlisten?.();
};
}).then((fn) => { unlisten = fn; });
return () => { unlisten?.(); };
}, []);
// --- File picker ---
const pickFirmware = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
@ -69,43 +52,28 @@ export function FlashFirmware() {
{ name: "All Files", extensions: ["*"] },
],
});
if (selected && typeof selected === "string") {
setFirmwarePath(selected);
}
if (selected && typeof selected === "string") setFirmwarePath(selected);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// --- Flash ---
const startFlash = async () => {
if (!selectedPort || !firmwarePath) return;
setIsFlashing(true);
setFlashResult(null);
setProgress(null);
setError(null);
try {
await invoke("flash_firmware", {
port: selectedPort,
firmwarePath,
chip,
baud,
});
setFlashResult({
success: true,
message: "Firmware flashed successfully.",
});
await invoke("flash_firmware", { port: selectedPort, firmwarePath, chip, baud });
setFlashResult({ success: true, message: "Firmware flashed successfully." });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setFlashResult({ success: false, message: msg });
setFlashResult({ success: false, message: err instanceof Error ? err.message : String(err) });
} finally {
setIsFlashing(false);
}
};
// --- Step validation ---
const canProceed = (s: WizardStep): boolean => {
if (s === 1) return selectedPort !== "";
if (s === 2) return firmwarePath !== "";
@ -113,43 +81,16 @@ export function FlashFirmware() {
};
return (
<div style={{ padding: "24px", maxWidth: "700px" }}>
<h1
style={{
fontSize: "22px",
fontWeight: 700,
color: "var(--text-primary, #e2e8f0)",
margin: "0 0 4px",
}}
>
Flash Firmware
</h1>
<p
style={{
fontSize: "13px",
color: "var(--text-secondary, #94a3b8)",
marginBottom: "24px",
}}
>
<div style={{ padding: "var(--space-5)", maxWidth: 700 }}>
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Flash Firmware</h1>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
Flash firmware to an ESP32 via serial connection
</p>
{/* Step indicator */}
<StepIndicator current={step} />
{/* Error banner */}
{error && (
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "13px",
color: "#fca5a5",
}}
>
<div style={bannerStyle("var(--status-error)")}>
{error}
</div>
)}
@ -157,47 +98,33 @@ export function FlashFirmware() {
{/* Step 1: Select Serial Port */}
{step === 1 && (
<div style={cardStyle}>
<h2 style={stepTitle}>Step 1: Select Serial Port</h2>
<p style={stepDesc}>
Connect your ESP32 via USB and select the serial port.
</p>
<h2 style={stepTitleStyle}>Step 1: Select Serial Port</h2>
<p style={stepDescStyle}>Connect your ESP32 via USB and select the serial port.</p>
<div style={{ marginBottom: "16px" }}>
<div style={{ marginBottom: "var(--space-4)" }}>
<label style={labelStyle}>Serial Port</label>
<div style={{ display: "flex", gap: "8px" }}>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<select
value={selectedPort}
onChange={(e) => setSelectedPort(e.target.value)}
style={{ ...inputStyle, flex: 1 }}
style={{ flex: 1 }}
disabled={isLoadingPorts}
>
<option value="">
{isLoadingPorts
? "Loading..."
: ports.length === 0
? "No ports detected"
: "Select a port..."}
{isLoadingPorts ? "Loading..." : ports.length === 0 ? "No ports detected" : "Select a port..."}
</option>
{ports.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
{p.description ? ` - ${p.description}` : ""}
{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
{p.name}{p.description ? ` - ${p.description}` : ""}{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
</option>
))}
</select>
<button onClick={loadPorts} style={secondaryBtnStyle} disabled={isLoadingPorts}>
Refresh
</button>
<button onClick={loadPorts} style={secondaryBtn} disabled={isLoadingPorts}>Refresh</button>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={() => setStep(2)}
disabled={!canProceed(1)}
style={canProceed(1) ? primaryBtnStyle : disabledBtnStyle}
>
<button onClick={() => setStep(2)} disabled={!canProceed(1)} style={canProceed(1) ? primaryBtn : disabledBtn}>
Next
</button>
</div>
@ -207,42 +134,21 @@ export function FlashFirmware() {
{/* Step 2: Select Firmware */}
{step === 2 && (
<div style={cardStyle}>
<h2 style={stepTitle}>Step 2: Select Firmware</h2>
<p style={stepDesc}>
Choose the firmware binary file and chip configuration.
</p>
<h2 style={stepTitleStyle}>Step 2: Select Firmware</h2>
<p style={stepDescStyle}>Choose the firmware binary file and chip configuration.</p>
<div style={{ marginBottom: "16px" }}>
<div style={{ marginBottom: "var(--space-4)" }}>
<label style={labelStyle}>Firmware Binary (.bin)</label>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={firmwarePath}
readOnly
placeholder="No file selected"
style={{ ...inputStyle, flex: 1 }}
/>
<button onClick={pickFirmware} style={secondaryBtnStyle}>
Browse
</button>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "16px",
marginBottom: "16px",
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-4)" }}>
<div>
<label style={labelStyle}>Chip</label>
<select
value={chip}
onChange={(e) => setChip(e.target.value as Chip)}
style={inputStyle}
>
<select value={chip} onChange={(e) => setChip(e.target.value as Chip)}>
<option value="esp32">ESP32</option>
<option value="esp32s3">ESP32-S3</option>
<option value="esp32c3">ESP32-C3</option>
@ -250,11 +156,7 @@ export function FlashFirmware() {
</div>
<div>
<label style={labelStyle}>Baud Rate</label>
<select
value={baud}
onChange={(e) => setBaud(Number(e.target.value))}
style={inputStyle}
>
<select value={baud} onChange={(e) => setBaud(Number(e.target.value))}>
<option value={115200}>115200</option>
<option value={230400}>230400</option>
<option value={460800}>460800</option>
@ -264,14 +166,8 @@ export function FlashFirmware() {
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<button onClick={() => setStep(1)} style={secondaryBtnStyle}>
Back
</button>
<button
onClick={() => setStep(3)}
disabled={!canProceed(2)}
style={canProceed(2) ? primaryBtnStyle : disabledBtnStyle}
>
<button onClick={() => setStep(1)} style={secondaryBtn}>Back</button>
<button onClick={() => setStep(3)} disabled={!canProceed(2)} style={canProceed(2) ? primaryBtn : disabledBtn}>
Next
</button>
</div>
@ -281,87 +177,58 @@ export function FlashFirmware() {
{/* Step 3: Flash */}
{step === 3 && (
<div style={cardStyle}>
<h2 style={stepTitle}>Step 3: Flash</h2>
<h2 style={stepTitleStyle}>Step 3: Flash</h2>
{/* Summary */}
<div
style={{
background: "rgba(0,0,0,0.15)",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "12px",
background: "var(--bg-base)",
borderRadius: 6,
padding: "var(--space-3) var(--space-4)",
marginBottom: "var(--space-4)",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "8px",
gap: "var(--space-2)",
fontSize: 12,
}}
>
<SummaryField label="Port" value={selectedPort} />
<SummaryField
label="Firmware"
value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath}
/>
<SummaryField label="Firmware" value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath} />
<SummaryField label="Chip" value={chip.toUpperCase()} />
<SummaryField label="Baud" value={String(baud)} />
</div>
{/* Progress */}
{(isFlashing || progress) && !flashResult && (
<div style={{ marginBottom: "16px" }}>
<div style={{ marginBottom: "var(--space-4)" }}>
<ProgressBar progress={progress} />
</div>
)}
{/* Result */}
{flashResult && (
<div
style={{
background: flashResult.success
? "rgba(34, 197, 94, 0.1)"
: "rgba(239, 68, 68, 0.1)",
border: `1px solid ${flashResult.success ? "rgba(34, 197, 94, 0.3)" : "rgba(239, 68, 68, 0.3)"}`,
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "13px",
color: flashResult.success ? "#86efac" : "#fca5a5",
}}
>
<div style={bannerStyle(flashResult.success ? "var(--status-online)" : "var(--status-error)")}>
{flashResult.message}
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between" }}>
<button
onClick={() => {
setStep(2);
setFlashResult(null);
setProgress(null);
}}
style={secondaryBtnStyle}
onClick={() => { setStep(2); setFlashResult(null); setProgress(null); }}
style={secondaryBtn}
disabled={isFlashing}
>
Back
</button>
{flashResult ? (
<button
onClick={() => {
setStep(1);
setFlashResult(null);
setProgress(null);
setFirmwarePath("");
setSelectedPort("");
}}
style={primaryBtnStyle}
onClick={() => { setStep(1); setFlashResult(null); setProgress(null); setFirmwarePath(""); setSelectedPort(""); }}
style={primaryBtn}
>
Flash Another
</button>
) : (
<button
onClick={startFlash}
disabled={isFlashing}
style={isFlashing ? disabledBtnStyle : primaryBtnStyle}
>
<button onClick={startFlash} disabled={isFlashing} style={isFlashing ? disabledBtn : primaryBtn}>
{isFlashing ? "Flashing..." : "Start Flash"}
</button>
)}
@ -372,9 +239,7 @@ export function FlashFirmware() {
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
// --- Sub-components ---
function StepIndicator({ current }: { current: WizardStep }) {
const steps = [
@ -384,65 +249,42 @@ function StepIndicator({ current }: { current: WizardStep }) {
];
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0",
marginBottom: "24px",
}}
>
<div style={{ display: "flex", alignItems: "center", marginBottom: "var(--space-5)" }}>
{steps.map(({ n, label }, i) => {
const isActive = n === current;
const isDone = n < current;
return (
<div key={n} style={{ display: "flex", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<div
style={{
width: "28px",
height: "28px",
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontSize: 12,
fontWeight: 700,
background: isActive
? "var(--accent, #6366f1)"
: isDone
? "rgba(34, 197, 94, 0.2)"
: "var(--border, #2e2e3e)",
color: isActive
? "#fff"
: isDone
? "#22c55e"
: "var(--text-muted, #64748b)",
fontFamily: "var(--font-mono)",
background: isActive ? "var(--accent)" : isDone ? "rgba(63, 185, 80, 0.2)" : "var(--border)",
color: isActive ? "#fff" : isDone ? "var(--status-online)" : "var(--text-muted)",
}}
>
{isDone ? "\u2713" : n}
</div>
<span
style={{
fontSize: "12px",
fontSize: 12,
fontWeight: isActive ? 600 : 400,
color: isActive
? "var(--text-primary, #e2e8f0)"
: "var(--text-muted, #64748b)",
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
}}
>
{label}
</span>
</div>
{i < steps.length - 1 && (
<div
style={{
width: "40px",
height: "1px",
background: "var(--border, #2e2e3e)",
margin: "0 12px",
}}
/>
<div style={{ width: 40, height: 1, background: "var(--border)", margin: "0 var(--space-3)" }} />
)}
</div>
);
@ -468,42 +310,21 @@ function ProgressBar({ progress }: { progress: FlashProgress | null }) {
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "12px",
marginBottom: "6px",
}}
>
<span style={{ color: "var(--text-secondary, #94a3b8)" }}>
{PHASE_LABELS[phase]}
</span>
<span style={{ color: "var(--text-muted, #64748b)" }}>
{pct.toFixed(1)}% {speedKB && `| ${speedKB}`}
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 6 }}>
<span style={{ color: "var(--text-secondary)" }}>{PHASE_LABELS[phase]}</span>
<span style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
{pct.toFixed(1)}%{speedKB && ` | ${speedKB}`}
</span>
</div>
<div
style={{
width: "100%",
height: "8px",
background: "var(--border, #2e2e3e)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<div style={{ width: "100%", height: 8, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
<div
style={{
width: `${Math.min(pct, 100)}%`,
height: "100%",
background:
phase === "error"
? "#ef4444"
: phase === "done"
? "#22c55e"
: "var(--accent, #6366f1)",
borderRadius: "4px",
background: phase === "error" ? "var(--status-error)" : phase === "done" ? "var(--status-online)" : "var(--accent)",
borderRadius: 4,
transition: "width 0.3s ease",
animation: phase === "writing" ? "pulse-accent 2s infinite" : "none",
}}
/>
</div>
@ -514,102 +335,81 @@ function ProgressBar({ progress }: { progress: FlashProgress | null }) {
function SummaryField({ label, value }: { label: string; value: string }) {
return (
<div>
<div
style={{
fontSize: "10px",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted, #64748b)",
marginBottom: "1px",
}}
>
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: 1 }}>
{label}
</div>
<div
style={{
color: "var(--text-secondary, #94a3b8)",
fontFamily: "monospace",
fontSize: "12px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
<div style={{ color: "var(--text-secondary)", fontFamily: "var(--font-mono)", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{value}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
// --- 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(--card-bg, #1e1e2e)",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "8px",
padding: "20px",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-5)",
};
const stepTitle: React.CSSProperties = {
fontSize: "16px",
const stepTitleStyle: React.CSSProperties = {
fontSize: 16,
fontWeight: 600,
color: "var(--text-primary, #e2e8f0)",
margin: "0 0 4px",
color: "var(--text-primary)",
margin: "0 0 var(--space-1)",
fontFamily: "var(--font-sans)",
};
const stepDesc: React.CSSProperties = {
fontSize: "13px",
color: "var(--text-secondary, #94a3b8)",
marginBottom: "16px",
const stepDescStyle: React.CSSProperties = {
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: "var(--space-4)",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: "12px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary, #94a3b8)",
marginBottom: "6px",
color: "var(--text-secondary)",
marginBottom: 6,
fontFamily: "var(--font-sans)",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "6px",
background: "var(--input-bg, #12121a)",
color: "var(--text-primary, #e2e8f0)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
};
const primaryBtnStyle: React.CSSProperties = {
padding: "8px 20px",
border: "none",
borderRadius: "6px",
background: "var(--accent, #6366f1)",
const primaryBtn: React.CSSProperties = {
padding: "var(--space-2) 20px",
borderRadius: 6,
background: "var(--accent)",
color: "#fff",
fontSize: "13px",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
const secondaryBtnStyle: React.CSSProperties = {
padding: "8px 16px",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "6px",
const secondaryBtn: React.CSSProperties = {
padding: "var(--space-2) var(--space-4)",
border: "1px solid var(--border)",
borderRadius: 6,
background: "transparent",
color: "var(--text-secondary, #94a3b8)",
fontSize: "13px",
color: "var(--text-secondary)",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
};
const disabledBtnStyle: React.CSSProperties = {
...primaryBtnStyle,
background: "var(--border, #2e2e3e)",
color: "var(--text-muted, #64748b)",
cursor: "not-allowed",
const disabledBtn: React.CSSProperties = {
...primaryBtn,
background: "var(--bg-active)",
color: "var(--text-muted)",
};

View File

@ -16,34 +16,19 @@ export function Nodes() {
};
return (
<div style={{ padding: "24px", maxWidth: "1200px" }}>
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
marginBottom: "var(--space-5)",
}}
>
<div>
<h1
style={{
fontSize: "22px",
fontWeight: 700,
color: "var(--text-primary, #e2e8f0)",
margin: 0,
}}
>
Nodes
</h1>
<p
style={{
fontSize: "13px",
color: "var(--text-secondary, #94a3b8)",
marginTop: "4px",
}}
>
<h1 className="heading-lg" style={{ margin: 0 }}>Nodes</h1>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
</p>
</div>
@ -51,16 +36,12 @@ export function Nodes() {
onClick={scan}
disabled={isScanning}
style={{
padding: "8px 16px",
border: "none",
borderRadius: "6px",
background: isScanning
? "var(--border, #2e2e3e)"
: "var(--accent, #6366f1)",
color: isScanning ? "var(--text-muted, #64748b)" : "#fff",
fontSize: "13px",
padding: "var(--space-2) var(--space-4)",
borderRadius: 6,
background: isScanning ? "var(--bg-active)" : "var(--accent)",
color: isScanning ? "var(--text-muted)" : "#fff",
fontSize: 13,
fontWeight: 600,
cursor: isScanning ? "not-allowed" : "pointer",
}}
>
{isScanning ? "Scanning..." : "Refresh"}
@ -71,13 +52,13 @@ export function Nodes() {
{error && (
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "13px",
color: "#fca5a5",
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}
@ -88,13 +69,13 @@ export function Nodes() {
{nodes.length === 0 ? (
<div
style={{
background: "var(--card-bg, #1e1e2e)",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "8px",
padding: "48px",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-8)",
textAlign: "center",
color: "var(--text-muted, #64748b)",
fontSize: "13px",
color: "var(--text-muted)",
fontSize: 13,
}}
>
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
@ -102,26 +83,15 @@ export function Nodes() {
) : (
<div
style={{
background: "var(--card-bg, #1e1e2e)",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "8px",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
overflow: "hidden",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "13px",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr
style={{
borderBottom: "1px solid var(--border, #2e2e3e)",
textAlign: "left",
}}
>
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
<Th>Status</Th>
<Th>MAC</Th>
<Th>IP</Th>
@ -133,12 +103,11 @@ export function Nodes() {
<tbody>
{nodes.map((node) => {
const key = node.mac ?? node.ip;
const isExpanded = expandedMac === key;
return (
<NodeRow
key={key}
node={node}
isExpanded={isExpanded}
isExpanded={expandedMac === key}
onToggle={() => toggleExpand(node)}
/>
);
@ -151,20 +120,17 @@ export function Nodes() {
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function Th({ children }: { children: React.ReactNode }) {
return (
<th
style={{
padding: "10px 16px",
fontSize: "10px",
padding: "10px var(--space-4)",
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted, #64748b)",
color: "var(--text-muted)",
fontFamily: "var(--font-sans)",
}}
>
{children}
@ -172,20 +138,15 @@ function Th({ children }: { children: React.ReactNode }) {
);
}
function Td({
children,
mono = false,
}: {
children: React.ReactNode;
mono?: boolean;
}) {
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
return (
<td
style={{
padding: "10px 16px",
color: "var(--text-secondary, #94a3b8)",
fontFamily: mono ? "monospace" : "inherit",
padding: "10px var(--space-4)",
color: "var(--text-secondary)",
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
whiteSpace: "nowrap",
fontSize: 13,
}}
>
{children}
@ -220,29 +181,23 @@ function NodeRow({
<tr
onClick={onToggle}
style={{
borderBottom: isExpanded ? "none" : "1px solid var(--border, #2e2e3e)",
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
cursor: "pointer",
transition: "background 0.1s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(255,255,255,0.02)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "transparent")
}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<Td>
<StatusBadge status={node.health} />
</Td>
<Td><StatusBadge status={node.health} /></Td>
<Td mono>{node.mac ?? "--"}</Td>
<Td mono>{node.ip}</Td>
<Td>{node.firmware_version ?? "--"}</Td>
<Td mono>{node.firmware_version ?? "--"}</Td>
<Td>{node.chip.toUpperCase()}</Td>
<Td>{formatLastSeen(node.last_seen)}</Td>
</tr>
{isExpanded && (
<tr style={{ borderBottom: "1px solid var(--border, #2e2e3e)" }}>
<td colSpan={6} style={{ padding: "0 16px 16px" }}>
<tr style={{ borderBottom: "1px solid var(--border)" }}>
<td colSpan={6} style={{ padding: "0 var(--space-4) var(--space-4)" }}>
<ExpandedDetails node={node} />
</td>
</tr>
@ -255,17 +210,17 @@ function ExpandedDetails({ node }: { node: Node }) {
return (
<div
style={{
background: "rgba(0,0,0,0.15)",
borderRadius: "6px",
padding: "16px",
background: "var(--bg-elevated)",
borderRadius: 6,
padding: "var(--space-4)",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: "12px 24px",
fontSize: "12px",
gap: "var(--space-3) var(--space-5)",
fontSize: 12,
}}
>
<DetailField label="Hostname" value={node.hostname ?? "--"} />
<DetailField label="Node ID" value={String(node.node_id)} />
<DetailField label="Node ID" value={String(node.node_id)} mono />
<DetailField label="Mesh Role" value={node.mesh_role} />
<DetailField
label="TDM Slot"
@ -274,10 +229,12 @@ function ExpandedDetails({ node }: { node: Node }) {
? `${node.tdm_slot} / ${node.tdm_total}`
: "--"
}
mono
/>
<DetailField
label="Edge Tier"
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
mono
/>
<DetailField
label="Uptime"
@ -286,6 +243,7 @@ function ExpandedDetails({ node }: { node: Node }) {
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
: "--"
}
mono
/>
<DetailField label="Discovery" value={node.discovery_method} />
<DetailField
@ -299,29 +257,30 @@ function ExpandedDetails({ node }: { node: Node }) {
: "--"
}
/>
{node.friendly_name && (
<DetailField label="Name" value={node.friendly_name} />
)}
{node.friendly_name && <DetailField label="Name" value={node.friendly_name} />}
{node.notes && <DetailField label="Notes" value={node.notes} />}
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
function DetailField({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div
style={{
fontSize: "10px",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted, #64748b)",
marginBottom: "2px",
color: "var(--text-muted)",
marginBottom: 2,
fontFamily: "var(--font-sans)",
}}
>
{label}
</div>
<div style={{ color: "var(--text-secondary, #94a3b8)" }}>{value}</div>
<div style={{ color: "var(--text-secondary)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)" }}>
{value}
</div>
</div>
);
}

View File

@ -5,7 +5,7 @@ const DEFAULT_SETTINGS: AppSettings = {
server_http_port: 8080,
server_ws_port: 8765,
server_udp_port: 5005,
bind_address: "0.0.0.0",
bind_address: "127.0.0.1",
ui_path: "",
ota_psk: "",
auto_discover: true,
@ -19,17 +19,14 @@ export function Settings() {
const [showPsk, setShowPsk] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load persisted settings on mount
useEffect(() => {
(async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const persisted = await invoke<AppSettings | null>("get_settings");
if (persisted) {
setSettings(persisted);
}
if (persisted) setSettings(persisted);
} catch {
// Settings command may not exist yet -- use defaults
// Settings command may not exist yet
}
})();
}, []);
@ -60,55 +57,38 @@ export function Settings() {
};
return (
<div style={{ padding: "24px", maxWidth: "600px" }}>
<h1
style={{
fontSize: "22px",
fontWeight: 700,
color: "var(--text-primary, #e2e8f0)",
margin: "0 0 4px",
}}
>
Settings
</h1>
<p
style={{
fontSize: "13px",
color: "var(--text-secondary, #94a3b8)",
marginBottom: "24px",
}}
>
<div style={{ padding: "var(--space-5)", maxWidth: 600 }}>
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Settings</h1>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
Configure server, network, and application preferences
</p>
{/* Error */}
{error && (
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "13px",
color: "#fca5a5",
background: "rgba(248, 81, 73, 0.1)",
border: "1px solid rgba(248, 81, 73, 0.3)",
borderRadius: 6,
padding: "var(--space-3) var(--space-4)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
{/* Saved toast */}
{saved && (
<div
style={{
background: "rgba(34, 197, 94, 0.1)",
border: "1px solid rgba(34, 197, 94, 0.3)",
borderRadius: "6px",
padding: "12px 16px",
marginBottom: "16px",
fontSize: "13px",
color: "#86efac",
background: "rgba(63, 185, 80, 0.1)",
border: "1px solid rgba(63, 185, 80, 0.3)",
borderRadius: 6,
padding: "var(--space-3) var(--space-4)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-online)",
}}
>
Settings saved.
@ -117,50 +97,32 @@ export function Settings() {
{/* Sensing Server */}
<Section title="Sensing Server">
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "16px",
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
<Field label="HTTP Port">
<NumberInput
value={settings.server_http_port}
onChange={(v) => update("server_http_port", v)}
min={1}
max={65535}
/>
<NumberInput value={settings.server_http_port} onChange={(v) => update("server_http_port", v)} min={1} max={65535} />
</Field>
<Field label="WebSocket Port">
<NumberInput
value={settings.server_ws_port}
onChange={(v) => update("server_ws_port", v)}
min={1}
max={65535}
/>
<NumberInput value={settings.server_ws_port} onChange={(v) => update("server_ws_port", v)} min={1} max={65535} />
</Field>
<Field label="UDP Port">
<NumberInput
value={settings.server_udp_port}
onChange={(v) => update("server_udp_port", v)}
min={1}
max={65535}
/>
<NumberInput value={settings.server_udp_port} onChange={(v) => update("server_udp_port", v)} min={1} max={65535} />
</Field>
<Field label="Bind Address">
<TextInput
<input
type="text"
value={settings.bind_address}
onChange={(v) => update("bind_address", v)}
placeholder="0.0.0.0"
onChange={(e) => update("bind_address", e.target.value)}
placeholder="127.0.0.1"
style={{ fontFamily: "var(--font-mono)" }}
/>
</Field>
</div>
<div style={{ marginTop: "16px" }}>
<div style={{ marginTop: "var(--space-4)" }}>
<Field label="UI Static Files Path">
<TextInput
<input
type="text"
value={settings.ui_path}
onChange={(v) => update("ui_path", v)}
onChange={(e) => update("ui_path", e.target.value)}
placeholder="Leave empty for default"
/>
</Field>
@ -170,28 +132,19 @@ export function Settings() {
{/* Security */}
<Section title="Security">
<Field label="OTA Pre-Shared Key (PSK)">
<div style={{ display: "flex", gap: "8px" }}>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<input
type={showPsk ? "text" : "password"}
value={settings.ota_psk}
onChange={(e) => update("ota_psk", e.target.value)}
placeholder="Enter PSK for OTA authentication"
style={{ ...inputStyle, flex: 1 }}
style={{ flex: 1, fontFamily: "var(--font-mono)" }}
/>
<button
onClick={() => setShowPsk((prev) => !prev)}
style={secondaryBtnStyle}
>
<button onClick={() => setShowPsk((prev) => !prev)} style={secondaryBtn}>
{showPsk ? "Hide" : "Show"}
</button>
</div>
<p
style={{
fontSize: "11px",
color: "var(--text-muted, #64748b)",
marginTop: "4px",
}}
>
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)" }}>
Used for authenticating OTA firmware updates to nodes.
</p>
</Field>
@ -199,36 +152,16 @@ export function Settings() {
{/* Discovery */}
<Section title="Network Discovery">
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "16px",
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
<Field label="Auto-Discover">
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
}}
>
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", cursor: "pointer" }}>
<input
type="checkbox"
checked={settings.auto_discover}
onChange={(e) => update("auto_discover", e.target.checked)}
style={{ accentColor: "var(--accent, #6366f1)" }}
style={{ accentColor: "var(--accent)" }}
/>
<span
style={{
fontSize: "13px",
color: "var(--text-secondary, #94a3b8)",
}}
>
Enable periodic scanning
</span>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Enable periodic scanning</span>
</label>
</Field>
<Field label="Scan Interval (ms)">
@ -245,51 +178,34 @@ export function Settings() {
</Section>
{/* Actions */}
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: "24px",
}}
>
<button onClick={reset} style={secondaryBtnStyle}>
Reset to Defaults
</button>
<button onClick={save} style={primaryBtnStyle}>
Save Settings
</button>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "var(--space-5)" }}>
<button onClick={reset} style={secondaryBtn}>Reset to Defaults</button>
<button onClick={save} style={primaryBtn}>Save Settings</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
// --- Sub-components ---
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div
style={{
background: "var(--card-bg, #1e1e2e)",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "8px",
padding: "20px",
marginBottom: "16px",
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-5)",
marginBottom: "var(--space-4)",
}}
>
<h2
style={{
fontSize: "14px",
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary, #e2e8f0)",
margin: "0 0 16px",
color: "var(--text-primary)",
margin: "0 0 var(--space-4)",
fontFamily: "var(--font-sans)",
}}
>
{title}
@ -299,22 +215,17 @@ function Section({
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label
style={{
display: "block",
fontSize: "12px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary, #94a3b8)",
marginBottom: "6px",
color: "var(--text-secondary)",
marginBottom: 6,
fontFamily: "var(--font-sans)",
}}
>
{label}
@ -324,101 +235,42 @@ function Field({
);
}
function TextInput({
value,
onChange,
placeholder,
disabled = false,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
style={{
...inputStyle,
opacity: disabled ? 0.5 : 1,
}}
/>
);
}
function NumberInput({
value,
onChange,
min,
max,
step = 1,
disabled = false,
value, onChange, min, max, step = 1, disabled = false,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; disabled?: boolean;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (!isNaN(n)) onChange(n);
}}
onChange={(e) => { const n = parseInt(e.target.value, 10); if (!isNaN(n)) onChange(n); }}
min={min}
max={max}
step={step}
disabled={disabled}
style={{
...inputStyle,
opacity: disabled ? 0.5 : 1,
}}
/>
);
}
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
// --- Shared styles ---
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "6px",
background: "var(--input-bg, #12121a)",
color: "var(--text-primary, #e2e8f0)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
};
const primaryBtnStyle: React.CSSProperties = {
padding: "8px 20px",
const primaryBtn: React.CSSProperties = {
padding: "var(--space-2) 20px",
border: "none",
borderRadius: "6px",
background: "var(--accent, #6366f1)",
borderRadius: 6,
background: "var(--accent)",
color: "#fff",
fontSize: "13px",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
const secondaryBtnStyle: React.CSSProperties = {
padding: "8px 16px",
border: "1px solid var(--border, #2e2e3e)",
borderRadius: "6px",
const secondaryBtn: React.CSSProperties = {
padding: "var(--space-2) var(--space-4)",
border: "1px solid var(--border)",
borderRadius: 6,
background: "transparent",
color: "var(--text-secondary, #94a3b8)",
fontSize: "13px",
color: "var(--text-secondary)",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
};