diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js deleted file mode 100644 index 6af4e96a..00000000 --- a/hive-c0re/assets/app.js +++ /dev/null @@ -1,268 +0,0 @@ -// Dashboard SPA. Renders containers + approvals from `/api/state`, wires -// up async-form submission (URL-encoded POST + spinner + state refresh), -// and tails the broker over `/messages/stream` SSE. - -(() => { - // ─── helpers ──────────────────────────────────────────────────────────── - const $ = (id) => document.getElementById(id); - const esc = (s) => String(s).replace(/[&<>"]/g, (c) => - ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) - ); - const el = (tag, attrs = {}, ...children) => { - const e = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === 'class') e.className = v; - else if (k === 'html') e.innerHTML = v; - else if (k.startsWith('data-')) e.setAttribute(k, v); - else e.setAttribute(k, v); - } - for (const c of children) { - if (c == null) continue; - e.append(c.nodeType ? c : document.createTextNode(c)); - } - return e; - }; - const form = (action, btnClass, btnLabel, confirmMsg, extra = {}) => { - const f = el('form', { - method: 'POST', action, class: 'inline', 'data-async': '', - ...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), - }); - for (const [name, value] of Object.entries(extra)) { - f.append(el('input', { type: 'hidden', name, value })); - } - f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel)); - return f; - }; - - // ─── async forms ──────────────────────────────────────────────────────── - document.addEventListener('submit', async (e) => { - const f = e.target; - if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; - e.preventDefault(); - if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; - const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline'); - const original = btn ? btn.innerHTML : ''; - if (btn) { btn.disabled = true; btn.innerHTML = '◐'; } - try { - const resp = await fetch(f.action, { - method: f.method || 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(new FormData(f)), - redirect: 'manual', - }); - const ok = resp.ok || resp.type === 'opaqueredirect' - || (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ''); - alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); - if (btn) { btn.disabled = false; btn.innerHTML = original; } - return; - } - refreshState(); - } catch (err) { - alert('action failed: ' + err); - if (btn) { btn.disabled = false; btn.innerHTML = original; } - } - }); - - // ─── state rendering ──────────────────────────────────────────────────── - function renderContainers(s) { - const root = $('containers-section'); - root.innerHTML = ''; - - if (s.any_stale) { - root.append(form( - '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', - 'rebuild every stale container?', - )); - } - - const spawn = el('form', { - method: 'POST', action: '/request-spawn', - class: 'spawnform', 'data-async': '', - }); - spawn.append( - el('input', { - name: 'name', - placeholder: 'new agent name (≤9 chars)', - maxlength: '9', required: '', autocomplete: 'off', - }), - el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), - ); - root.append(spawn); - root.append(el('p', { class: 'meta' }, - 'spawn requests queue as approvals. operator approves below to actually create the container.', - )); - - if (s.transients.length) { - const ul = el('ul'); - for (const t of s.transients) { - ul.append(el('li', {}, - el('span', { class: 'glyph spinner' }, '◐'), ' ', - el('span', { class: 'agent' }, t.name), ' ', - el('span', { class: 'role role-pending' }, t.kind + '…'), ' ', - el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`), - )); - } - root.append(ul); - } - - if (!s.containers.length && !s.transients.length) { - root.append(el('p', { class: 'empty' }, '▓ no managed containers ▓')); - return; - } - - const ul = el('ul'); - for (const c of s.containers) { - const url = `http://${s.hostname}:${c.port}/`; - const li = el('li'); - li.append( - el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'), - ' ', - el('a', { href: url }, c.name), - ' ', - el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, - c.is_manager ? 'm1nd' : 'ag3nt'), - ); - if (c.needs_login) { - li.append(' ', el('a', - { class: 'role role-pending', href: url }, 'needs login →')); - } - if (c.needs_update) { - li.append(' ', form( - '/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻', - 'rebuild ' + c.name + '? hot-reloads the container.', - )); - } - li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`)); - - if (c.running) { - li.append( - ' ', - form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), - ); - if (!c.is_manager) { - li.append( - ' ', - form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), - ); - } - } - li.append( - ' ', - form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', - 'rebuild ' + c.name + '? hot-reloads the container.'), - ); - if (!c.is_manager) { - li.append( - ' ', - form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', - 'destroy ' + c.name + '? container is removed; state + creds kept.'), - ); - } - ul.append(li); - } - root.append(ul); - } - - function renderApprovals(s) { - const root = $('approvals-section'); - root.innerHTML = ''; - if (!s.approvals.length) { - root.append(el('p', { class: 'empty' }, '▓ queue empty ▓')); - return; - } - const ul = el('ul', { class: 'approvals' }); - for (const a of s.approvals) { - const li = el('li'); - const row = el('div', { class: 'row' }); - if (a.kind === 'apply_commit') { - row.append( - el('span', { class: 'glyph' }, '→'), ' ', - el('span', { class: 'id' }, '#' + a.id), ' ', - el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind' }, 'apply'), ' ', - el('code', {}, a.sha_short || ''), - ); - } else { - row.append( - el('span', { class: 'glyph' }, '⊕'), ' ', - el('span', { class: 'id' }, '#' + a.id), ' ', - el('span', { class: 'agent' }, a.agent), ' ', - el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ', - el('span', { class: 'meta' }, - 'new sub-agent — container will be created on approve'), - ); - } - row.append( - ' ', - form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'), - ' ', - form('/deny/' + a.id, 'btn-deny', 'DENY'), - ); - li.append(row); - if (a.diff_html) { - const details = el('details'); - details.append(el('summary', {}, 'diff vs applied')); - // diff_html is pre-rendered server-side (per-line class spans inside - // a
); inject as innerHTML.
- const pre = el('pre', { class: 'diff', html: a.diff_html });
- details.append(pre);
- li.append(details);
- }
- ul.append(li);
- }
- root.append(ul);
- }
-
- // ─── state polling ──────────────────────────────────────────────────────
- let pollTimer = null;
- async function refreshState() {
- try {
- const resp = await fetch('/api/state');
- if (!resp.ok) throw new Error('http ' + resp.status);
- const s = await resp.json();
- renderContainers(s);
- renderApprovals(s);
- // Auto-refresh while a spawn is in flight; otherwise back off.
- const next = s.transients.length ? 2000 : 0;
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
- if (next) pollTimer = setTimeout(refreshState, next);
- } catch (err) {
- console.error('refreshState failed', err);
- pollTimer = setTimeout(refreshState, 5000);
- }
- }
- refreshState();
-
- // ─── message flow SSE ───────────────────────────────────────────────────
- (() => {
- const flow = $('msgflow');
- if (!flow) return;
- flow.innerHTML = '';
- const es = new EventSource('/messages/stream');
- const MAX_ROWS = 200;
- const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
- es.onmessage = (e) => {
- let m;
- try { m = JSON.parse(e.data); } catch { return; }
- const row = document.createElement('div');
- row.className = 'msgrow ' + m.kind;
- const kind = m.kind === 'sent' ? '→' : '✓';
- row.innerHTML =
- '' + tsFmt(m.at) + '' +
- '' + kind + '' +
- '' + esc(m.from) + '' +
- '→' +
- '' + esc(m.to) + '' +
- '' + esc(m.body) + '';
- flow.insertBefore(row, flow.firstChild);
- while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
- };
- es.onerror = () => {
- flow.insertBefore(Object.assign(document.createElement('div'), {
- className: 'msgrow meta', textContent: '[connection lost — retrying]',
- }), flow.firstChild);
- };
- })();
-})();
diff --git a/hive-c0re/assets/async_forms.js b/hive-c0re/assets/async_forms.js
new file mode 100644
index 00000000..f063b7c4
--- /dev/null
+++ b/hive-c0re/assets/async_forms.js
@@ -0,0 +1,41 @@
+// Generic async submit + spinner for any `