swactor/crates/dashboard/src/control_plane_page.html

549 lines
27 KiB
HTML
Raw Normal View History

<!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(--card-hover); }
.node-card .role { display: inline-flex; margin-left: 8px; padding: 2px 6px; border: 1px solid var(--amber); border-radius: var(--r); color: var(--amber); font: 600 10px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .07em; vertical-align: 2px; }
: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 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)}">
<h3>${esc(node.stream.node)}${role} <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 machine = machineDetail(node);
// The machine panel churns with live metrics, so swaps here are real; keep
// the roster filter usable by restoring focus and caret across them.
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 (${fmt(live.length)} live, ${fmt(stale.length)} stale)</a>
<div class="layout ${selectedActor ? 'with-dossier' : ''}">
<div class="node-view">
<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>
${machine}
</section>
<section class="panel">
<h2 style="margin:0 0 8px">Actors (${fmt(summary.actors)})</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;
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 seen = document.querySelector('#page h2 [data-seen]');
if (seen) seen.textContent = node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago';
renderRoster(node);
if (selectedActor) startDetailPolling();
}
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>${fmt(a.mailbox_depth)}</td>
<td>${fmtRate(a.msg_per_sec)}</td>
<td>${fmt(a.messages_processed)}</td>
<td>${a.worker_id == null ? '—' : fmt(a.worker_id)}</td>
<td><span class="muted" title="${esc(a.last_msg_type || '')}">${typeShort(a.last_msg_type)}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
if (html === lastRosterHtml) return;
wrap.innerHTML = html;
lastRosterHtml = html;
}
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>