Explorer and exporter were scanning the full Parquet lake every 1–5s (~2000 files, 200MB+), driving ~2.2 CPU cores. Limit metrics to the last N flights, cache DuckDB views and the partition tree, slow live polls to 2s, and keep drones alive after seal to stop restart churn. Add node-exporter, scan-duration metrics, and a Swarm Platform Grafana dashboard for node CPU/memory and scan health.
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
|
import {
|
|
AREA,
|
|
MAX_ASPECT,
|
|
areaWidth,
|
|
fmtBytes,
|
|
makeWorld,
|
|
seed,
|
|
tick,
|
|
type World,
|
|
} from "./sim";
|
|
|
|
const VIEW = 1000;
|
|
const PANEL_W = 320;
|
|
const HEADER_H = 70;
|
|
|
|
// Patrol field aspect ratio for the current window: wide monitors get a
|
|
// wider field instead of empty side margins. Quantized to 0.25 steps so
|
|
// minor resizes don't rebuild the world.
|
|
function displayAspect(): number {
|
|
const w = Math.max(320, window.innerWidth - PANEL_W);
|
|
const h = Math.max(320, window.innerHeight - HEADER_H);
|
|
const q = Math.round((w / h) * 4) / 4;
|
|
return Math.max(1, Math.min(MAX_ASPECT, q));
|
|
}
|
|
|
|
const LIVE_POLL_MS = 2000;
|
|
|
|
export default function App(): JSX.Element {
|
|
const [droneCount, setDroneCount] = useState(8);
|
|
const [speedup, setSpeedup] = useState(1);
|
|
const [paused, setPaused] = useState(false);
|
|
const [aspect, setAspect] = useState(displayAspect);
|
|
const [mode, setMode] = useState<"sim" | "live">("sim");
|
|
const [liveError, setLiveError] = useState<string | null>(null);
|
|
const [world, setWorld] = useState<World>(() => {
|
|
seed(42);
|
|
return makeWorld(8, displayAspect());
|
|
});
|
|
const raf = useRef(0);
|
|
const last = useRef(performance.now());
|
|
const liveWorld = useRef<World | null>(null);
|
|
|
|
useEffect(() => {
|
|
seed(42);
|
|
setWorld(makeWorld(droneCount, aspect));
|
|
}, [droneCount, aspect]);
|
|
|
|
useEffect(() => {
|
|
let timer = 0;
|
|
const onResize = (): void => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(() => setAspect(displayAspect()), 250);
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
window.removeEventListener("resize", onResize);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "sim") return;
|
|
const loop = (now: number): void => {
|
|
const dt = Math.min(0.05, (now - last.current) / 1000);
|
|
last.current = now;
|
|
if (!paused && dt > 0) {
|
|
setWorld((w) => tick(w, dt * speedup));
|
|
}
|
|
raf.current = requestAnimationFrame(loop);
|
|
};
|
|
raf.current = requestAnimationFrame(loop);
|
|
return () => cancelAnimationFrame(raf.current);
|
|
}, [paused, speedup, mode]);
|
|
|
|
// Live mode: poll the explorer's read-only SQL API for real poses
|
|
useEffect(() => {
|
|
if (mode !== "live") return;
|
|
let cancelled = false;
|
|
const poll = async (): Promise<void> => {
|
|
try {
|
|
const poses = await fetchLivePoses(DEFAULT_EXPLORER_URL);
|
|
if (cancelled) return;
|
|
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
|
|
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
|
|
setWorld(liveWorld.current);
|
|
} catch (err) {
|
|
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
void poll();
|
|
const timer = window.setInterval(() => void poll(), LIVE_POLL_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [mode]);
|
|
|
|
useEffect(() => {
|
|
if (mode === "sim") {
|
|
liveWorld.current = null;
|
|
setLiveError(null);
|
|
seed(42);
|
|
setWorld(makeWorld(droneCount, aspect));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
|
|
}, [mode]);
|
|
|
|
const activeLinks = useMemo(
|
|
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
|
[world.links],
|
|
);
|
|
const busiest = activeLinks.slice(0, 6);
|
|
|
|
return (
|
|
<div style={styles.shell}>
|
|
<header style={styles.header}>
|
|
<div>
|
|
<div style={styles.title}>SWARM HOUSE</div>
|
|
<div style={styles.subtitle}>
|
|
swarm data plane — pose broadcast + opportunistic bulk sync
|
|
</div>
|
|
</div>
|
|
<div style={styles.controls}>
|
|
<button
|
|
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
|
|
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
|
|
title={`live source: ${DEFAULT_EXPLORER_URL}`}
|
|
>
|
|
{mode === "sim" ? "go live" : "back to sim"}
|
|
</button>
|
|
{mode === "live" && (
|
|
<span style={liveError ? styles.liveError : styles.liveOk}>
|
|
{liveError ?? `live · ${DEFAULT_EXPLORER_URL}`}
|
|
</span>
|
|
)}
|
|
{mode === "sim" && (
|
|
<>
|
|
<label style={styles.label}>
|
|
drones
|
|
<input
|
|
type="range"
|
|
min={3}
|
|
max={24}
|
|
value={droneCount}
|
|
onChange={(e) => setDroneCount(Number(e.target.value))}
|
|
/>
|
|
<span style={styles.value}>{droneCount}</span>
|
|
</label>
|
|
<label style={styles.label}>
|
|
speed
|
|
<input
|
|
type="range"
|
|
min={0.5}
|
|
max={6}
|
|
step={0.5}
|
|
value={speedup}
|
|
onChange={(e) => setSpeedup(Number(e.target.value))}
|
|
/>
|
|
<span style={styles.value}>{speedup}x</span>
|
|
</label>
|
|
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
|
|
{paused ? "resume" : "pause"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<div style={styles.main}>
|
|
<svg
|
|
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
|
style={styles.canvas}
|
|
preserveAspectRatio="xMidYMid meet"
|
|
>
|
|
<defs>
|
|
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
|
<stop offset="0%" stopColor="#101826" />
|
|
<stop offset="100%" stopColor="#0a0f18" />
|
|
</radialGradient>
|
|
</defs>
|
|
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
|
{gridLines()}
|
|
|
|
{/* Mesh links */}
|
|
{activeLinks.map((l) => {
|
|
const a = world.drones[l.a];
|
|
const b = world.drones[l.b];
|
|
if (!a || !b) return null;
|
|
const mx = (a.pos.x + b.pos.x) / 2;
|
|
const my = (a.pos.y + b.pos.y) / 2;
|
|
const hot = l.rate > 20_000;
|
|
return (
|
|
<g key={l.key}>
|
|
<line
|
|
x1={sx(a.pos.x)}
|
|
y1={sy(a.pos.y)}
|
|
x2={sx(b.pos.x)}
|
|
y2={sy(b.pos.y)}
|
|
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
|
strokeWidth={hot ? 2.2 : 1}
|
|
opacity={0.25 + l.flash * 0.75}
|
|
/>
|
|
{hot && (
|
|
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
|
|
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
|
|
</text>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Obstacles */}
|
|
{world.obstacles.map((ob) => (
|
|
<g key={ob.id}>
|
|
<circle
|
|
cx={sx(ob.pos.x)}
|
|
cy={sy(ob.pos.y)}
|
|
r={(ob.radius / AREA) * VIEW}
|
|
fill={ob.moving ? "#5a2a35" : "#2a3245"}
|
|
stroke={ob.moving ? "#c05a6a" : "#46536e"}
|
|
strokeWidth={1.5}
|
|
opacity={0.85}
|
|
/>
|
|
{ob.moving && (
|
|
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
|
transit
|
|
</text>
|
|
)}
|
|
</g>
|
|
))}
|
|
|
|
{/* Drones */}
|
|
{world.drones.map((d) => (
|
|
<g
|
|
key={d.id}
|
|
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
|
|
>
|
|
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
|
|
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
|
|
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
|
|
</text>
|
|
</g>
|
|
))}
|
|
</svg>
|
|
|
|
<aside style={styles.panel}>
|
|
<Section title="Mission">
|
|
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
|
|
<Stat name="drones" value={String(world.drones.length)} />
|
|
<Stat name="active links" value={String(world.links.size)} />
|
|
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
|
|
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
|
|
</Section>
|
|
<Section title="Busiest links">
|
|
{busiest.map((l) => (
|
|
<div key={l.key} style={styles.linkRow}>
|
|
<span style={styles.linkName}>
|
|
{world.drones[l.a]?.name ?? `dr-${String(l.a + 1).padStart(2, "0")}`} ↔{" "}
|
|
{world.drones[l.b]?.name ?? `dr-${String(l.b + 1).padStart(2, "0")}`}
|
|
</span>
|
|
<span style={styles.linkStat}>
|
|
{fmtBytes(l.rate)}/s · ↑{fmtBytes(l.totalUp)} ↓{fmtBytes(l.totalDown)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
|
</Section>
|
|
<Section title="Legend">
|
|
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
|
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
|
<div style={styles.dim}>flash — broadcast event delivered</div>
|
|
<div style={styles.dim}>red circle — transit object crossing the area</div>
|
|
</Section>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function sx(x: number): number {
|
|
return (x / AREA) * VIEW;
|
|
}
|
|
function sy(y: number): number {
|
|
return (y / AREA) * VIEW;
|
|
}
|
|
|
|
function gridLines(): JSX.Element {
|
|
const width = sx(areaWidth());
|
|
const step = VIEW / 10; // one cell per 100 m in both directions
|
|
const lines = [];
|
|
for (let p = step; p < width; p += step) {
|
|
lines.push(
|
|
<line key={`v${p}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
|
);
|
|
}
|
|
for (let p = step; p < VIEW; p += step) {
|
|
lines.push(
|
|
<line key={`h${p}`} x1={0} y1={p} x2={width} y2={p} stroke="#1a2436" strokeWidth={1} />,
|
|
);
|
|
}
|
|
return <g>{lines}</g>;
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
|
|
return (
|
|
<div style={styles.section}>
|
|
<div style={styles.sectionTitle}>{title}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Stat({ name, value }: { name: string; value: string }): JSX.Element {
|
|
return (
|
|
<div style={styles.statRow}>
|
|
<span style={styles.dim}>{name}</span>
|
|
<span style={styles.statValue}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
shell: {
|
|
minHeight: "100vh",
|
|
background: "#070b12",
|
|
color: "#dde4f0",
|
|
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
},
|
|
header: {
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "14px 22px",
|
|
borderBottom: "1px solid #1a2436",
|
|
flexWrap: "wrap",
|
|
gap: 12,
|
|
},
|
|
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
|
|
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
|
|
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
|
|
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
|
|
value: { minWidth: 30, color: "#dde4f0" },
|
|
button: {
|
|
background: "#16233a",
|
|
color: "#8fb4e8",
|
|
border: "1px solid #2a3c5c",
|
|
borderRadius: 6,
|
|
padding: "6px 16px",
|
|
fontFamily: "inherit",
|
|
fontSize: 12,
|
|
cursor: "pointer",
|
|
},
|
|
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
|
|
liveOk: { fontSize: 11, color: "#39d98a" },
|
|
liveError: { fontSize: 11, color: "#c05a6a" },
|
|
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
|
canvas: { flex: 1, minWidth: 0, display: "block" },
|
|
panel: {
|
|
width: 320,
|
|
borderLeft: "1px solid #1a2436",
|
|
padding: 16,
|
|
overflowY: "auto",
|
|
background: "#0a0f18",
|
|
},
|
|
section: { marginBottom: 22 },
|
|
sectionTitle: {
|
|
fontSize: 11,
|
|
letterSpacing: 2,
|
|
color: "#5a6a85",
|
|
textTransform: "uppercase",
|
|
marginBottom: 10,
|
|
borderBottom: "1px solid #1a2436",
|
|
paddingBottom: 5,
|
|
},
|
|
statRow: { display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12 },
|
|
statValue: { color: "#8fb4e8" },
|
|
linkRow: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
padding: "4px 0",
|
|
fontSize: 11,
|
|
borderBottom: "1px dotted #141d2e",
|
|
},
|
|
linkName: { color: "#c8d2e4" },
|
|
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
|
|
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
|
|
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
|
|
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
|
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
|
};
|