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 | | Field | Value |
|-------|-------| |-------|-------|
| Status | Proposed | | Status | Accepted |
| Date | 2026-03-06 | | Date | 2026-03-06 |
| Deciders | ruv | | Deciders | ruv |
| Depends on | ADR-052 (Tauri Desktop Frontend) | | Depends on | ADR-052 (Tauri Desktop Frontend) |

View File

@ -4,19 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView Desktop</title> <title>RuView Desktop</title>
<style> <link rel="preconnect" href="https://fonts.googleapis.com" />
* { <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
margin: 0; <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
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>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -25,9 +25,9 @@ const NAV_ITEMS: NavItem[] = [
{ id: "nodes", label: "Nodes", shortcut: "N" }, { id: "nodes", label: "Nodes", shortcut: "N" },
{ id: "flash", label: "Flash", shortcut: "F" }, { id: "flash", label: "Flash", shortcut: "F" },
{ id: "ota", label: "OTA", shortcut: "O" }, { 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: "sensing", label: "Sensing", shortcut: "S" },
{ id: "mesh", label: "Mesh", shortcut: "M" }, { id: "mesh", label: "Mesh View", shortcut: "M" },
{ id: "settings", label: "Settings", shortcut: "G" }, { id: "settings", label: "Settings", shortcut: "G" },
]; ];
@ -35,115 +35,178 @@ const App: React.FC = () => {
const [activePage, setActivePage] = useState<Page>("dashboard"); const [activePage, setActivePage] = useState<Page>("dashboard");
return ( return (
<div style={{ display: "flex", height: "100vh" }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
{/* Sidebar */} <div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<nav {/* Sidebar */}
style={{ <nav
width: 200,
background: "#1e293b",
borderRight: "1px solid #334155",
display: "flex",
flexDirection: "column",
padding: "16px 0",
}}
>
<div
style={{ style={{
padding: "0 16px 16px", width: "var(--sidebar-width)",
borderBottom: "1px solid #334155", minWidth: "var(--sidebar-width)",
marginBottom: 8, background: "var(--bg-surface)",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
}} }}
> >
<h1 {/* Brand */}
<div
style={{ style={{
fontSize: 18, padding: "var(--space-4)",
fontWeight: 700, borderBottom: "1px solid var(--border)",
color: "#38bdf8",
}} }}
> >
RuView <h1
</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
style={{ style={{
width: 20, fontSize: 18,
height: 20,
borderRadius: 4,
background:
activePage === item.id ? "#38bdf8" : "#475569",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700, fontWeight: 700,
color: color: "var(--accent)",
activePage === item.id ? "#0f172a" : "#94a3b8", 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> </span>
{item.label} </div>
</button>
))}
<div style={{ flex: 1 }} /> {/* Nav items */}
<div <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={{ style={{
padding: "8px 16px", flex: 1,
fontSize: 11, overflow: "auto",
color: "#475569", background: "var(--bg-base)",
borderTop: "1px solid #334155",
}} }}
> >
v0.3.0 {activePage === "dashboard" && <Dashboard />}
</div> {activePage === "nodes" && <Nodes />}
</nav> {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 */} {/* Status Bar */}
<main style={{ flex: 1, overflow: "auto", padding: 24 }}> <footer
{activePage === "dashboard" && <Dashboard />} style={{
{activePage === "nodes" && <Nodes />} height: "var(--statusbar-height)",
{activePage === "flash" && <FlashFirmware />} minHeight: "var(--statusbar-height)",
{activePage === "settings" && <Settings />} background: "var(--bg-surface)",
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && ( borderTop: "1px solid var(--border)",
<div> display: "flex",
<h2 style={{ fontSize: 24, marginBottom: 8 }}> alignItems: "center",
{NAV_ITEMS.find((n) => n.id === activePage)?.label} padding: "0 var(--space-4)",
</h2> gap: "var(--space-4)",
<p style={{ color: "#64748b" }}> fontSize: 11,
This page is not yet implemented. fontFamily: "var(--font-sans)",
</p> color: "var(--text-muted)",
</div> }}
)} >
</main> <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> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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