swactor/crates/dashboard/src/control_plane_page.html
Zachery Aaron Shores-Chmielewski 1926e73064 feat(dashboard): Segment Mask visual world and merged Fleet Control
Fleet page: drop the aggregate totals row and bar skeletons — cards carry
identity, liveness, and runtime summary; hardware detail stays one click
down. Stream descriptors (origin/label) now ride FrameEvents into the
fleet view, so the orchestrator renders as a full-width amber-framed
module pinned above the grid. Cards are real links; roster rows are
keyboard-operable.

Fleet Control: /view/reconciler folds into /view/demo-control as one
control bench — ghost-segment ready/desired counter, generation digit,
unified node rows (reconciler stage + pid + state + kill), activity feeds
demoted to a collapsed tail. The standalone reconciler page is retired;
its API stays live to feed the merge.

Visual world (both themes, nav toggle, persisted, prefers-color-scheme
default): dark = Bloomberg night housing (black ground, navy panels,
amber data register); light = Atom One Light. 2px corners, monospace
data, outline chips for states, cyan as the only interactive voice,
ghost-eight segments for counters, blink reserved for unresolved states,
depressing controls, reduced-motion collapse.
2026-08-16 15:16:30 +04:00

469 lines
24 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(--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;
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);
}
}
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>`);
}
}
page.innerHTML = parts.join('');
bindCards();
}
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 stats = [
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(' · ');
const label = node.stream.label ? ` · ${esc(node.stream.label)}` : '';
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 ${ago(node.last_seen_ms_ago)} ago · ${stats || 'no runtime stats'}${label}</div>
${summary.poisoned ? `<div class="notice err">${fmt(summary.poisoned)} poisoned actor(s)</div>` : ''}
</a>`;
}
function renderNode(page, node, live, stale) {
const summary = node.actor_summary || {};
const machine = machineDetail(node);
page.innerHTML = `
<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)} · ${node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago'}</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>`;
renderRoster(node);
const filter = document.getElementById('roster-filter');
filter.addEventListener('input', e => { rosterFilter = e.target.value; 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>`
: '';
if (!rows.length) {
wrap.innerHTML = '<div class="empty">No actors on this stream (or none match the filter).</div>';
return;
}
wrap.innerHTML = 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>`;
wrap.querySelectorAll('tr[data-addr]').forEach(tr => {
const select = () => {
selectedActor = tr.getAttribute('data-addr');
pushUrl();
render();
};
tr.addEventListener('click', select);
tr.addEventListener('keydown', e => { if (e.key === 'Enter') select(); });
});
}
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">${ago(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>';
slot.innerHTML = `<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>`;
document.getElementById('dossier-close').addEventListener('click', () => {
selectedActor = null;
pushUrl();
render();
});
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');
}
}
function bindCards() {
document.querySelectorAll('.node-card').forEach(card => {
card.addEventListener('click', e => {
e.preventDefault();
selectedStream = card.getAttribute('data-stream');
selectedActor = null;
pushUrl();
render();
});
});
}
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>