swactor/crates/dashboard/src/demo_control_page.html
Zachery Aaron Shores-Chmielewski c7d9c28e2b refactor(myelin): rework 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-20 01:46:11 +04:00

432 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

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