feat: add IaC layer with shared var/t1 and var/t3 data paths

Ansible provisions the k3d cluster and Flux controllers; Terraform
modules deploy the simulated fleet and ground warehouse. Compose and
k3d share var/t1 (live lake) and var/t3 (warehouse). The prototype
gains a live mode fed by the explorer read-only SQL API.
This commit is contained in:
2026-07-08 20:17:32 +01:00
parent d58569b25c
commit cb4664f5ee
44 changed files with 8067 additions and 35 deletions
+88 -28
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
import {
AREA,
MAX_ASPECT,
@@ -24,17 +25,22 @@ function displayAspect(): number {
return Math.max(1, Math.min(MAX_ASPECT, q));
}
const LIVE_POLL_MS = 1000;
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);
@@ -55,6 +61,7 @@ export default function App(): JSX.Element {
}, []);
useEffect(() => {
if (mode !== "sim") return;
const loop = (now: number): void => {
const dt = Math.min(0.05, (now - last.current) / 1000);
last.current = now;
@@ -65,7 +72,40 @@ export default function App(): JSX.Element {
};
raf.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf.current);
}, [paused, speedup]);
}, [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),
@@ -83,32 +123,48 @@ export default function App(): JSX.Element {
</div>
</div>
<div style={styles.controls}>
<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
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>
@@ -183,7 +239,7 @@ export default function App(): JSX.Element {
>
<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)})`}>
dr-{String(d.id + 1).padStart(2, "0")} · ch{d.channel} · {d.battery.toFixed(0)}%
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
</text>
</g>
))}
@@ -201,7 +257,8 @@ export default function App(): JSX.Element {
{busiest.map((l) => (
<div key={l.key} style={styles.linkRow}>
<span style={styles.linkName}>
dr-{String(l.a + 1).padStart(2, "0")} dr-{String(l.b + 1).padStart(2, "0")}
{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)}
@@ -297,6 +354,9 @@ const styles: Record<string, React.CSSProperties> = {
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: {