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.
584 lines
28 KiB
HTML
584 lines
28 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[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"> </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>
|