Some checks are pending
offline / test (push) Waiting to run
Match CS/sales/accounting/access-staff to the portal indigo system with dollar amounts, skip links, and empty states. Fleet replica actions move into overflow menus, roles become chips, Docs become cards, and the header copy reflects the 0.0.0.0 bind. Simulator uses the same shell (orange only for faults). Portal API keys are masked; admin customers edit in a drawer. Catalog uses system-ui. New UI-Docs repo holds screenshots, usage notes, and UI-REVIEW.pdf.
432 lines
27 KiB
HTML
432 lines
27 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 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 .roles-field { flex: 1 0 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-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">
|
|
<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>identity file <input name="identityFile" value="~/.ssh/id_ed25519"/></label>
|
|
<label>capacity <input name="capacity" type="number" min="1" value="8"/></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>
|
|
</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' : 'not operational';
|
|
const cls = health === 'operational' ? 'good' : health === 'degraded' ? 'warn' : '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');
|
|
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 ? 'down' : 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>';
|
|
}
|
|
$('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>
|