// 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; }; // ─── browser notifications ────────────────────────────────────────────── // Fires OS notifications on three operator-bound signals: // - new approval landed in the queue // - new operator question queued (ask_operator) // - broker message sent `to: "operator"` // permission grant is per-browser; a localStorage "muted" toggle lets // the operator silence without revoking. Secure-context only (HTTPS / // localhost) — on other origins the API is unavailable and we hide // the controls. const NOTIF = (() => { const supported = typeof Notification !== 'undefined'; const MUTED_KEY = 'hyperhive.notify.muted'; const isMuted = () => localStorage.getItem(MUTED_KEY) === '1'; const setMuted = (v) => v ? localStorage.setItem(MUTED_KEY, '1') : localStorage.removeItem(MUTED_KEY); function renderControls() { const enable = $('notif-enable'); const mute = $('notif-mute'); const unmute = $('notif-unmute'); const status = $('notif-status'); if (!enable || !mute || !unmute || !status) return; if (!supported) { enable.hidden = mute.hidden = unmute.hidden = true; status.hidden = false; status.textContent = 'notifications unsupported in this browser'; return; } const perm = Notification.permission; enable.hidden = perm === 'granted'; mute.hidden = perm !== 'granted' || isMuted(); unmute.hidden = perm !== 'granted' || !isMuted(); status.hidden = perm !== 'denied'; if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings'; } function bind() { const enable = $('notif-enable'); const mute = $('notif-mute'); const unmute = $('notif-unmute'); if (!supported || !enable || !mute || !unmute) return; enable.addEventListener('click', async () => { await Notification.requestPermission(); renderControls(); }); mute.addEventListener('click', () => { setMuted(true); renderControls(); }); unmute.addEventListener('click', () => { setMuted(false); renderControls(); }); renderControls(); } function show(title, body) { if (!supported || Notification.permission !== 'granted' || isMuted()) return; try { const n = new Notification(title, { body, tag: 'hyperhive', // collapse rapid bursts icon: '/static/dashboard.css', // any same-origin asset works as a favicon stand-in }); n.onclick = () => { window.focus(); n.close(); }; } catch (err) { console.warn('notification show failed', err); } } return { bind, show, renderControls }; })(); // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads. const seenApprovals = new Set(); const seenQuestions = new Set(); const seenInboxIds = new Set(); let seededNotify = false; function notifyDeltas(s) { const approvals = s.approvals || []; const questions = s.questions || []; const inbox = s.operator_inbox || []; if (!seededNotify) { // First render after page load — fill the "seen" sets without // firing notifications. We only want to notify on NEW items // that arrived while the page is open. for (const a of approvals) seenApprovals.add(a.id); for (const q of questions) seenQuestions.add(q.id); for (const m of inbox) seenInboxIds.add(m.id); seededNotify = true; return; } for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`); } for (const q of questions) { if (seenQuestions.has(q.id)) continue; seenQuestions.add(q.id); NOTIF.show('◆ manager asks', q.question.slice(0, 120)); } // operator_inbox: only notify on truly new ids — sse already // handles single-message notifications, but if the operator // missed an SSE event (page reloaded), this catches up. for (const m of inbox) { if (seenInboxIds.has(m.id)) continue; seenInboxIds.add(m.id); // suppress here; SSE path handles the live notification. } } // ─── 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; } // Re-enable the button — refreshState() rebuilds most lists but // skips forms that didn't change (e.g. the spawn form), so without // this the spinner sticks and the button can't be clicked again. if (btn) { btn.disabled = false; btn.innerHTML = original; } // Clear text inputs whose value was just submitted. f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); 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?', )); } 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', { class: 'containers' }); for (const c of s.containers) { const url = `http://${s.hostname}:${c.port}/`; const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') }); // ── line 1: identity ───────────────────────────────────────── const head = el('div', { class: 'head' }); head.append( el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); if (c.pending) { head.append(el('span', { class: 'pending-state' }, el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); } else if (c.needs_login) { head.append(el('a', { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, 'needs login →')); } if (c.needs_update) { head.append(form( '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', )); } head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); li.append(head); // ── line 2: action buttons ─────────────────────────────────── const actions = el('div', { class: 'actions' }); if (c.running) { actions.append( form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), ); if (!c.is_manager) { actions.append( form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), ); } } else { actions.append( form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'), ); } actions.append( form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', 'rebuild ' + c.name + '? hot-reloads the container.'), ); if (!c.is_manager) { actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', 'destroy ' + c.name + '? container is removed; state + creds kept.'), form('/destroy/' + c.name, 'btn-destroy', 'PURG3', 'PURGE ' + c.name + '? container, config history, claude creds, ' + 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }), ); } li.append(actions); // Per-container journald viewer. Expand to fetch + render the // last N lines; refresh button re-fetches; unit selector // narrows to the harness service (or empty = full machine). const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; li.append(buildJournalDetails(c.container, journalUnit)); ul.append(li); } root.append(ul); } // Build the per-container journald
. Lazy-fetches when the // operator expands; refresh re-fetches; unit toggle switches // between the harness service and the full machine journal. function buildJournalDetails(containerName, defaultUnit) { const details = el('details', { class: 'journal' }); const summary = el('summary', {}, '↳ logs · ' + containerName); const body = el('div', { class: 'journal-body' }); const controls = el('div', { class: 'journal-controls' }); const unitSelect = el('select', { class: 'journal-unit' }); unitSelect.append( el('option', { value: defaultUnit }, defaultUnit), el('option', { value: '' }, '(full machine journal)'), ); const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, '↻ refresh'); const pre = el('pre', { class: 'journal-output' }, 'fetching…'); let fetching = false; async function fetchLogs() { if (fetching) return; fetching = true; pre.textContent = 'fetching…'; const unit = unitSelect.value; const params = new URLSearchParams({ lines: '500' }); if (unit) params.set('unit', unit); try { const resp = await fetch('/api/journal/' + containerName + '?' + params); const text = await resp.text(); if (!resp.ok) { pre.textContent = 'error: ' + resp.status + '\n' + text; } else { pre.textContent = text || '(empty)'; // Auto-scroll to bottom on fresh fetch. pre.scrollTop = pre.scrollHeight; } } catch (err) { pre.textContent = 'fetch failed: ' + err; } finally { fetching = false; } } details.addEventListener('toggle', () => { if (details.open) fetchLogs(); }); refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); }); unitSelect.addEventListener('change', fetchLogs); controls.append(unitSelect, refresh); body.append(controls, pre); details.append(summary, body); return details; } function renderTombstones(s) { const root = $('tombstones-section'); root.innerHTML = ''; if (!s.tombstones || !s.tombstones.length) { root.append(el('p', { class: 'empty' }, 'no kept state — clean')); return; } const fmtBytes = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; const fmtAge = (ts) => { if (!ts) return '?'; const d = Math.floor((Date.now() / 1000 - ts) / 86400); if (d <= 0) return 'today'; if (d === 1) return '1 day ago'; return d + ' days ago'; }; const ul = el('ul', { class: 'containers' }); for (const t of s.tombstones) { const li = el('li', { class: 'container-row tombstone' }); const head = el('div', { class: 'head' }); head.append( el('span', { class: 'name' }, t.name), el('span', { class: 'badge badge-muted' }, 'destroyed'), ); if (t.has_creds) { head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); } head.append(el('span', { class: 'meta' }, `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`)); li.append(head); const actions = el('div', { class: 'actions' }); // Reuse the existing spawn form pattern via /request-spawn — operator // can queue an approval that recreates the agent with the same name // and reuses the kept state. const respawn = el('form', { method: 'POST', action: '/request-spawn', class: 'inline', 'data-async': '', 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.', }); respawn.append( el('input', { type: 'hidden', name: 'name', value: t.name }), el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'), ); actions.append(respawn); actions.append(form( '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', 'PURGE ' + t.name + '? config history, claude creds, /state/ notes ' + 'are all WIPED. no undo.', )); li.append(actions); ul.append(li); } root.append(ul); } function renderQuestions(s) { const root = $('questions-section'); root.innerHTML = ''; if (!s.questions || !s.questions.length) { root.append(el('p', { class: 'empty' }, 'no pending questions')); return; } const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'questions' }); for (const q of s.questions) { const li = el('li', { class: 'question' }); const head = el('div', { class: 'q-head' }, el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', el('span', { class: 'msg-from' }, q.asker), ' ', el('span', { class: 'msg-sep' }, 'asks:'), ); if (q.deadline_at) { const remaining = q.deadline_at - Math.floor(Date.now() / 1000); let txt; if (remaining <= 0) txt = 'expiring…'; else if (remaining < 60) txt = '⏳ ' + remaining + 's'; else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm ' + (remaining % 60) + 's'; else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h ' + Math.floor((remaining % 3600) / 60) + 'm'; head.append(' ', el('span', { class: 'q-ttl' }, txt)); } li.append(head, el('div', { class: 'q-body' }, q.question)); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', }); const hasOptions = q.options && q.options.length; const isMulti = !!q.multi && hasOptions; const freeText = el('input', { type: 'text', name: 'answer-free', placeholder: hasOptions ? 'or type your own…' : 'your answer', autocomplete: 'off', }); const optionGroup = el('div', { class: 'q-options' }); if (hasOptions) { for (const opt of q.options) { const inputType = isMulti ? 'checkbox' : 'radio'; const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); const input = el('input', { type: inputType, name: 'choice', value: opt, id }); const label = el('label', { for: id }, ' ' + opt); optionGroup.append(el('div', { class: 'q-option' }, input, label)); } } // On submit, build the final `answer` field from selected // options + free-text, joined by ', '. This lets the operator // pick options AND add free text in the same form. f.addEventListener('submit', (ev) => { const parts = []; for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { parts.push(cb.value); } const ft = (freeText.value || '').trim(); if (ft) parts.push(ft); const merged = parts.join(', '); // Replace the existing hidden `answer` (if any) with the merged value. const existing = f.querySelector('input[name="answer"]'); if (existing) existing.remove(); f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } }, true); if (hasOptions) f.append(optionGroup); const buttons = el('div', { class: 'q-buttons' }); buttons.append( el('button', { type: 'submit', class: 'btn btn-approve' }, isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), ); f.append( el('div', { class: 'q-free' }, freeText), buttons, ); li.append(f); // Separate form so the cancel button doesn't get the answer // merge-on-submit handler attached to the main form. const cancelForm = el('form', { method: 'POST', action: '/cancel-question/' + q.id, class: 'qform-cancel', 'data-async': '', 'data-confirm': 'cancel this question? manager will see ' + '"[cancelled]" as the answer.', }); cancelForm.append( el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), ); li.append(cancelForm); ul.append(li); } root.append(ul); } function renderInbox(s) { const root = $('inbox-section'); root.innerHTML = ''; if (!s.operator_inbox || !s.operator_inbox.length) { root.append(el('p', { class: 'empty' }, 'no messages')); return; } const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const ul = el('ul', { class: 'inbox' }); for (const m of s.operator_inbox) { const li = el('li'); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-sep' }, '→ '), el('span', { class: 'msg-body' }, m.body), ); ul.append(li); } root.append(ul); } function renderApprovals(s) { const root = $('approvals-section'); root.innerHTML = ''; // Spawn request form: submitting it queues a Spawn approval that // lands in this same list, so the form belongs here rather than on // the containers list (the agent doesn't exist yet). 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); 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;
  // Sections whose innerHTML gets blown away on each refresh. If the
  // operator is typing in one of them, skip the refresh — the next
  // tick (or a manual action) will pick it up after they blur.
  const MANAGED_SECTION_IDS = [
    'containers-section',
    'tombstones-section',
    'questions-section',
    'inbox-section',
    'approvals-section',
  ];
  function operatorIsTyping() {
    const el_ = document.activeElement;
    if (!el_ || el_ === document.body) return false;
    const tag = el_.tagName;
    if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
    return MANAGED_SECTION_IDS.some((id) => {
      const sect = document.getElementById(id);
      return sect && sect.contains(el_);
    });
  }
  async function refreshState() {
    // Don't yank the form out from under the operator. Try again
    // shortly on the next tick; eventually they'll blur and the
    // refresh lands.
    if (operatorIsTyping()) {
      if (pollTimer) clearTimeout(pollTimer);
      pollTimer = setTimeout(refreshState, 2000);
      return;
    }
    try {
      const resp = await fetch('/api/state');
      if (!resp.ok) throw new Error('http ' + resp.status);
      const s = await resp.json();
      renderContainers(s);
      renderTombstones(s);
      renderQuestions(s);
      renderInbox(s);
      renderApprovals(s);
      notifyDeltas(s);
      // Auto-refresh: fast (2s) while a spawn or a per-container
      // action is in flight, otherwise heartbeat (5s) so newly-queued
      // approvals from the manager show up without the operator
      // having to reload the page. Broker SSE already triggers a
      // refresh on operator-bound messages; this catches the rest
      // (approvals, tombstones, questions).
      const anyPending = s.containers.some((c) => c.pending);
      const next = (s.transients.length || anyPending) ? 2000 : 5000;
      if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
      if (next) pollTimer = setTimeout(refreshState, next);
    } catch (err) {
      console.error('refreshState failed', err);
      pollTimer = setTimeout(refreshState, 5000);
    }
  }
  refreshState();
  NOTIF.bind();

  // ─── 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);
    // Animate the banner whenever a broker event lands. Each event nudges
    // the shimmer window; if traffic stops, the class falls off after the
    // grace timer.
    const banner = document.querySelector('.banner');
    let bannerOffTimer = null;
    function pulseBanner() {
      if (!banner) return;
      banner.classList.add('active');
      if (bannerOffTimer) clearTimeout(bannerOffTimer);
      bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
    }
    es.onmessage = (e) => {
      let m;
      try { m = JSON.parse(e.data); } catch { return; }
      pulseBanner();
      // Live-update the inbox when claude sends to operator + ping
      // the OS notification center.
      if (m.kind === 'sent' && m.to === 'operator') {
        refreshState();
        NOTIF.show('◆ ' + m.from + ' → operator', String(m.body || '').slice(0, 200));
      }
      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);
    };
  })();
})();