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.
432 lines
22 KiB
HTML
432 lines
22 KiB
HTML
<!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) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[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>
|