swactor/crates/dashboard/src/control_plane_page.html
Zachery Aaron Shores-Chmielewski 3389abfe33 Rework Myelin control and runtime integration
Add actor-backed manual node provisioning, control-plane endpoints, and fleet UI assets with durable provider lifecycle handling.

Simplify Myelin orchestration, node runtime, staging, and telemetry paths while removing obsolete engine-builder, dashboard-view, and local-mock implementations.

Align runtime delivery, data-plane, distribution, job-runner, process, telemetry, dashboard, Vast.ai integrations, and their tests with the revised actor and transport contracts.
2026-08-19 15:53:35 +04:00

584 lines
28 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>swactor fleet</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;
/* dark: Bloomberg night housing */
--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;
--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;
/* light: Atom One Light bench card */
--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;
--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;
--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; }
header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 16px; }
h1 { margin: 0; font-size: 20px; }
h2 { margin: 20px 0 10px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); }
.muted { color: var(--muted); }
.mono { font-family: var(--mono); font-size: 12px; }
#status { font-family: var(--mono); font-size: 13px; color: var(--amber); }
a.node-card { color: inherit; text-decoration: none; display: block; }
.node-card.orchestrator { grid-column: 1 / -1; border-color: var(--amber); background: var(--ghost); box-shadow: inset 3px 0 0 var(--amber); padding-left: 17px; }
.node-card.orchestrator .role { margin-bottom: 6px; color: var(--muted); font: 600 9px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .12em; }
:is(a, button, input, summary):focus-visible, tr[data-addr]:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.node-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; cursor: pointer; transition: border-color var(--t), background var(--t); }
.node-card:hover { border-color: var(--amber); background: var(--card-hover); }
.node-card[data-live="false"] { opacity: .55; }
.node-card h3 { margin: 0 0 2px; font: 700 13px/1.4 var(--mono); word-break: break-all; }
.node-card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.bars { display: grid; gap: 5px; }
.bar-row { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
.bar-row .name { width: 92px; color: var(--muted); }
.bar-track { flex: 1; height: 8px; background: var(--inset); border-radius: var(--r); overflow: hidden; }
.bar-fill { height: 100%; }
.bar-fill.ok { background: var(--ok); } .bar-fill.warn { background: var(--amber); } .bar-fill.bad { background: var(--bad); }
.bar-row .num { width: 64px; text-align: right; font-variant-numeric: tabular-nums; }
.pill { display: inline-flex; padding: 3px 6px; border: 1px solid var(--muted); border-radius: var(--r); font: 600 10px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text); }
.pill.live { color: var(--ok); border-color: var(--ok); }
.pill.stale { color: var(--bad); border-color: var(--bad); }
.pill.poisoned { color: var(--bad); border-color: var(--bad); font-weight: 700; }
details.stale { margin-top: 18px; }
details.stale summary { cursor: pointer; color: var(--muted); padding: 8px 0; font-family: var(--mono); font-size: 12px; }
.focus-back { display: inline-block; margin-bottom: 12px; color: var(--cyan); text-decoration: none; font-family: var(--mono); font-size: 12px; }
.focus-back:hover { color: var(--amber); }
.node-view { display: grid; gap: 14px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; }
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; }
th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
tbody tr[data-addr] { cursor: pointer; transition: background var(--t); }
tbody tr[data-addr]:hover { background: var(--row-hover); }
tbody tr[data-selected="true"] { background: var(--selected); box-shadow: inset 2px 0 0 var(--selected-edge); }
input[type=search] { background: var(--inset); color: var(--text); border: 1px solid var(--border); border-radius: var(--r); padding: 7px 10px; width: 240px; max-width: 100%; font: 12px var(--mono); }
input[type=search]:hover { border-color: var(--cyan); }
.layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
@media (min-width: 1100px) { .layout.with-dossier { grid-template-columns: minmax(0, 1fr) 420px; } }
.dossier { position: sticky; top: 12px; align-self: start; max-height: calc(100vh - 24px); overflow: auto; }
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 10px; font-size: 13px; }
.kv .k { color: var(--muted); font-family: var(--mono); font-size: 12px; }
.diet .bar-row .name { width: 48%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.receipts td { font-size: 11px; }
.empty { padding: 24px; border: 1px dashed var(--border); border-radius: var(--r); color: var(--muted); font-family: var(--mono); font-size: 12px; }
.notice { font-size: 12px; color: var(--muted); margin: 6px 0; }
canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
.err { color: var(--bad); }
@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 designed negative space, 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 fleet as an instrument rack — orchestrator module pinned with an amber edge, node cards as module slots with state tags.
SIGNATURE: state as instrument grammar — lit tag for live, struck dim for dead, no skeleton bars for silent channels.
RISK: terminal cosplay if decoration creeps past data; held by the palette law.
-->
<!--swactor:nav-->
<header>
<h1>Fleet</h1>
<span class="muted" id="status" role="status" aria-live="polite">loading…</span>
</header>
<div id="page">
<div class="empty">Waiting for telemetry frames…</div>
</div>
<script>
'use strict';
const POLL_MS = 1000;
const ROSTER_RENDER_CAP = 200;
const params = new URLSearchParams(window.location.search);
let selectedStream = params.get('stream') || null;
let selectedActor = params.get('actor') || null;
let rosterFilter = '';
let lastSnapshot = null;
let detailTimer = null;
// A1: last-rendered HTML per region. A poll that yields identical markup
// must not swap innerHTML — the swap destroyed hover/selection/presses and
// restarted animations every second even in a fully converged steady state.
let lastPageHtml = null;
let lastRosterHtml = null;
let lastMachineHtml = null;
let lastDossierHtml = null;
// Receipt ages (dossier) render as empty spans carrying an absolute epoch —
// stable across polls — and a 1 Hz pass rewrites their textContent. All other
// ticking text (card seen/stats) is written per-poll by updateCardTexts.
function agoSpan(epochMs) {
return `<span data-ago-at="${Math.round(epochMs)}"></span>`;
}
function updateAgo() {
const now = Date.now();
for (const span of document.querySelectorAll('[data-ago-at]')) {
span.textContent = ago(now - Number(span.dataset.agoAt));
}
}
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c]));
}
function fmt(value, digits = 0) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return n.toLocaleString(undefined, { maximumFractionDigits: digits });
}
function fmtRate(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' G';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' k';
return n.toFixed(n < 10 ? 1 : 0);
}
function typeShort(name) {
if (!name) return '—';
const parts = String(name).split('::');
return esc(parts[parts.length - 1] || name);
}
function ago(ms) {
if (ms == null) return '—';
if (ms < 1000) return ms + 'ms';
if (ms < 60000) return Math.round(ms / 1000) + 's';
if (ms < 3600000) return Math.round(ms / 60000) + 'm';
return Math.round(ms / 3600000) + 'h';
}
function bar(percent, max = 100) {
if (percent == null) return '<div class="bar-track"></div>';
const clamped = Math.max(0, Math.min(100, percent / max * 100));
const cls = clamped >= 90 ? 'bad' : clamped >= 70 ? 'warn' : 'ok';
return `<div class="bar-track"><div class="bar-fill ${cls}" style="width:${clamped}%"></div></div>`;
}
function pushUrl() {
const next = new URL(window.location);
if (selectedStream) next.searchParams.set('stream', selectedStream); else next.searchParams.delete('stream');
if (selectedActor) next.searchParams.set('actor', selectedActor); else next.searchParams.delete('actor');
window.history.replaceState(null, '', next);
}
async function refresh() {
let data;
try {
const response = await fetch('/api/view/fleet');
data = await response.json();
} catch { return; }
lastSnapshot = data;
render();
}
function render() {
const page = document.getElementById('page');
const status = document.getElementById('status');
const data = lastSnapshot;
const live = data.live || [];
const stale = data.stale || [];
const totals = data.totals || {};
status.textContent = `${fmt(totals.live_nodes)} live · ${fmt(totals.stale_nodes)} stale`;
const node = live.concat(stale).find(n => n.stream.key === selectedStream)
|| live.find(n => n.stream.key === selectedStream);
if (node) {
renderNode(page, node, live, stale);
} else {
renderFleet(page, live, stale, totals);
}
updateAgo();
}
function renderFleet(page, live, stale, totals) {
stopDetailPolling();
const orchestrators = live.filter(n => (n.stream.origin || '') === 'orchestrator');
const workers = live.filter(n => (n.stream.origin || '') !== 'orchestrator');
const parts = [];
if (!live.length && !stale.length) {
parts.push('<div class="empty">No telemetry streams yet. A live swactor runtime or host publisher will populate this page.</div>');
} else {
parts.push('<div class="grid">' + orchestrators.concat(workers).map(nodeCard).join('') + '</div>');
if (stale.length) {
parts.push(`<details class="stale">
<summary>Stale nodes (${stale.length}) — silent beyond the liveness window</summary>
<div class="grid">${stale.map(nodeCard).join('')}</div>
</details>`);
}
}
const html = parts.join('');
if (html !== lastPageHtml) {
page.innerHTML = html;
lastPageHtml = html;
}
updateCardTexts(orchestrators.concat(workers, stale));
}
function nodeCard(node) {
const summary = node.actor_summary || {};
const proc = node.process ? `<span class="pill">${esc(node.process.state)}</span> ` : '';
const origin = node.stream.origin || '';
const isOrchestrator = origin === 'orchestrator';
const role = isOrchestrator ? '<span class="role">orchestrator</span>' : '';
const label = node.stream.label ? ` · ${esc(node.stream.label)}` : '';
// The stats line (actors · msg/s · uptime) jitters every frame on busy
// supervisors; it renders as a pass-filled span so the card markup — and
// with it the whole grid — stays byte-identical between polls.
return `<a class="node-card${isOrchestrator ? ' orchestrator' : ''}" data-stream="${esc(node.stream.key)}" data-live="${node.live ? 'true' : 'false'}" href="?stream=${encodeURIComponent(node.stream.key)}">
${role}
<h3>${esc(node.stream.node)} <span class="muted" style="font-size:12px">life ${fmt(node.stream.life)}</span></h3>
<div class="meta">${proc}seen <span data-seen></span> ago · <span data-stats></span>${label}</div>
${summary.poisoned ? `<div class="notice err">${fmt(summary.poisoned)} poisoned actor(s)</div>` : ''}
</a>`;
}
function cardStats(node) {
const summary = node.actor_summary || {};
return [
summary.actors ? fmt(summary.actors) + ' actors' : null,
summary.msg_per_sec ? fmtRate(summary.msg_per_sec) + ' msg/s' : null,
summary.num_workers != null ? fmt(summary.num_workers) + ' workers' : null,
summary.uptime_ms != null ? 'up ' + ago(summary.uptime_ms) : null,
].filter(Boolean).join(' · ') || 'no runtime stats';
}
function updateCardTexts(nodes) {
const byStream = new Map(nodes.map(node => [node.stream.key, node]));
for (const card of document.querySelectorAll('a.node-card')) {
const node = byStream.get(card.getAttribute('data-stream'));
const seen = card.querySelector('[data-seen]');
if (seen) seen.textContent = node ? ago(node.last_seen_ms_ago) : '—';
const stats = card.querySelector('[data-stats]');
if (stats) stats.textContent = node ? cardStats(node) : 'no runtime stats';
}
}
function renderNode(page, node, live, stale) {
const summary = node.actor_summary || {};
const active = document.activeElement;
const refocus = active && active.id === 'roster-filter' && page.contains(active)
? { start: active.selectionStart, end: active.selectionEnd, dir: active.selectionDirection }
: null;
const html = `
<a class="focus-back" href="/">← Fleet (<span data-live-count></span> live, <span data-stale-count></span> stale)</a>
<div class="layout ${selectedActor ? 'with-dossier' : ''}">
<div class="node-view" data-node="${esc(node.stream.node)}">
<section class="panel">
<h2 style="margin:0 0 8px">${esc(node.stream.node)}${node.stream.label ? ` <span class="muted" style="font-size:13px">${esc(node.stream.label)}</span>` : ''} <span class="muted" style="font-size:13px">life ${fmt(node.stream.life)} · <span data-seen></span></span></h2>
<div id="machine-detail"></div>
</section>
<section class="panel">
<h2 style="margin:0 0 8px">Actors (<span data-actor-count></span>)</h2>
<input type="search" id="roster-filter" aria-label="Filter actors" placeholder="filter by type, address, worker…" value="${esc(rosterFilter)}">
<div id="roster-wrap" style="margin-top:10px"></div>
</section>
</div>
<div id="dossier-slot"></div>
</div>`;
if (html !== lastPageHtml) {
page.innerHTML = html;
lastPageHtml = html;
lastMachineHtml = null;
lastRosterHtml = null;
const filter = document.getElementById('roster-filter');
filter.addEventListener('input', e => { rosterFilter = e.target.value; renderRoster(node); });
if (refocus) {
filter.focus();
try { filter.setSelectionRange(refocus.start, refocus.end, refocus.dir); } catch { /* caret shape changed */ }
}
}
const liveCount = page.querySelector('[data-live-count]');
if (liveCount) liveCount.textContent = fmt(live.length);
const staleCount = page.querySelector('[data-stale-count]');
if (staleCount) staleCount.textContent = fmt(stale.length);
const actorCount = page.querySelector('[data-actor-count]');
if (actorCount) actorCount.textContent = fmt(summary.actors);
const seen = page.querySelector('h2 [data-seen]');
if (seen) seen.textContent = node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago';
const machine = machineDetail(node);
const machineSlot = document.getElementById('machine-detail');
if (machineSlot && machine !== lastMachineHtml) {
machineSlot.innerHTML = machine;
lastMachineHtml = machine;
}
renderRoster(node);
if (selectedActor) {
if (!detailTimer) startDetailPolling();
} else {
stopDetailPolling();
}
}
function machineDetail(node) {
const cpu = node.cpu, gpu = node.gpu, net = node.net;
const rows = [];
const cpuTotal = cpu && cpu.host ? cpu.host.total_percent : null;
rows.push(`<div class="bar-row"><span class="name">CPU</span>${bar(cpuTotal)}<span class="num">${cpuTotal == null ? '—' : fmt(cpuTotal, 1) + '%'}</span></div>`);
if (gpu && gpu.gpus) {
gpu.gpus.forEach((device, index) => {
const util = device.utilization_gpu_percent;
const used = device.memory_used_mib || 0, total = device.memory_total_mib || 0;
rows.push(`<div class="bar-row"><span class="name">GPU ${fmt(index)}</span>${bar(util)}<span class="num">${util == null ? '—' : fmt(util) + '%'}</span></div>`);
if (total) rows.push(`<div class="bar-row"><span class="name">&nbsp;</span>${bar(used, total)}<span class="num">${fmt(used)}/${fmt(total)} MiB</span></div>`);
});
}
if (net && net.interfaces) {
net.interfaces.slice(0, 4).forEach(nic => {
rows.push(`<div class="bar-row"><span class="name" title="${esc(nic.name)}">${esc(nic.name.slice(0, 12))}</span><span class="num" style="width:auto;flex:1">${fmtRate(nic.rx_bps)}↓ ${fmtRate(nic.tx_bps)}↑</span></div>`);
});
}
if (node.history && node.history.length) drawMachineHistory(node.history);
const errors = (node.errors || []).map(e => `<div class="notice err">${esc(e)}</div>`).join('');
return `<div class="bars">${rows.join('')}</div><canvas id="machine-spark" width="600" height="64"></canvas>${errors}`;
}
function drawMachineHistory(history) {
requestAnimationFrame(() => {
const canvas = document.getElementById('machine-spark');
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const points = history.map(h => ({ cpu: h.cpu_total_percent, gpu: h.gpu_max_percent }));
drawLine(ctx, points.map(p => p.gpu), '#fbbf24');
drawLine(ctx, points.map(p => p.cpu), '#34d399');
});
}
function drawLine(ctx, values, color) {
const valid = values.filter(v => v != null);
if (valid.length < 2) return;
const max = Math.max(100, ...valid);
const step = ctx.canvas.width / (values.length - 1 || 1);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
values.forEach((value, index) => {
if (value == null) return;
const x = index * step;
const y = ctx.canvas.height - (value / max) * (ctx.canvas.height - 6) - 3;
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
});
ctx.stroke();
}
function renderRoster(node) {
const wrap = document.getElementById('roster-wrap');
if (!wrap) return;
let rows = node.roster || [];
if (rosterFilter) {
const q = rosterFilter.toLowerCase();
rows = rows.filter(a => `${a.name || ''} ${a.actor_type || ''} ${a.address} ${a.worker_id ?? ''}`.toLowerCase().includes(q));
}
const capped = rows.slice(0, ROSTER_RENDER_CAP);
const notice = rows.length > capped.length
? `<div class="notice">showing ${fmt(capped.length)} of ${fmt(rows.length)} — refine the filter to see more</div>`
: '';
let html;
if (!rows.length) {
html = '<div class="empty">No actors on this stream (or none match the filter).</div>';
} else {
html = notice + `<table>
<thead><tr><th>Actor</th><th>Type</th><th>State</th><th>Mailbox</th><th>Msg/s</th><th>Processed</th><th>Worker</th><th>Last message</th></tr></thead>
<tbody>${capped.map(a => `<tr data-addr="${esc(a.address)}" tabindex="0" ${a.address === selectedActor ? 'data-selected="true"' : ''}>
<td class="mono">${esc(shortAddr(a.address))}${a.name ? `<br><span class="muted">${esc(a.name)}</span>` : ''}</td>
<td><span class="muted" title="${esc(a.actor_type || '')}">${typeShort(a.actor_type)}</span></td>
<td>${a.poisoned ? '<span class="pill poisoned">poisoned</span>' : '<span class="muted">running</span>'}</td>
<td data-mailbox></td>
<td data-rate></td>
<td data-processed></td>
<td>${a.worker_id == null ? '—' : fmt(a.worker_id)}</td>
<td><span class="muted" data-last-message></span></td>
</tr>`).join('')}</tbody>
</table>`;
}
if (html !== lastRosterHtml) {
wrap.innerHTML = html;
lastRosterHtml = html;
}
updateRosterTexts(wrap, capped);
}
function updateRosterTexts(wrap, actors) {
const byAddress = new Map(actors.map(actor => [actor.address, actor]));
for (const row of wrap.querySelectorAll('tr[data-addr]')) {
const actor = byAddress.get(row.getAttribute('data-addr'));
if (!actor) continue;
row.querySelector('[data-mailbox]').textContent = fmt(actor.mailbox_depth);
row.querySelector('[data-rate]').textContent = fmtRate(actor.msg_per_sec);
row.querySelector('[data-processed]').textContent = fmt(actor.messages_processed);
const last = row.querySelector('[data-last-message]');
const messageType = actor.last_msg_type || '';
last.title = messageType;
const parts = String(messageType).split('::');
last.textContent = messageType ? parts[parts.length - 1] || messageType : '—';
}
}
function shortAddr(address) {
const s = String(address || '');
return s.length > 14 ? s.slice(0, 8) + '…' + s.slice(-4) : s;
}
function stopDetailPolling() {
if (detailTimer) { clearInterval(detailTimer); detailTimer = null; }
}
function startDetailPolling() {
stopDetailPolling();
const poll = async () => {
if (!selectedStream || !selectedActor) return;
let detail;
try {
const response = await fetch(`/api/view/fleet/detail?stream=${encodeURIComponent(selectedStream)}&actor=${encodeURIComponent(selectedActor)}`);
if (!response.ok) { stopDetailPolling(); return; }
detail = await response.json();
} catch { return; }
renderDossier(detail);
};
poll();
detailTimer = setInterval(poll, POLL_MS);
}
function renderDossier(d) {
const slot = document.getElementById('dossier-slot');
if (!slot) return;
const diet = (d.message_type_counts || []).slice().sort((a, b) => b.count - a.count);
const dietMax = Math.max(1, ...diet.map(x => x.count));
const lastName = d.last_msg_type;
const dietHtml = diet.length ? diet.map(x => `
<div class="bar-row"><span class="name" title="${esc(x.message_type)}">${esc(x.message_type)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${(x.count / dietMax * 100).toFixed(1)}%"></div></div>
<span class="num">${fmt(x.count)}</span></div>`).join('')
: '<div class="empty">no message-type counts on frame</div>';
const receipts = (d.receipts || []).slice().reverse();
const receiptsHtml = receipts.length ? `<table class="receipts">
<thead><tr><th>When</th><th>Message type</th><th>Folded</th></tr></thead>
<tbody>${receipts.map(r => `<tr>
<td class="muted">${agoSpan(Date.now() - r.ms_ago)} ago</td>
<td><span class="mono" title="${esc(r.ty)}">${typeShort(r.ty)}</span></td>
<td class="muted">${r.folded ? '+' + fmt(r.folded) + ' sampled' : '—'}</td>
</tr>`).join('')}</tbody>
</table>
<div class="notice">showing last ${fmt(receipts.length)} receipts · ${fmt(d.sampled_out)} messages sampled out · ${fmt(d.messages_processed)} processed total</div>`
: '<div class="empty">no message receipts folded yet</div>';
const html = `<section class="panel dossier">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px">
<h2 style="margin:0">${typeShort(d.actor_type)} <span class="muted mono" style="font-size:12px">${esc(shortAddr(d.address))}</span></h2>
<button id="dossier-close" aria-label="Close dossier" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px">×</button>
</div>
<div class="kv" style="margin-top:10px">
<span class="k">node</span><span>${esc(d.stream.node)}</span>
<span class="k">address</span><span class="mono">${esc(d.address)}</span>
${d.name ? `<span class="k">name</span><span>${esc(d.name)}</span>` : ''}
<span class="k">actor type</span><span class="mono" title="${esc(d.actor_type || '')}">${esc(d.actor_type || '—')}</span>
<span class="k">message type</span><span class="mono" title="${esc(d.message_type || '')}">${esc(d.message_type || '—')}</span>
<span class="k">state</span><span>${d.poisoned ? '<span class="pill poisoned">poisoned</span>' : 'running'}</span>
<span class="k">worker</span><span>${d.worker_id == null ? '—' : fmt(d.worker_id)}</span>
<span class="k">mailbox</span><span>${fmt(d.mailbox_depth)} (${d.mailbox_growth >= 0 ? '+' : ''}${fmtRate(d.mailbox_growth)}/s)</span>
<span class="k">throughput</span><span>${fmtRate(d.msg_per_sec)} msg/s</span>
<span class="k">processed</span><span>${fmt(d.messages_processed)}</span>
<span class="k">last message</span><span class="mono" title="${esc(d.last_msg_type || '')}">${esc(d.last_msg_type || '—')}</span>
</div>
<h2>Throughput</h2>
<canvas id="dossier-spark" width="380" height="64"></canvas>
<h2>Message diet</h2>
<div class="diet">${dietHtml}</div>
<h2>Message history</h2>
${receiptsHtml}
</section>`;
if (html !== lastDossierHtml) {
slot.innerHTML = html;
lastDossierHtml = html;
}
const history = d.history || [];
const canvas = document.getElementById('dossier-spark');
if (canvas && history.length > 1) {
const ctx = canvas.getContext('2d');
drawLine(ctx, history.map(h => h.msg_per_sec), '#60a5fa');
}
updateAgo();
}
// A2: delegation on the persistent #page container. Cards, roster rows and
// the dossier close button are rebuilt by render passes; listeners bound at
// startup mean a press straddling a rebuild still lands on the right target.
const pageEl = document.getElementById('page');
pageEl.addEventListener('click', e => {
if (e.target.closest('#dossier-close')) {
selectedActor = null;
pushUrl();
render();
return;
}
const rosterRow = e.target.closest('tr[data-addr]');
if (rosterRow) {
selectedActor = rosterRow.getAttribute('data-addr');
pushUrl();
render();
return;
}
const card = e.target.closest('a.node-card');
if (card) {
e.preventDefault();
selectedStream = card.getAttribute('data-stream');
selectedActor = null;
pushUrl();
render();
}
});
pageEl.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const rosterRow = e.target.closest('tr[data-addr]');
if (rosterRow) {
selectedActor = rosterRow.getAttribute('data-addr');
pushUrl();
render();
}
});
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>