Files
swarm-house/simulator/explorer/index.html
T
eSlider 59acffc068 feat(simulator): web data-plane explorer
Single-page view over the Parquet lake: live partition tree with
rolled-up sizes and a SQL console gated by the same read-only rules
as the peer query channel. Ships in the monitoring profile.
2026-07-08 13:45:52 +01:00

161 lines
7.0 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swarm data-plane explorer</title>
<style>
:root {
--bg: #0e1116; --panel: #161b22; --border: #2b3240;
--fg: #d7dde6; --dim: #8b95a5; --accent: #4ec9b0; --warn: #e5c07b;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 "JetBrains Mono", ui-monospace, monospace;
display: grid; grid-template-columns: 380px 1fr; height: 100vh;
}
header {
grid-column: 1 / -1; padding: 10px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: baseline; gap: 14px;
}
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
header .totals { color: var(--dim); font-size: 12px; }
body { grid-template-rows: auto 1fr; }
aside {
border-right: 1px solid var(--border); overflow: auto; padding: 10px 8px;
background: var(--panel);
}
main { display: flex; flex-direction: column; overflow: hidden; }
details { margin-left: 12px; }
details > summary {
cursor: pointer; padding: 1px 4px; border-radius: 4px; white-space: nowrap;
}
details > summary:hover { background: #1f2733; }
summary .meta { color: var(--dim); font-size: 11px; margin-left: 6px; }
.leaf { margin-left: 30px; color: var(--dim); font-size: 12px; white-space: nowrap; }
.leaf.current { color: var(--warn); }
.sql-box { padding: 12px 14px; border-bottom: 1px solid var(--border); }
textarea {
width: 100%; height: 92px; background: var(--panel); color: var(--fg);
border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px;
font: 13px/1.45 inherit; resize: vertical;
}
textarea:focus { outline: 1px solid var(--accent); }
.row { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
button {
background: var(--accent); color: #0b0e12; border: 0; border-radius: 6px;
padding: 5px 14px; font: 600 13px inherit; cursor: pointer;
}
button.sample {
background: transparent; color: var(--dim); border: 1px solid var(--border);
font-weight: 400; font-size: 12px;
}
button.sample:hover { color: var(--fg); border-color: var(--dim); }
.hint { color: var(--dim); font-size: 12px; }
#results { flex: 1; overflow: auto; padding: 12px 14px; }
table { border-collapse: collapse; font-size: 12.5px; }
th, td { border: 1px solid var(--border); padding: 3px 9px; text-align: left; white-space: nowrap; }
th { background: var(--panel); position: sticky; top: 0; }
.error { color: #e06c75; white-space: pre-wrap; }
.note { color: var(--dim); font-size: 12px; margin: 6px 0; }
</style>
</head>
<body>
<header>
<h1>Swarm data-plane explorer</h1>
<span class="totals" id="totals">scanning…</span>
<span class="totals" style="margin-left:auto">read-only SQL · same gate as the peer query channel</span>
</header>
<aside id="tree"></aside>
<main>
<div class="sql-box">
<textarea id="sql" spellcheck="false">SELECT sensor, count(*) AS rows, count(DISTINCT drone) AS drones
FROM telemetry GROUP BY sensor ORDER BY rows DESC</textarea>
<div class="row">
<button id="run">Run</button>
<span class="hint">views: <b>telemetry</b>, <b>detections</b>, <b>state</b> · Ctrl+Enter to run</span>
</div>
<div class="row" id="samples"></div>
</div>
<div id="results"><p class="note">Run a query to see results. The tree on the left refreshes every 10 s while the swarm writes.</p></div>
</main>
<script>
const SAMPLES = [
["fleet overview", "SELECT drone, min(to_timestamp(ts_ns/1e9)) AS first_seen, max(to_timestamp(ts_ns/1e9)) AS last_seen, count(*) AS rows FROM telemetry GROUP BY drone ORDER BY drone"],
["battery timeline", "SELECT drone, time_bucket(INTERVAL 10 seconds, to_timestamp(ts_ns/1e9)) AS t, avg(level_pct) AS battery FROM telemetry WHERE sensor='battery' GROUP BY 1,2 ORDER BY 2,1 LIMIT 100"],
["detections by class", "SELECT cls, count(*) AS n, round(avg(confidence),3) AS avg_conf FROM detections GROUP BY cls ORDER BY n DESC"],
["peer link quality", "SELECT drone, peer_id, round(avg(rssi_dbm),1) AS rssi, round(avg(distance_m),1) AS dist_m FROM telemetry WHERE sensor='rssi' GROUP BY 1,2 ORDER BY 1,2"],
["partitions", "SELECT dataset, drone, sensor, count(*) AS rows FROM telemetry GROUP BY ALL ORDER BY 2,3"],
["schema", "DESCRIBE telemetry"],
];
function fmtBytes(b) {
if (b == null) return "";
const u = ["B","KB","MB","GB"]; let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return b.toFixed(i ? 1 : 0) + " " + u[i];
}
function renderNode(name, node, open) {
const children = node.children || {};
const keys = Object.keys(children);
if (!keys.length) {
const cls = name.includes("current") || name.endsWith(".tmp") ? "leaf current" : "leaf";
return `<div class="${cls}">${name} <span class="meta">${fmtBytes(node.bytes)}</span></div>`;
}
const meta = `<span class="meta">${node.files ?? ""} files · ${fmtBytes(node.bytes)}</span>`;
const inner = keys.map(k => renderNode(k, children[k], false)).join("");
return `<details ${open ? "open" : ""}><summary>${name} ${meta}</summary>${inner}</details>`;
}
async function refreshTree() {
try {
const r = await (await fetch("/api/tree")).json();
document.getElementById("totals").textContent =
`${r.total_files} parquet files · ${fmtBytes(r.total_bytes)}`;
const children = (r.tree.children) || {};
document.getElementById("tree").innerHTML =
Object.keys(children).sort().map(k => renderNode(k, children[k], true)).join("")
|| '<p class="note">no parquet files yet</p>';
} catch { /* transient; try again on next tick */ }
}
async function run() {
const sql = document.getElementById("sql").value;
const out = document.getElementById("results");
out.innerHTML = '<p class="note">running…</p>';
const r = await (await fetch("/api/query", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({sql}),
})).json();
if (r.error) { out.innerHTML = `<p class="error">${r.error}</p>`; return; }
const head = r.columns.map(c => `<th>${c}</th>`).join("");
const body = r.rows.map(row =>
`<tr>${row.map(v => `<td>${v === null ? "<i>null</i>" : String(v)}</td>`).join("")}</tr>`).join("");
out.innerHTML =
`<p class="note">${r.rows.length} rows${r.truncated ? " (truncated)" : ""}</p>` +
`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
document.getElementById("run").onclick = run;
document.getElementById("sql").addEventListener("keydown", e => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") run();
});
document.getElementById("samples").innerHTML =
SAMPLES.map(([label], i) => `<button class="sample" data-i="${i}">${label}</button>`).join("");
document.getElementById("samples").onclick = e => {
const i = e.target.dataset.i;
if (i != null) { document.getElementById("sql").value = SAMPLES[i][1]; run(); }
};
refreshTree();
setInterval(refreshTree, 10000);
</script>
</body>
</html>