swactor/crates/dashboard/src/demo_control_page.html
Zachery Aaron Shores-Chmielewski c42bf5e983 demo: rename xtask demo command; dashboard-established data-plane edges
Rename `cargo xtask provisioning-reconciler-demo` to `cargo xtask demo`
(CLI dispatch, help, child re-exec argv, launch spec strings, module dir
xtask/src/provisioning_demo -> xtask/src/demo).

Add iteration-1 data-plane edges, established from Fleet Control:

- Fleet Control "edge" button -> POST /control/edge (new
  ControlCommand::EstablishEdge) -> supervisor actor resolves the node's
  advertised EndpointAddr (stashed in NodeRuntime by AnnounceActor) and
  provisions a real outbound EdgeRuntime (arena ring lease, recorder
  WorkerPort, EDGE_ALPN send pump) in a new edge pump thread.
- Node gains EDGE_ALPN, an actor bridge decoding EdgeProvision gossip,
  and a NodeEdgeAgent that provisions its (single) inbound edge, polls
  it, mirrors observations onto the node.edge telemetry channel
  (render-only), and answers EdgeAck gossip which terminates the
  supervisor's provision retries. Node teardown replaces its inbound on
  re-provision; supervisor replaces sessions per node and tears them
  down on node exit/replacement/shutdown.
- The edge pump runs on the engine's blocking pool with sole session
  ownership (commands in, state mirror + feed lines out): the connect
  handshake blocks its thread and must not run on a Tokio worker or
  share a lock with the actor. Connects are bounded (10s) so a dead
  node faults its session instead of wedging edge polling.
- iroh-driver: retain_telemetry_connections() opts an application out
  of the driver-owned TELEMETRY_ALPN ingress so the node's pull server
  can drain those connections itself (the actor-bridge pump would
  otherwise claim them).
- Dashboard: edges array in the reconciler snapshot, per-node edge
  badges and edge button in Fleet Control, node_edges render mirror.
2026-08-17 00:11:20 +04:00

