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:
2026-09-12 07:58:38 -05:00
co-authored by Claude Fable 5.1
parent b9f59a5903
commit ebedd1281e
10 changed files with 914 additions and 26 deletions
+24 -8
View File
@@ -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;
}
});
+354
View File
@@ -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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>
+75
View File
@@ -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);
});
});
}