Some checks are pending
offline / test (push) Waiting to run
Staff pages typeahead customers by name (Ada, not cust_1). Add-machine is two rows with a wide identity path and filename picker. Overflow menus flip up near the viewport edge. Customer list no longer leaks password hashes. UI-REVIEW.pdf remaining list is the three leftover items.
456 lines
28 KiB
HTML
456 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>Verae operator console</title>
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
|
|
<style>
|
|
:root {
|
|
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
|
|
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe;
|
|
--ok:#047857; --ok-bg:#e3f6e8; --warn:#8a5a00; --warn-bg:#fff3bf;
|
|
--err:#dc2626; --err-bg:#f9d4d4; --radius:12px;
|
|
--shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
|
|
--fault:#ea580c;
|
|
}
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
|
.skip { position:absolute; left:-999px; }
|
|
.skip:focus { left:1rem; top:1rem; z-index:30; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
|
|
header.app {
|
|
background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%);
|
|
color:#eef0fe; padding:1rem 1.25rem 0; display:flex; flex-wrap:wrap; gap:.75rem 1.5rem; align-items:flex-end;
|
|
}
|
|
header.app h1 { margin:0; font-size:1.2rem; }
|
|
header.app p { margin:.2rem 0 .8rem; font-size:13px; opacity:.85; max-width:44rem; }
|
|
nav { display:flex; gap:.15rem; }
|
|
nav button {
|
|
border:0; background:transparent; color:#c7c9ff; min-height:44px; padding:.7rem 1.15rem;
|
|
border-radius:10px 10px 0 0; font:650 14px system-ui; cursor:pointer;
|
|
}
|
|
nav button:hover { color:#fff; background:rgba(255,255,255,.08); }
|
|
nav button.on { background:var(--bg); color:var(--accent); }
|
|
nav button:focus-visible, button.act:focus-visible, a.act:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
|
outline:2px solid #fff; outline-offset:2px;
|
|
}
|
|
nav button.on:focus-visible { outline-color:var(--accent); }
|
|
main { padding:1rem 1.25rem 2.5rem; max-width:1280px; margin:0 auto; }
|
|
.panel { display:none; }
|
|
.panel.on { display:block; }
|
|
h2 { font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); margin:1.2rem 0 .5rem; }
|
|
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:.75rem; }
|
|
.card {
|
|
background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow);
|
|
padding:.85rem .95rem; border:1px solid var(--line);
|
|
}
|
|
.card.operational { background:var(--ok-bg); border-color:#c8efd4; }
|
|
.card.degraded { background:var(--warn-bg); border-color:#ffe08a; }
|
|
.card.down { background:var(--err-bg); border-color:#f3c0c0; }
|
|
.card.disabled { background:#f1f5f9; border-color:#e5e7f0; }
|
|
.card h3 { margin:0 0 .25rem; font-size:14px; }
|
|
.muted { color:var(--muted); font-size:12px; }
|
|
.pill { font:700 10px system-ui; letter-spacing:.06em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; }
|
|
.pill.good { background:#c8efd4; color:var(--ok); }
|
|
.pill.warn { background:#ffe08a; color:var(--warn); }
|
|
.pill.bad { background:#f3c0c0; color:var(--err); }
|
|
button.act, a.act { margin:.15rem .2rem 0 0; border:1px solid var(--line); background:#fff; border-radius:8px; padding:.28rem .5rem; font:650 12px system-ui; cursor:pointer; text-decoration:none; color:var(--ink); display:inline-block; }
|
|
button.act.primary, a.act.primary { background:var(--accent); color:#fff; border:0; }
|
|
button.act.danger { color:var(--err); }
|
|
button.act.fault { background:var(--fault); color:#fff; border:0; }
|
|
.table-wrap { overflow:auto; border-radius:var(--radius); box-shadow:var(--shadow); }
|
|
table { width:100%; border-collapse:collapse; background:var(--panel); overflow:visible; }
|
|
th,td { text-align:left; padding:.45rem .6rem; border-bottom:1px solid var(--line); font-size:13px; vertical-align:top; }
|
|
th { font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); background:#fafbff; }
|
|
tr.operational { background:var(--ok-bg); }
|
|
tr.degraded { background:var(--warn-bg); }
|
|
tr.down { background:var(--err-bg); }
|
|
.inst { font:12px ui-monospace,Menlo,monospace; margin:.2rem 0; display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; }
|
|
form.add { display:flex; flex-wrap:wrap; gap:.4rem; margin:.4rem 0 1rem; align-items:end; }
|
|
form.add .row1, form.add .row2 { display:flex; flex-wrap:wrap; gap:.4rem; align-items:end; width:100%; }
|
|
form.add .identity-field { flex: 1 1 22rem; }
|
|
form.add .identity-field input[name="identityFile"] { min-width:18rem; width:100%; }
|
|
form.add .roles-field { flex: 1 1 100%; }
|
|
form.add label { font-size:11px; color:var(--muted); display:flex; flex-direction:column; gap:.15rem; }
|
|
form.add input, form.add select, aside input, aside select, aside textarea {
|
|
padding:.4rem .5rem; border:1px solid var(--line); border-radius:8px; font:13px ui-monospace,Menlo,monospace;
|
|
}
|
|
.roles { display:flex; flex-wrap:wrap; gap:.35rem; max-width:22rem; }
|
|
.roles label {
|
|
display:inline-flex; align-items:center; gap:.25rem; font:650 11px system-ui; color:var(--ink);
|
|
background:var(--soft); border:1px solid #d7dbf8; border-radius:999px; padding:.2rem .55rem; cursor:pointer;
|
|
}
|
|
.roles input { accent-color:var(--accent); }
|
|
#toast { position:fixed; right:1rem; bottom:1rem; max-width:28rem; background:#171a26; color:#eef0fe; padding:.8rem 1rem; border-radius:10px; display:none; white-space:pre-wrap; font:12px ui-monospace,Menlo,monospace; z-index:9; }
|
|
.layout { display:grid; grid-template-columns:minmax(260px,340px) 1fr; gap:1rem; min-height:70vh; }
|
|
@media (max-width:900px) { .layout { grid-template-columns:1fr; } }
|
|
aside.trace { background:var(--panel); border-radius:var(--radius); box-shadow:var(--shadow); padding:1rem; }
|
|
.console { background:#0f171d; color:#d7e2ea; border-radius:10px; min-height:220px; overflow:auto; font:12px/1.35 ui-monospace,Menlo,monospace; }
|
|
.hop { display:grid; grid-template-columns:4.5rem 1.2rem minmax(7rem,11rem) minmax(0,1fr); gap:.4rem; padding:.28rem .7rem; border-left:3px solid transparent; }
|
|
.hop .mod, .hop .addr, .hop strong { overflow-wrap:anywhere; word-break:break-word; }
|
|
.hop.ok { border-color:var(--ok); }
|
|
.hop.error { border-color:var(--err); color:#ffb4b0; }
|
|
.hop.silent { border-color:#6a7a86; color:#8fa0ab; }
|
|
.hop.retry,.hop.delay { border-color:#d39b00; }
|
|
.mod { color:#9ad7c2; }
|
|
.addr { color:#8cb4ea; }
|
|
.docs .card p { margin:.35rem 0 .7rem; color:var(--muted); font-size:13px; }
|
|
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
|
|
code { font:12px ui-monospace,Menlo,monospace; }
|
|
.spark { display:flex; gap:3px; align-items:flex-end; height:28px; margin:.35rem 0; }
|
|
.spark i { display:block; width:8px; background:var(--accent); border-radius:2px 2px 0 0; opacity:.85; }
|
|
.more { position:relative; display:inline-block; }
|
|
.more-btn { min-width:2rem; font-size:16px; line-height:1; }
|
|
.more-pop {
|
|
display:none; position:absolute; right:0; top:calc(100% + 4px); z-index:6;
|
|
background:#fff; border:1px solid var(--line); border-radius:10px;
|
|
box-shadow:var(--shadow); min-width:11rem; padding:.25rem; z-index:40;
|
|
}
|
|
.more.open .more-pop { display:block; }
|
|
.more.open.up .more-pop { top:auto; bottom:calc(100% + 4px); }
|
|
.more-pop button {
|
|
display:block; width:100%; text-align:left; border:0; background:none;
|
|
padding:.45rem .6rem; border-radius:8px; font:650 12px system-ui; cursor:pointer; color:var(--ink);
|
|
}
|
|
.more-pop button:hover { background:var(--soft); }
|
|
.more-pop button.danger { color:var(--err); }
|
|
.faults { border:1px solid #fed7aa; background:#fff7ed; border-radius:10px; padding:.55rem .7rem; }
|
|
.empty { text-align:center; padding:1.4rem 1rem; color:var(--muted); }
|
|
.empty svg { display:block; margin:0 auto .5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
<header class="app">
|
|
<div>
|
|
<div class="kicker">operator LAN · binds 0.0.0.0:3850</div>
|
|
<h1>Verae operator console</h1>
|
|
<p>Fleet, message trace, and docs in one shell. HTTP listens on all interfaces; NATS stays private. Pause does not count toward replica floors. SSH keys stay on disk.</p>
|
|
</div>
|
|
<nav>
|
|
<button class="on" data-tab="fleet">Fleet</button>
|
|
<button data-tab="trace">Trace</button>
|
|
<button data-tab="docs">Docs</button>
|
|
</nav>
|
|
</header>
|
|
<main id="main">
|
|
<section id="fleet" class="panel on">
|
|
<p id="meta" class="muted"></p>
|
|
<h2>Machines</h2>
|
|
<form class="add" id="addMachine">
|
|
<div class="row1">
|
|
<label>id <input name="id" required placeholder="ns1-b"/></label>
|
|
<label>host <input name="host" required placeholder="70.88.205.138"/></label>
|
|
<label>kind <select name="kind"><option value="local">local</option><option value="ssh" selected>ssh</option><option value="agent">agent</option></select></label>
|
|
<label>user <input name="user" value="marchon"/></label>
|
|
<label>ssh port <input name="sshPort" type="number" value="22"/></label>
|
|
<label>capacity <input name="capacity" type="number" min="1" value="8"/></label>
|
|
</div>
|
|
<div class="row2">
|
|
<label class="identity-field">identity file (path on this host)
|
|
<span style="display:flex;gap:.35rem;align-items:center">
|
|
<input name="identityFile" value="~/.ssh/id_ed25519"/>
|
|
<input type="file" id="identityPick" title="Use filename only; SSH keys stay on disk" style="max-width:11rem"/>
|
|
</span>
|
|
</label>
|
|
<label class="roles-field">roles
|
|
<div class="roles" id="roleChips">
|
|
<label><input type="checkbox" name="role" value="*"/> *</label>
|
|
<label><input type="checkbox" name="role" value="tree-node" checked/> tree-node</label>
|
|
<label><input type="checkbox" name="role" value="archive-worm" checked/> archive-worm</label>
|
|
<label><input type="checkbox" name="role" value="archive-aggregator"/> archive-aggregator</label>
|
|
<label><input type="checkbox" name="role" value="job-poller"/> job-poller</label>
|
|
<label><input type="checkbox" name="role" value="webhook-deliver"/> webhook-deliver</label>
|
|
<label><input type="checkbox" name="role" value="nats"/> nats</label>
|
|
<label><input type="checkbox" name="role" value="edge"/> edge</label>
|
|
</div>
|
|
</label>
|
|
<button class="act primary" type="submit">Add machine</button>
|
|
</div>
|
|
</form>
|
|
<div class="cards" id="machines"></div>
|
|
<h2>Services</h2>
|
|
<div id="rows"></div>
|
|
</section>
|
|
<section id="trace" class="panel">
|
|
<div class="layout">
|
|
<aside class="trace">
|
|
<h2>Zapier step (simulator)</h2>
|
|
<label class="muted">Action</label>
|
|
<select id="action"></select>
|
|
<label class="muted">Data / batch lines</label>
|
|
<textarea id="data" style="width:100%;min-height:72px">invoice-1001
|
|
invoice-1002
|
|
invoice-1003</textarea>
|
|
<label class="muted">SHA256</label>
|
|
<input id="sha256" style="width:100%"/>
|
|
<label class="muted">Public metadata JSON</label>
|
|
<input id="publicMeta" style="width:100%" placeholder='{"source":"zap"}'/>
|
|
<p><label><input type="checkbox" id="includeAttached"/> includeAttached</label>
|
|
<label><input type="checkbox" id="includeTree"/> includeTree</label></p>
|
|
<h2>Faults</h2>
|
|
<div class="faults">
|
|
<p class="muted" style="margin:.1rem 0 .35rem">Orange is inject-only — not the product brand.</p>
|
|
<p class="muted"><label><input type="checkbox" id="failEdge402"/> 402</label>
|
|
<label><input type="checkbox" id="failNatsDropEvents"/> drop events</label>
|
|
<label><input type="checkbox" id="recover"/> recover</label></p>
|
|
</div>
|
|
<p>
|
|
<button class="act primary" id="run">Run step</button>
|
|
<button class="act" id="reset">Reset world</button>
|
|
<button class="act" data-sc="batch">Batch Merkle</button>
|
|
<button class="act" data-sc="leaf">Lookup leaf</button>
|
|
<button class="act" data-sc="tree">Lookup tree</button>
|
|
</p>
|
|
</aside>
|
|
<div>
|
|
<h2>Response</h2>
|
|
<pre id="response" class="card" style="white-space:pre-wrap">Run a step.</pre>
|
|
<h2>Hop trace</h2>
|
|
<div class="console" id="console"></div>
|
|
<h2>Monitors</h2>
|
|
<div id="monitors"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section id="docs" class="panel docs">
|
|
<p class="muted">Reading order: high-level first, then architecture, then the rest. Catalog defaults to colored PDFs. Operator HTTP binds all interfaces; NATS does not.</p>
|
|
<p><a class="act primary" href="/docs/CONSOLE.pdf" target="_blank" rel="noopener">Open CONSOLE.pdf</a>
|
|
· <a href="https://zapier.georgelambert.org/">PDF catalog</a>
|
|
· <a href="https://zapier.georgelambert.org/index-md.html">Markdown indexes</a></p>
|
|
<h2>High-level (start here)</h2>
|
|
<div class="cards">
|
|
<article class="card"><h3>Overview</h3><p>Whole system in plain language.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/README.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Modules and repositories</h3><p>Short names of every independent repo.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/02-modules-and-repos.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>docs-master</h3><p>One-line SUMMARY + NATS for each module.</p><a class="act primary" href="https://zapier.georgelambert.org/docs-master/README.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>User guide</h3><p>Signup → register → lookup (non-operator).</p><a class="act primary" href="https://zapier.georgelambert.org/user-docs/README.pdf">Open PDF</a></article>
|
|
</div>
|
|
<h2>Architecture</h2>
|
|
<div class="cards">
|
|
<article class="card"><h3>NATS.IO 3-server cluster</h3><p>Loopback NATS, not a public bus.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/03-nats-cluster.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Uptime / replica floors</h3><p>Pause does not count toward floors.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/04-uptime.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Local network failures</h3><p>What the monitors flag.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/05-network-failures.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Address routing</h3><p>Access planes to internal subjects.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/06-address-routing.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Diagrams</h3><p>System, cluster, routing.</p><a class="act" href="https://zapier.georgelambert.org/overview/08-diagrams.pdf">PDF</a> <a class="act" href="https://zapier.georgelambert.org/overview/diagrams/system.svg">SVG</a></article>
|
|
<article class="card"><h3>Composition</h3><p>How packages sit together.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/composition.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Module NATS map</h3><p>Subjects per repo.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/modules-and-nats.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Archive / bloom</h3><p>WORM nodes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/archive-nats.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Tree nodes</h3><p>Merkle proofs for hashes.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/tree-nodes.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Fleet</h3><p>Replica floors and spawn.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/02-architecture/fleet.pdf">Open PDF</a></article>
|
|
</div>
|
|
<h2>Remaining documentation</h2>
|
|
<div class="cards">
|
|
<article class="card"><h3>Full index</h3><p>Every remaining document.</p><a class="act primary" href="https://zapier.georgelambert.org/overview/INDEX.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Expansion template</h3><p>New verae.* worker.</p><a class="act" href="https://zapier.georgelambert.org/overview/09-expansion-template.pdf">PDF</a> <a class="act" href="https://git.georgelambert.org/marchon/verae-nats-process">repo</a></article>
|
|
<article class="card"><h3>Zapier developer setup</h3><p>Platform CLI, not a customer surface.</p><a class="act primary" href="https://zapier.georgelambert.org/docs/04-activate/SETUP-ZAPIER-DEVELOPER.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>Simulator README</h3><p>Hop-by-hop lab.</p><a class="act primary" href="https://zapier.georgelambert.org/packages/verae-zapier-simulator/README.pdf">Open PDF</a></article>
|
|
<article class="card"><h3>verae-fleet repo</h3><p>Source for this console.</p><a class="act primary" href="https://git.georgelambert.org/marchon/verae-fleet">Open repo</a></article>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<div id="toast"></div>
|
|
<script>
|
|
const $ = (id) => document.getElementById(id);
|
|
const EMPTY_SVG = '<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg>';
|
|
async function j(url, opts) {
|
|
const r = await fetch(url, opts);
|
|
const body = await r.json().catch(() => ({}));
|
|
return { ok: r.ok, status: r.status, body };
|
|
}
|
|
function toast(msg) {
|
|
const t = $('toast');
|
|
t.textContent = typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2);
|
|
t.style.display = 'block';
|
|
clearTimeout(t._tm);
|
|
t._tm = setTimeout(() => { t.style.display = 'none'; }, 8000);
|
|
}
|
|
function spark(r) {
|
|
if (!r || !r.count) return '';
|
|
const vals = [r.minMs, r.avgMs, r.p50Ms, r.p90Ms].map(Number);
|
|
const max = Math.max(0.1, ...vals);
|
|
return `<div class="spark" title="min avg p50 p90">${vals.map(v => `<i style="height:${Math.max(4, (v/max)*28)}px"></i>`).join('')}</div>
|
|
<div class="muted">RTT ms ${r.minMs} / ${r.avgMs} / ${r.p50Ms} / ${r.p90Ms} n=${r.count}</div>`;
|
|
}
|
|
function pill(health) {
|
|
const label = health === 'operational' ? 'operational' : health === 'degraded' ? 'degraded' : health === 'disabled' ? 'disabled' : 'not operational';
|
|
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : health === 'disabled' ? '' : 'bad';
|
|
return `<span class="pill ${cls}">${label}</span>`;
|
|
}
|
|
function moreMenu(items) {
|
|
return `<div class="more">
|
|
<button type="button" class="act more-btn" aria-haspopup="menu" aria-label="Actions">⋯</button>
|
|
<div class="more-pop" role="menu">${items.map((it) =>
|
|
`<button type="button" role="menuitem" class="${it.danger ? 'danger' : ''}" data-path="${it.path}" data-confirm="${it.confirm || ''}">${it.label}</button>`
|
|
).join('')}</div>
|
|
</div>`;
|
|
}
|
|
async function act(path, { confirm: c } = {}) {
|
|
if (c && !confirm(c)) return;
|
|
const r = await j(path, { method:'POST' });
|
|
if (r.body.check) toast(r.body.check);
|
|
else if (!r.ok) toast(r.body.error || r.body);
|
|
await drawFleet();
|
|
}
|
|
document.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.more-pop button[data-path]');
|
|
if (item) {
|
|
act(item.dataset.path, { confirm: item.dataset.confirm || undefined });
|
|
item.closest('.more')?.classList.remove('open');
|
|
return;
|
|
}
|
|
const btn = e.target.closest('.more-btn');
|
|
if (btn) {
|
|
const wrap = btn.closest('.more');
|
|
document.querySelectorAll('.more.open').forEach((m) => { if (m !== wrap) m.classList.remove('open'); });
|
|
wrap.classList.toggle('open');
|
|
if (wrap.classList.contains('open')) {
|
|
const pop = wrap.querySelector('.more-pop');
|
|
const space = window.innerHeight - wrap.getBoundingClientRect().bottom;
|
|
wrap.classList.toggle('up', space < (pop?.offsetHeight || 140) + 16);
|
|
}
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
document.querySelectorAll('.more.open').forEach((m) => m.classList.remove('open'));
|
|
});
|
|
document.querySelectorAll('nav button').forEach((b) => b.addEventListener('click', () => {
|
|
document.querySelectorAll('nav button').forEach((x) => x.classList.toggle('on', x === b));
|
|
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === b.dataset.tab));
|
|
}));
|
|
async function drawFleet() {
|
|
const { body: s } = await j('/api/status');
|
|
$('meta').textContent = 'probe ' + (s.monitor?.t || '—') + ' · NATS ' + (s.nats?.url || '') + ' · HTTP 0.0.0.0:3850';
|
|
const machines = s.machines || [];
|
|
$('machines').innerHTML = machines.length ? machines.map((m) => {
|
|
const health = !m.enabled ? 'disabled' : m.running > 0 ? 'operational' : 'degraded';
|
|
return `<article class="card ${health}">
|
|
<h3>${m.id} ${pill(health)}</h3>
|
|
<div class="muted">${m.title || ''}</div>
|
|
<div><code>${m.kind === 'ssh' ? (m.user||'marchon')+'@' : ''}${m.host}</code></div>
|
|
<div class="muted">${m.kind} · ${m.running}/${m.capacity} running · ${m.available} avail</div>
|
|
<div class="muted">${(m.roles||[]).map((r) => `<span class="pill">${r}</span>`).join(' ')}</div>
|
|
${spark(m.rtt)}
|
|
<div>
|
|
<button class="act" onclick="act('/api/machines/${m.id}/${m.enabled ? 'disable' : 'enable'}')">${m.enabled ? 'disable' : 'enable'}</button>
|
|
${m.kind === 'ssh' ? `<button class="act" onclick="act('/api/machines/${m.id}/check')">ssh-check</button>` : ''}
|
|
</div>
|
|
</article>`;
|
|
}).join('') : `<div class="empty">${EMPTY_SVG}<div>No machines yet</div><p>Add a machine above. Roles are chips, not a truncated text field.</p></div>`;
|
|
const services = Object.values(s.services || {});
|
|
if (!services.length) {
|
|
$('rows').innerHTML = `<div class="empty">${EMPTY_SVG}<div>No services</div><p>Fleet catalog is empty on this host.</p></div>`;
|
|
return;
|
|
}
|
|
$('rows').innerHTML = '<div class="table-wrap"><table><thead><tr><th>Service</th><th>Health</th><th>Floor</th><th>RTT</th><th>Instances</th><th>Power</th></tr></thead><tbody>' +
|
|
services.map((sv) => {
|
|
const h = sv.health || 'down';
|
|
const inst = (sv.instances || []).map((i) => {
|
|
const st = !i.pid ? 'dead' : i.paused ? 'paused' : i.healthy === false ? 'unhealthy' : 'up';
|
|
const cls = st === 'up' ? 'good' : st === 'paused' ? 'warn' : 'bad';
|
|
return `<div class="inst"><span class="pill ${cls}">${st}</span> ${i.id} @${i.machine||'local'} :${i.healthPort}
|
|
${moreMenu([
|
|
{ label: 'Pause replica', path: `/api/instances/${i.id}/pause` },
|
|
{ label: 'Resume replica', path: `/api/instances/${i.id}/resume` },
|
|
{ label: 'Restart replica', path: `/api/instances/${i.id}/restart`, confirm: `Restart ${i.id}?`, danger: true },
|
|
])}
|
|
</div>`;
|
|
}).join('') || '<span class="muted">none</span>';
|
|
const pausedAll = (sv.instances || []).length && (sv.instances || []).every((i) => i.paused);
|
|
const power = h === 'down' || !sv.available ? 'off' : pausedAll ? 'paused' : 'on';
|
|
const powerCls = power === 'on' ? 'good' : power === 'paused' ? 'warn' : 'bad';
|
|
return `<tr class="${h}">
|
|
<td><strong>${sv.id}</strong><br><span class="muted">${sv.title||''}</span></td>
|
|
<td>${pill(h)}</td>
|
|
<td>${sv.available} / min ${sv.min}${sv.belowFloor ? ' <span class="pill bad">below</span>' : ''}</td>
|
|
<td>${spark(sv.rtt)}</td>
|
|
<td>${inst}</td>
|
|
<td>
|
|
<span class="pill ${powerCls}" title="Status, not a toggle">${power}</span>
|
|
${moreMenu([
|
|
{ label: 'Start (keep floor)', path: `/api/services/${sv.id}/start` },
|
|
{ label: 'Pause all replicas', path: `/api/services/${sv.id}/pause` },
|
|
{ label: 'Stop (disable keepFloor)', path: `/api/services/${sv.id}/stop`, confirm: `Turn off ${sv.id}? This disables keepFloor.`, danger: true },
|
|
])}
|
|
</td>
|
|
</tr>`;
|
|
}).join('') + '</tbody></table></div>';
|
|
}
|
|
$('identityPick')?.addEventListener('change', (e) => {
|
|
const f = e.target.files && e.target.files[0];
|
|
if (!f) return;
|
|
document.querySelector('input[name="identityFile"]').value = '~/.ssh/' + f.name;
|
|
});
|
|
$('addMachine').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const roles = [...f.querySelectorAll('input[name=role]:checked')].map((el) => el.value);
|
|
await j('/api/machines', {
|
|
method:'POST', headers:{'content-type':'application/json'},
|
|
body: JSON.stringify({
|
|
id: f.id.value, host: f.host.value, kind: f.kind.value, user: f.user.value,
|
|
sshPort: Number(f.sshPort.value||22), identityFile: f.identityFile.value,
|
|
capacity: Number(f.capacity.value||8),
|
|
roles: roles.length ? roles : ['tree-node'], enabled: true,
|
|
}),
|
|
});
|
|
f.reset();
|
|
f.querySelector('input[name=role][value="tree-node"]').checked = true;
|
|
f.querySelector('input[name=role][value="archive-worm"]').checked = true;
|
|
await drawFleet();
|
|
});
|
|
async function bootTrace() {
|
|
const { body } = await j('/api/sim/actions');
|
|
$('action').innerHTML = (body.actions||[]).map(a => `<option value="${a.key}">${a.label}</option>`).join('');
|
|
$('action').value = 'timestamp_and_wait';
|
|
}
|
|
function faults() {
|
|
const f = {};
|
|
for (const id of ['failEdge402','failNatsDropEvents','recover']) if ($(id).checked) f[id] = true;
|
|
return f;
|
|
}
|
|
async function runSim() {
|
|
const action = $('action').value;
|
|
let publicMetadata;
|
|
try { publicMetadata = $('publicMeta').value ? JSON.parse($('publicMeta').value) : undefined; } catch { publicMetadata = { raw: $('publicMeta').value }; }
|
|
const { body } = await j('/api/sim/run', {
|
|
method:'POST', headers:{'content-type':'application/json'},
|
|
body: JSON.stringify({
|
|
action, includeAttached: $('includeAttached').checked, includeTree: $('includeTree').checked,
|
|
faults: faults(),
|
|
input: { data: $('data').value, sha256: $('sha256').value.trim()||undefined, publicMetadata, items: $('data').value, email: 'ada@example.com' },
|
|
}),
|
|
});
|
|
$('response').textContent = JSON.stringify(body.response, null, 2);
|
|
if (body.response?.sha256) $('sha256').value = body.response.sha256;
|
|
if (body.response?.leaves) $('sha256').value = body.response.leaves[0];
|
|
$('console').innerHTML = (body.trace||[]).map(e => {
|
|
const arrow = e.dir === 'out' ? '←' : e.dir === 'in' ? '→' : '·';
|
|
return `<div class="hop ${e.status}"><span>${e.tMs}ms</span><span>${arrow}</span><span class="mod">${e.module}</span><span><strong>${e.hop}</strong> <span class="addr">${e.address||''}</span> ${e.note? '— '+e.note:''}</span></div>`;
|
|
}).join('');
|
|
const m = body.monitor || {};
|
|
$('monitors').innerHTML = `<p>${pill(m.ok ? 'operational' : 'degraded')} ${m.stats ? m.stats.hops+' hops · '+m.stats.errors+' errors' : ''}</p>` +
|
|
(m.suggestions||[]).map(s => `<div class="card ${s.severity==='high'?'down':'degraded'}"><span class="pill ${s.severity==='high'?'bad':'warn'}">${s.severity}</span> ${s.text}</div>`).join('');
|
|
}
|
|
$('run').onclick = runSim;
|
|
$('reset').onclick = async () => { await j('/api/sim/reset', { method:'POST' }); $('console').innerHTML=''; $('response').textContent='World reset.'; };
|
|
document.querySelectorAll('[data-sc]').forEach((b) => b.addEventListener('click', async () => {
|
|
const sc = b.dataset.sc;
|
|
if (sc === 'batch') { $('action').value = 'batch_timestamp'; $('data').value = 'invoice-1001\ninvoice-1002\ninvoice-1003'; }
|
|
if (sc === 'leaf') { $('action').value = 'hash_lookup'; $('includeTree').checked = false; }
|
|
if (sc === 'tree') { $('action').value = 'tree_lookup'; $('includeTree').checked = true; }
|
|
await runSim();
|
|
}));
|
|
drawFleet();
|
|
bootTrace().then(async () => {
|
|
const tab = new URLSearchParams(location.search).get('tab');
|
|
if (tab) document.querySelector(`nav button[data-tab="${tab}"]`)?.click();
|
|
const demo = new URLSearchParams(location.search).get('demo');
|
|
if (demo) {
|
|
const b = document.querySelector(`[data-sc="${demo}"]`);
|
|
if (b) b.click();
|
|
}
|
|
});
|
|
setInterval(drawFleet, 2000);
|
|
</script>
|
|
</body>
|
|
</html>
|