418 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet Control</title>
<style>
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--bg: #000000; --panel: #001220; --panel-hover: #001c38; --inset: #00060c;
--border: #14406a; --divider: #0d2c4a;
--text: #ffffff; --muted: #9db2c4;
--amber: #ff9900; --ghost: rgba(255, 153, 0, .16);
--ok: #00d400; --bad: #ff4141; --cyan: #5cd5ff; --magenta: #cc78bc;
--primary-fill: #ff9900; --primary-ink: #000000;
--danger-fill: #7a1010; --danger-border: #ff4141; --danger-ink: #ffd7d7;
--selected: rgba(255, 153, 0, .10); --selected-edge: #ff9900;
--card-hover: #00263f; --row-hover: #002a47;
--r: 2px; --t: 120ms;
background: var(--bg); color: var(--text);
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #fafafa; --panel: #ffffff; --panel-hover: #f0f0f0; --inset: #f5f5f5;
--border: #dcdcE0; --divider: #eaeaea;
--text: #383a42; --muted: #696c77;
--amber: #986801; --ghost: rgba(152, 104, 1, .18);
--ok: #50a14f; --bad: #e45649; --cyan: #0184bc; --magenta: #a626a4;
--primary-fill: #0184bc; --primary-ink: #ffffff;
--danger-fill: #cc3a2f; --danger-border: #cc3a2f; --danger-ink: #ffffff;
--selected: rgba(64, 120, 242, .08); --selected-edge: #4078f2;
--card-hover: #f0f4fb; --row-hover: #eef1f8;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
color-scheme: light;
--bg: #fafafa; --panel: #ffffff; --panel-hover: #f0f0f0; --inset: #f5f5f5;
--border: #dcdcE0; --divider: #eaeaea;
--text: #383a42; --muted: #696c77;
--amber: #986801; --ghost: rgba(152, 104, 1, .18);
--ok: #50a14f; --bad: #e45649; --cyan: #0184bc; --magenta: #a626a4;
--primary-fill: #0184bc; --primary-ink: #ffffff;
--danger-fill: #cc3a2f; --danger-border: #cc3a2f; --danger-ink: #ffffff;
--selected: rgba(64, 120, 242, .08); --selected-edge: #4078f2;
--card-hover: #f0f4fb; --row-hover: #eef1f8;
}
}
* { box-sizing: border-box; }
body { margin: 0; padding: 20px; }
.muted { color: var(--muted); }
header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px 24px; margin-bottom: 16px; }
h1 { font-size: 20px; margin: 0; }
.counter { font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 6px; }
.counter .muted { font-size: 13px; }
.counter .meta { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.counter #converged.ok { color: var(--ok); }
.counter #converged.warn { color: var(--amber); }
.seg { position: relative; display: inline-flex; justify-content: flex-end; font: 700 26px/1 var(--mono); color: var(--amber); font-variant-numeric: tabular-nums; letter-spacing: .05em; }
.seg::before { content: attr(data-ghost); position: absolute; right: 0; top: 0; color: var(--ghost); }
.seg .seg-v { position: relative; }
.seg-sm { font-size: 15px; }
.blink { animation: seg-blink 1.1s steps(2, start) infinite; }
@keyframes seg-blink { to { visibility: hidden; } }
.controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.controls input {
width: 3.5em; background: var(--inset); color: var(--text);
border: 1px solid var(--border); border-radius: var(--r); padding: 6px 8px; font: 13px var(--mono);
}
.controls input:hover { border-color: var(--cyan); }
button { font: 600 13px var(--mono); border-radius: var(--r); border: 1px solid var(--border); background: transparent; color: var(--text); padding: 6px 12px; cursor: pointer; transition: background var(--t), border-color var(--t); }
button:hover:not(:disabled) { background: var(--panel-hover); border-color: var(--cyan); }
button:active:not(:disabled) { transform: translateY(1px); }
button.primary { background: var(--primary-fill); border-color: var(--primary-fill); color: var(--primary-ink); }
button.primary:hover:not(:disabled) { background: var(--primary-fill); border-color: var(--cyan); filter: brightness(1.1); }
button.danger { background: var(--danger-fill); border-color: var(--danger-border); color: var(--danger-ink); }
button.danger:hover:not(:disabled) { background: var(--danger-fill); border-color: var(--bad); filter: brightness(1.15); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
:is(a, button, input, summary):focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
#status { font-size: 12px; font-family: var(--mono); }
#status.failed { color: var(--bad); }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 16px; margin-bottom: 16px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 620px; font-family: var(--mono); font-size: 12px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: none; }
th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.node-name { font-weight: 700; }
.node-sub { font-size: 11px; color: var(--muted); }
.pill { display: inline-flex; padding: 3px 6px; border: 1px solid var(--muted); border-radius: var(--r); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text); }
.pill.running { color: var(--ok); border-color: var(--ok); }
.pill.exited, .pill.failed { color: var(--bad); border-color: var(--bad); }
.badge { display: inline-block; padding: 3px 6px; border-radius: var(--r); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; border: 1px solid; font-family: var(--mono); }
.badge .sub { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.85; }
.b-ready { color: var(--ok); border-color: var(--ok); }
.b-progress { color: var(--cyan); border-color: var(--cyan); }
.b-failed { color: var(--bad); border-color: var(--bad); }
.b-deleting { color: var(--amber); border-color: var(--amber); }
.b-destroyed { color: var(--muted); border-color: var(--muted); }
.row-error { color: var(--bad); font-size: 11px; font-family: var(--mono); }
details.activity summary { cursor: pointer; color: var(--muted); padding: 6px 0; font-family: var(--mono); font-size: 12px; }
details.activity[open] summary { margin-bottom: 10px; color: var(--amber); }
.feeds { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.feed { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 10px 12px; }
.feed h2 { margin: 0 0 8px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); }
.feed ul { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; max-height: 360px; overflow-y: auto; }
.feed li { padding: 3px 0; border-bottom: 1px solid var(--divider); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.feed li time { color: var(--muted); margin-right: 8px; }
.k-command { color: var(--cyan); }
.k-result { color: var(--ok); }
.k-transition { color: var(--amber); }
.k-control { color: var(--magenta); }
.k-observation { color: var(--muted); }
.k-error { color: var(--bad); font-weight: 700; }
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
</style>
</head>
<body>
<!--
swactor dashboard — direction contract (impeccable seed 8b5f2278)
THESIS: a numeric instrument bank for a live actor fleet; Bloomberg night housing (dark) / Atom One Light bench card (light); absence drawn as ghost segments, never blank.
OWN-WORLD: black-on-navy or paper-on-white; amber owns the data register, green/red are law, cyan is the only interactive voice; 2px corners, monospace data, machined caps labels, controls that depress.
FIRST VIEWPORT: the control bench — ghost-segment ready/desired counter, generation digit, node rows as channel strips with stage tags and kill switches.
SIGNATURE: counters tick by instant segment swap; converging blinks like an unset clock; ghost eights hold every unlit place.
RISK: terminal cosplay if decoration creeps past data; held by the palette law.
-->
<!--swactor:nav-->
<header>
<div>
<h1>Fleet Control</h1>
<div class="counter" id="counter" hidden>
<span class="seg" id="ready-seg" data-ghost="8"><span class="seg-v" id="ready">0</span></span>
<span class="meta">/</span>
<span class="seg" id="desired-seg" data-ghost="8"><span class="seg-v" id="desired">0</span></span>
<span class="muted">ready</span>
<span class="meta">· <span id="converged">—</span> · gen <span class="seg seg-sm" id="gen-seg" data-ghost="8"><span class="seg-v" id="gen">0</span></span></span>
</div>
</div>
<div class="controls">
<label class="muted" for="provision-count">nodes</label>
<input id="provision-count" type="number" min="1" max="8" value="1">
<button id="provision" class="primary">+ provision</button>
<button id="remove">− remove</button>
<span id="status" class="muted" role="status" aria-live="polite"></span>
</div>
<section class="panel">
<table>
<thead>
<tr><th>node</th><th>stage</th><th>state</th><th>pid</th><th>seen</th><th>edge</th><th></th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<p class="muted" id="empty" hidden>No provisioned nodes observed yet.</p>
</section>
<details class="activity">
<summary>Activity — commands out · events in · transitions</summary>
<div class="feeds">
<div class="feed">
<h2>Commands out</h2>
<ul id="commands"></ul>
</div>
<div class="feed">
<h2>Events in · transitions · control</h2>
<ul id="events"></ul>
</div>
</div>
</details>
<script>
'use strict';
const CONTROL_URL = '/api/view/demo-control';
const RECONCILER_URL = '/api/view/reconciler';
const STAGE_CLASS = {
New: 'b-destroyed', LeaseRequested: 'b-progress', LeaseCreated: 'b-progress',
EndpointKnown: 'b-progress', BootstrapRunning: 'b-progress',
SwactorJoined: 'b-progress', HandedOff: 'b-ready', Dormant: 'b-ready',
Failed: 'b-failed', Destroyed: 'b-destroyed'
};
const INTENT_DELETING = new Set(['Deleting']);
const EDGE_CLASS = {
provisioning: 'b-progress',
ready: 'b-ready',
faulted: 'b-failed'
};
const empty = document.getElementById('empty');
const status = document.getElementById('status');
const counter = document.getElementById('counter');
// A1: last-rendered HTML per region — a poll that produces identical
// markup must not swap innerHTML (that destroyed hover/selection and any
// button press straddling the rebuild).
let lastRowsHtml = null;
let lastCommandsHtml = null;
let lastEventsHtml = null;
function setStatus(text, isError) {
status.textContent = text;
status.className = isError ? 'failed' : 'muted';
if (text) setTimeout(() => { if (status.textContent === text) status.textContent = ''; }, 4000);
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
function fmtSeen(msAgo) {
if (msAgo == null || msAgo === Number.MAX_SAFE_INTEGER) return '—';
if (msAgo < 1000) return 'just now';
if (msAgo < 60_000) return Math.round(msAgo / 1000) + 's ago';
return Math.round(msAgo / 60_000) + 'm ago';
}
function fmtTime(ms) {
if (!ms) return '--:--:--';
const date = new Date(ms);
return [date.getHours(), date.getMinutes(), date.getSeconds()]
.map((part) => String(part).padStart(2, '0')).join(':');
}
function stageBadge(node) {
const stage = node.stage || 'New';
const deleting = INTENT_DELETING.has(node.intent);
const cls = deleting ? 'b-deleting' : (STAGE_CLASS[stage] || 'b-progress');
const sub = node.bootstrap ? `<span class="sub"> · ${escapeHtml(node.bootstrap)}</span>` : '';
return `<span class="badge ${cls}">${escapeHtml(stage)}${sub}</span>`;
}
// One row per logical node: reconciler state (stage, attempt, ready) fused
// with the observed process (state, pid, seen). Reconciler order leads;
// processes the reconciler no longer tracks still render.
function renderRow(entry) {
const reconciler = entry.reconciler;
const process = entry.process;
const node = reconciler ? reconciler.id : process.node;
const stage = reconciler
? stageBadge(reconciler)
: '<span class="muted">—</span>';
const state = process
? `<span class="pill ${escapeHtml(process.state)}">${escapeHtml(process.state)}</span>`
: '<span class="muted">—</span>';
const pid = process && process.pid != null ? process.pid : (reconciler && reconciler.pid != null ? reconciler.pid : null);
const attempt = reconciler ? `<div class="node-sub">attempt ${escapeHtml(reconciler.attempt)}${reconciler.ready ? ' · ready' : ''}</div>` : '';
const failure = reconciler && reconciler.failure
? `<div class="row-error">${escapeHtml(reconciler.failure)}</div>`
: '';
const killable = process && process.state === 'running';
const edge = entry.edge
? `<span class="badge ${EDGE_CLASS[entry.edge.state] || 'b-progress'}">e${escapeHtml(entry.edge.edge)} ${escapeHtml(entry.edge.state)}</span>`
: '<span class="muted">—</span>';
// "seen" is deliberately absent from this HTML: it is written per-poll
// as textContent by updateSeen() so a ticking relative age never
// invalidates the cached row markup.
return `<tr data-node="${escapeHtml(node)}">
<td><span class="node-name">${escapeHtml(node)}</span>${attempt}</td>
<td>${stage}</td>
<td>${state}</td>
<td>${pid == null ? '—' : escapeHtml(pid)}</td>
<td class="muted" data-seen>—</td>
<td>${edge}</td>
<td>
<button class="edge" data-action="edge" data-node="${escapeHtml(node)}" ${killable ? '' : 'disabled'}>edge</button>
<button class="danger" data-action="kill" data-node="${escapeHtml(node)}" ${killable ? '' : 'disabled'}>kill</button>
</td>
</tr>${failure ? `<tr><td colspan="7" style="padding-top:0">${failure}</td></tr>` : ''}`;
}
function fuse(processes, reconcilerNodes, edges) {
const byNode = new Map();
const order = [];
const edgeByNode = new Map();
for (const edge of edges || []) {
if (edge && edge.node != null) edgeByNode.set(edge.node, edge);
}
for (const node of reconcilerNodes || []) {
byNode.set(node.id, { reconciler: node, process: null, edge: edgeByNode.get(node.id) || null });
order.push(node.id);
}
for (const process of processes || []) {
const entry = byNode.get(process.node);
if (entry) {
entry.process = process;
} else {
byNode.set(process.node, { reconciler: null, process, edge: edgeByNode.get(process.node) || null });
order.push(process.node);
}
}
return order.map((id) => byNode.get(id));
}
function render(processSnapshot, reconcilerSnapshot) {
const processes = (processSnapshot && processSnapshot.nodes) || [];
const reconcilerNodes = (reconcilerSnapshot && reconcilerSnapshot.nodes) || [];
const edges = (reconcilerSnapshot && reconcilerSnapshot.edges) || [];
const fused = fuse(processes, reconcilerNodes, edges);
empty.hidden = fused.length > 0;
const rowsHtml = fused.map(renderRow).join('');
if (rowsHtml !== lastRowsHtml) {
rows.innerHTML = rowsHtml;
lastRowsHtml = rowsHtml;
}
updateSeen(fused);
if (reconcilerSnapshot) {
counter.hidden = false;
setSeg('ready-seg', reconcilerSnapshot.ready);
setSeg('desired-seg', reconcilerSnapshot.desired);
const converged = document.getElementById('converged');
converged.textContent = reconcilerSnapshot.converged ? 'converged' : 'converging…';
converged.className = reconcilerSnapshot.converged ? 'ok' : 'warn blink';
setSeg('gen-seg', reconcilerSnapshot.generation);
renderFeeds(reconcilerSnapshot.feed || []);
}
}
// Relative "seen" ages tick every poll but must not churn row markup:
// write them as textContent against the row keyed by data-node.
function updateSeen(fused) {
const byNode = new Map(fused.map((entry) => [entry.reconciler ? entry.reconciler.id : entry.process.node, entry]));
for (const row of rows.querySelectorAll('tr[data-node]')) {
const entry = byNode.get(row.dataset.node);
const cell = row.querySelector('td[data-seen]');
if (cell) cell.textContent = entry && entry.process ? fmtSeen(entry.process.seen_ms_ago) : '—';
}
}
// Segment display: value over ghost eights; every unlit place stays drawn.
function setSeg(id, value) {
const text = String(value == null ? '—' : value);
const seg = document.getElementById(id);
seg.dataset.ghost = text === '—' ? '—' : '8'.repeat(text.length);
seg.querySelector('.seg-v').textContent = text;
}
function feedLine(line, filter) {
if (filter && line.kind !== filter) return '';
return `<li class="k-${escapeHtml(line.kind)}"><time>${fmtTime(line.at_ms)}</time>` +
`${line.node ? '[' + escapeHtml(line.node) + '] ' : ''}${escapeHtml(line.detail)}</li>`;
}
function renderFeeds(feed) {
const recent = feed.slice().reverse();
const commandsHtml = recent.map((line) => feedLine(line, 'command')).join('') || '<li class="muted" >none</li>';
const eventKinds = new Set(['command']);
const eventsHtml = recent.filter((line) => !eventKinds.has(line.kind)).map((line) => feedLine(line)).join('')
|| '<li >none</li>';
if (commandsHtml !== lastCommandsHtml) {
document.getElementById('commands').innerHTML = commandsHtml;
lastCommandsHtml = commandsHtml;
}
if (eventsHtml !== lastEventsHtml) {
document.getElementById('events').innerHTML = eventsHtml;
lastEventsHtml = eventsHtml;
}
}
function nodeCount() {
return Math.max(1, Math.min(8, Number(document.getElementById('provision-count').value) || 1));
}
function post(path, body, action) {
fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
}).then((response) => {
setStatus(response.ok ? action + ' issued' : action + ' failed: HTTP ' + response.status, !response.ok);
}).catch((error) => setStatus(action + ' failed: ' + error, true));
}
document.getElementById('provision').addEventListener('click', () => {
post('/control/provision', { Provision: { count: nodeCount() } }, 'provision');
});
document.getElementById('remove').addEventListener('click', () => {
post('/control/remove', { Remove: { count: nodeCount() } }, 'remove');
});
// A2: one delegated listener on the persistent tbody. Per-render binding
// dropped any press that straddled a row rebuild; delegation survives them.
rows.addEventListener('click', (event) => {
const button = event.target.closest('button[data-node]');
if (!button || !rows.contains(button)) return;
const action = button.dataset.action || 'kill';
const route = action === 'edge' ? '/control/edge' : '/control/kill';
const body = action === 'edge'
? { EstablishEdge: { node: button.dataset.node } }
: { Kill: { node: button.dataset.node } };
const label = action === 'edge' ? 'edge' : 'kill';
if (action === 'edge') button.disabled = false; // edges can be re-issued
else button.disabled = true;
fetch(route, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
}).then((response) => {
if (!response.ok && action !== 'edge') button.disabled = false;
setStatus(response.ok ? label + ' issued' : label + ' failed: HTTP ' + response.status, !response.ok);
}).catch((error) => {
if (action !== 'edge') button.disabled = false;
setStatus(label + ' failed: ' + error, true);
});
});
async function poll() {
try {
const [control, reconciler] = await Promise.allSettled([
fetch(CONTROL_URL).then((response) => response.ok ? response.json() : null),
fetch(RECONCILER_URL).then((response) => response.ok ? response.json() : null)
]);
if (control.status === 'fulfilled' && control.value) {
render(control.value, reconciler.status === 'fulfilled' ? reconciler.value : null);
}
} catch { /* transient */ }
}
poll();
setInterval(poll, 1000);
</script>
</body>
</html>