Add control board web page and local server (#1503)
Step 2 of the control board MVP (MOSAIC-STACK-D-001): `serve` command starts a loopback-only local server that serves one self-contained page and re-runs the status scanner on each /api/board request. The page lists sessions waiting on Jason first (errors on top), then one table per project with plain-word states, ages, last messages, expandable detail rows, per-project hide-offline, and a 10-second auto-refresh with pause. Tests: control-board 33/33 (10 new: loopback rules, host refusal, all routes, per-request rescan, 500 path, CLI refusals, live serve, page escaping guard); registry 69/69 unchanged. Receipt: docs/plans/reviews/2026-09-12_control-board-step2-review.md. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
// Usage: node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
// Usage:
|
||||
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||
// Exit 0 on success, 2 on a config refusal or bad usage.
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
|
||||
import { startServer } from "./serve.mjs";
|
||||
|
||||
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
@@ -18,19 +23,32 @@ function parseArgs(argv) {
|
||||
else if (a === "--fleet") opts.fleet = next();
|
||||
else if (a === "--liveness") opts.liveness = next();
|
||||
else if (a === "--print") opts.print = true;
|
||||
else if (a === "--port") {
|
||||
opts.port = Number(next());
|
||||
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
|
||||
} else if (a === "--host") opts.host = next();
|
||||
else throw new ConfigError(`unknown argument: ${a}`);
|
||||
}
|
||||
if (opts.command !== "scan") throw new ConfigError("usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]");
|
||||
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
|
||||
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const { dataRoot } = loadConfig(opts.config);
|
||||
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
|
||||
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
|
||||
const boardDir = join(dataRoot, "board");
|
||||
if (opts.command === "serve") {
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
|
||||
const addr = server.address();
|
||||
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
return;
|
||||
}
|
||||
const index = scan(specs, { boardDir, isAlive });
|
||||
if (opts.print) {
|
||||
for (const s of index.sessions) {
|
||||
@@ -42,12 +60,10 @@ function main() {
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
main().catch((err) => {
|
||||
if (err instanceof ConfigError) {
|
||||
process.stderr.write(`refused: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Control board</title>
|
||||
<style>
|
||||
:root{
|
||||
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
|
||||
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
|
||||
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--radius:8px;--radius-lg:14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
|
||||
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
|
||||
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
|
||||
}
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
|
||||
body{margin:0 auto;max-width:1100px;padding:16px}
|
||||
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
|
||||
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
|
||||
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
|
||||
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
|
||||
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.status-line{margin:0;color:var(--muted);font-size:.9rem}
|
||||
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
|
||||
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
|
||||
.btn:hover{background:var(--raised)}
|
||||
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
|
||||
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
|
||||
.empty{color:var(--muted);font-style:italic}
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tr.is-offline{opacity:.55}
|
||||
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
|
||||
border-radius:99px;border:1px solid transparent;white-space:nowrap}
|
||||
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
|
||||
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
|
||||
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
|
||||
.badge-muted{color:var(--muted);background:var(--raised)}
|
||||
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
||||
cursor:pointer;text-align:left}
|
||||
.row-toggle:hover{text-decoration:underline}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||
.detail-list dd{margin:0;overflow-wrap:anywhere}
|
||||
.project-group{margin-bottom:20px}
|
||||
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
||||
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
|
||||
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-head">
|
||||
<h1>Control board</h1>
|
||||
<div class="head-controls">
|
||||
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
|
||||
<button id="refreshBtn" class="btn" type="button">Refresh</button>
|
||||
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
|
||||
<main id="main">
|
||||
<section aria-labelledby="waiting-h">
|
||||
<h2 id="waiting-h">Waiting on you</h2>
|
||||
<div id="waitingBody"></div>
|
||||
</section>
|
||||
<section aria-labelledby="projects-h">
|
||||
<h2 id="projects-h">By project</h2>
|
||||
<div id="projectsBody"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="footer" class="page-footer"></footer>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
|
||||
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
|
||||
var REFRESH_MS = 10000;
|
||||
|
||||
var headerStatus = document.getElementById("headerStatus");
|
||||
var refreshBtn = document.getElementById("refreshBtn");
|
||||
var pauseBtn = document.getElementById("pauseBtn");
|
||||
var errorBanner = document.getElementById("errorBanner");
|
||||
var waitingBody = document.getElementById("waitingBody");
|
||||
var projectsBody = document.getElementById("projectsBody");
|
||||
var footer = document.getElementById("footer");
|
||||
var main = document.getElementById("main");
|
||||
|
||||
var lastData = null;
|
||||
var paused = false;
|
||||
var fetching = false;
|
||||
var timerId = null;
|
||||
var secondsLeft = REFRESH_MS / 1000;
|
||||
var hideOfflineState = {};
|
||||
// Open detail panels survive a refresh. Keyed by section, project and agent
|
||||
// because the same agent can appear in both the waiting list and its group.
|
||||
var openDetails = {};
|
||||
var rowIdx = 0;
|
||||
|
||||
function esc(v) {
|
||||
if (v === null || v === undefined) return "";
|
||||
return String(v).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function humanAge(s) {
|
||||
if (s === null || s === undefined) return "—";
|
||||
if (s < 60) return s + "s";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h";
|
||||
return Math.floor(h / 24) + "d";
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return "never";
|
||||
var ms = Date.now() - Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return "never";
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 5) return "just now";
|
||||
if (s < 60) return s + "s ago";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m ago";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h ago";
|
||||
return Math.floor(h / 24) + "d ago";
|
||||
}
|
||||
|
||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
function badge(state) {
|
||||
var cls = BADGE_CLASS[state] || "badge-muted";
|
||||
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
||||
}
|
||||
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
var open = !!openDetails[key];
|
||||
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||
var msg = rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
var main =
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button></td>" +
|
||||
"<td>" + badge(rec.state) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
"</tr>";
|
||||
var span = showProject ? 5 : 4;
|
||||
var tmux = rec.tmux && rec.tmux.session
|
||||
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
|
||||
: "—";
|
||||
var detail =
|
||||
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
|
||||
'<dl class="detail-list">' +
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Working directory</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
function renderWaiting(data) {
|
||||
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
|
||||
list.sort(function (a, b) {
|
||||
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
|
||||
if (ae !== be) return ae - be;
|
||||
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||
return aa - bb;
|
||||
});
|
||||
if (list.length === 0) {
|
||||
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||
waitingBody.innerHTML =
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||
}
|
||||
|
||||
function projectOrder(a, b) {
|
||||
if (a === "fleet" && b === "fleet") return 0;
|
||||
if (a === "fleet") return 1;
|
||||
if (b === "fleet") return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function renderProjects(data) {
|
||||
var sessions = data.sessions || [];
|
||||
var byProject = {};
|
||||
sessions.forEach(function (r) {
|
||||
(byProject[r.project] = byProject[r.project] || []).push(r);
|
||||
});
|
||||
var projects = Object.keys(byProject).sort(projectOrder);
|
||||
if (projects.length === 0) {
|
||||
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
|
||||
return;
|
||||
}
|
||||
projectsBody.innerHTML = projects.map(function (project) {
|
||||
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
|
||||
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
|
||||
var hideOffline = hideOfflineState[project];
|
||||
var visible = group.filter(function (r) { return !(hideOffline && r.state === "offline"); });
|
||||
var hiddenCount = group.length - visible.length;
|
||||
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
|
||||
var note = hiddenCount > 0 ? '<p class="offline-note">' + hiddenCount + " offline hidden</p>" : "";
|
||||
return (
|
||||
'<div class="project-group">' +
|
||||
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + group.length + ")</span></h3>" +
|
||||
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
|
||||
"</div>" +
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + (rows || '<tr><td colspan="4" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
|
||||
note + "</div>"
|
||||
);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderFooter(data) {
|
||||
var counts = data.counts || {};
|
||||
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
||||
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
rowIdx = 0;
|
||||
renderWaiting(lastData);
|
||||
renderProjects(lastData);
|
||||
renderFooter(lastData);
|
||||
}
|
||||
|
||||
function renderHeaderStatus() {
|
||||
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
|
||||
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
|
||||
headerStatus.textContent = "Updated " + updated + " · " + refresh;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBanner.hidden = false;
|
||||
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorBanner.hidden = true;
|
||||
errorBanner.textContent = "";
|
||||
}
|
||||
|
||||
function scheduleNextFetch() {
|
||||
clearTimeout(timerId);
|
||||
if (paused) return;
|
||||
secondsLeft = REFRESH_MS / 1000;
|
||||
timerId = setTimeout(runFetch, REFRESH_MS);
|
||||
}
|
||||
|
||||
function runFetch() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
fetch("/api/board", { cache: "no-store" })
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("control board refresh failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
function setPaused(p) {
|
||||
paused = p;
|
||||
pauseBtn.textContent = paused ? "Resume" : "Pause";
|
||||
pauseBtn.setAttribute("aria-pressed", String(paused));
|
||||
if (paused) clearTimeout(timerId);
|
||||
else scheduleNextFetch();
|
||||
renderHeaderStatus();
|
||||
}
|
||||
|
||||
main.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest(".row-toggle");
|
||||
if (!btn) return;
|
||||
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
||||
var expanded = btn.getAttribute("aria-expanded") === "true";
|
||||
btn.setAttribute("aria-expanded", String(!expanded));
|
||||
detail.hidden = expanded;
|
||||
if (expanded) delete openDetails[btn.dataset.key];
|
||||
else openDetails[btn.dataset.key] = true;
|
||||
});
|
||||
|
||||
main.addEventListener("change", function (e) {
|
||||
var cb = e.target.closest(".hide-offline-toggle");
|
||||
if (!cb) return;
|
||||
hideOfflineState[cb.dataset.project] = cb.checked;
|
||||
renderProjects(lastData);
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", runFetch);
|
||||
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
|
||||
|
||||
setInterval(function () {
|
||||
if (!paused && secondsLeft > 0) secondsLeft -= 1;
|
||||
renderHeaderStatus();
|
||||
}, 1000);
|
||||
|
||||
renderHeaderStatus();
|
||||
runFetch();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
// Control board step 2: a tiny local web server. No dependencies, no auth.
|
||||
// It only ever binds to a loopback address (fail closed otherwise).
|
||||
//
|
||||
// GET / the page (src/page.html)
|
||||
// GET /api/board re-runs the scanner and returns index.json as JSON
|
||||
// GET /healthz {"ok":true}
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { scan, ConfigError } from "./scan.mjs";
|
||||
|
||||
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
export function isLoopbackHost(host) {
|
||||
if (LOOPBACK.has(host)) return true;
|
||||
return isIP(host) === 4 && host.startsWith("127.");
|
||||
}
|
||||
|
||||
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
|
||||
return createHttpServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { "content-type": "text/plain" });
|
||||
return res.end("method not allowed\n");
|
||||
}
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
return res.end(page);
|
||||
}
|
||||
if (url.pathname === "/api/board") {
|
||||
let index;
|
||||
try {
|
||||
index = scan(specs, { boardDir, isAlive, now });
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify(index) + "\n");
|
||||
}
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end('{"ok":true}\n');
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found\n");
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves to the listening server. Refuses any non-loopback host.
|
||||
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
|
||||
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
|
||||
const server = createServer(rest);
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolvePromise(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user