From 6fc9862c3cc23f503fb13d9246a385092e5d6e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 17:10:57 +0200 Subject: [PATCH] =?UTF-8?q?dashboard:=20SPA=20shell=20=E2=80=94=20static?= =?UTF-8?q?=20index.html=20+=20app.js,=20/api/state=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-c0re/assets/app.js | 268 +++++++++++++++++++++++ hive-c0re/assets/async_forms.js | 41 ---- hive-c0re/assets/index.html | 37 ++++ hive-c0re/assets/msg_flow.js | 30 --- hive-c0re/src/dashboard.rs | 368 ++++++++++++++------------------ 5 files changed, 464 insertions(+), 280 deletions(-) create mode 100644 hive-c0re/assets/app.js delete mode 100644 hive-c0re/assets/async_forms.js create mode 100644 hive-c0re/assets/index.html delete mode 100644 hive-c0re/assets/msg_flow.js diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js new file mode 100644 index 0000000..6af4e96 --- /dev/null +++ b/hive-c0re/assets/app.js @@ -0,0 +1,268 @@ +// 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
deleted file mode 100644
index f063b7c..0000000
--- a/hive-c0re/assets/async_forms.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// Generic async submit + spinner for any `
`. -// Replaces the standard form-POST navigation: button shows a spinner during -// the request, `data-confirm` runs first (skips the action if cancelled), -// page reloads on success so the new state is reflected. -(() => { - document.querySelectorAll('form[data-async]').forEach(form => { - form.addEventListener('submit', async (e) => { - e.preventDefault(); - if (form.dataset.confirm && !confirm(form.dataset.confirm)) return; - const btn = form.querySelector('button[type="submit"], button:not([type]), .btn-inline'); - const original = btn ? btn.innerHTML : ''; - if (btn) { - btn.disabled = true; - btn.innerHTML = ''; - } - try { - // axum's `Form` extractor wants application/x-www-form-urlencoded; - // FormData would send multipart/form-data and bounce with 415. - const resp = await fetch(form.action, { - method: form.method || 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(new FormData(form)), - 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; - } - window.location.reload(); - } catch (err) { - alert('action failed: ' + err); - if (btn) { btn.disabled = false; btn.innerHTML = original; } - } - }); - }); -})(); diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html new file mode 100644 index 0000000..501117f --- /dev/null +++ b/hive-c0re/assets/index.html @@ -0,0 +1,37 @@ + + + + + hyperhive // h1ve-c0re + + + + + +

◆ C0NTAINERS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ P3NDING APPR0VALS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ MESS4GE FL0W ◆

+
══════════════════════════════════════════════════════════════
+

live tail — newest at the top. tap on every send / recv through the broker.

+
connecting…
+ +
+
══════════════════════════════════════════════════════════════
+

▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲

+
+ + + + diff --git a/hive-c0re/assets/msg_flow.js b/hive-c0re/assets/msg_flow.js deleted file mode 100644 index b793073..0000000 --- a/hive-c0re/assets/msg_flow.js +++ /dev/null @@ -1,30 +0,0 @@ -(() => { - const flow = document.getElementById('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); - const esc = (s) => s.replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c])); - 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/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8afafd9..0a1918e 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -21,7 +21,7 @@ use axum::{ routing::{get, post}, }; use hive_sh4re::Approval; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -38,7 +38,10 @@ struct AppState { pub async fn serve(port: u16, coord: Arc) -> Result<()> { let app = Router::new() - .route("/", get(index)) + .route("/", get(serve_index)) + .route("/static/dashboard.css", get(serve_css)) + .route("/static/app.js", get(serve_app_js)) + .route("/api/state", get(api_state)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) @@ -58,46 +61,170 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { Ok(()) } -async fn index(headers: HeaderMap, State(state): State) -> Html { +// --------------------------------------------------------------------------- +// Static asset handlers: the dashboard is an SPA. `GET /` returns the +// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state` +// returns the current snapshot as JSON. The JS app fetches state on load, +// re-fetches after every async-form submit, and listens on +// `/messages/stream` for broker traffic. +// --------------------------------------------------------------------------- + +async fn serve_index() -> impl IntoResponse { + Html(include_str!("../assets/index.html")) +} + +async fn serve_css() -> impl IntoResponse { + ([("content-type", "text/css")], include_str!("../assets/dashboard.css")) +} + +async fn serve_app_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript")], + include_str!("../assets/app.js"), + ) +} + +#[derive(Serialize)] +struct StateSnapshot { + hostname: String, + manager_port: u16, + any_stale: bool, + containers: Vec, + transients: Vec, + approvals: Vec, +} + +#[derive(Serialize)] +#[allow(clippy::struct_excessive_bools)] +struct ContainerView { + /// Logical agent name (no `h-` prefix). Used in action URLs. + name: String, + /// Container name as nixos-container sees it (`h-foo`, `hm1nd`). + container: String, + is_manager: bool, + port: u16, + running: bool, + needs_update: bool, + needs_login: bool, +} + +#[derive(Serialize)] +struct TransientView { + name: String, + kind: &'static str, + secs: u64, +} + +#[derive(Serialize)] +struct ApprovalView { + id: i64, + agent: String, + kind: &'static str, + /// First 12 chars of the `commit_ref`, for `ApplyCommit` only. + sha_short: Option, + /// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only. + diff_html: Option, +} + +async fn api_state( + headers: HeaderMap, + State(state): State, +) -> axum::Json { let host = headers .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("localhost"); let hostname = host.split(':').next().unwrap_or(host).to_owned(); - let containers = lifecycle::list().await.unwrap_or_default(); - let transient = state.coord.transient_snapshot(); + let raw_containers = lifecycle::list().await.unwrap_or_default(); let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); - let mut running: std::collections::HashMap = - std::collections::HashMap::new(); - for c in &containers { - let logical = c - .strip_prefix(lifecycle::AGENT_PREFIX) - .unwrap_or(c.as_str()) - .to_owned(); - running.insert(c.clone(), lifecycle::is_running(&logical).await); - } + let transient_snapshot = state.coord.transient_snapshot(); let approvals = gc_orphans( &state.coord, state.coord.approvals.pending().unwrap_or_default(), ); - let approvals_html = render_approvals(&approvals).await; - // Auto-refresh the dashboard root while there's a spawn in flight, so the - // operator sees the new agent show up in the container list without - // having to reload manually. 2s is a reasonable poll interval for - // nixos-container create + start, which usually finishes in <30s. - let refresh = if transient.is_empty() { - String::new() - } else { - "".to_owned() - }; + let mut containers = Vec::new(); + let mut any_stale = false; + for c in &raw_containers { + let (logical, is_manager) = if c == MANAGER_NAME { + (MANAGER_NAME.to_owned(), true) + } else if let Some(n) = c.strip_prefix(AGENT_PREFIX) { + (n.to_owned(), false) + } else { + continue; + }; + let needs_update = current_rev + .as_deref() + .is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev)); + if needs_update { + any_stale = true; + } + let needs_login = if is_manager { + false + } else { + !claude_has_session(&Coordinator::agent_claude_dir(&logical)) + }; + containers.push(ContainerView { + port: lifecycle::agent_web_port(&logical), + running: lifecycle::is_running(&logical).await, + container: c.clone(), + name: logical, + is_manager, + needs_update, + needs_login, + }); + } - Html(format!( - "\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n\n\n", - containers = - render_containers(&containers, &running, &transient, current_rev.as_deref(), &hostname), - )) + let transients = transient_snapshot + .into_iter() + .filter(|(name, _)| { + !raw_containers + .iter() + .any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == name) + }) + .map(|(name, st)| TransientView { + name, + kind: match st.kind { + crate::coordinator::TransientKind::Spawning => "spawning", + }, + secs: st.since.elapsed().as_secs(), + }) + .collect(); + + let mut approval_views = Vec::with_capacity(approvals.len()); + for a in approvals { + let view = match a.kind { + hive_sh4re::ApprovalKind::ApplyCommit => { + let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned(); + let diff = approval_diff(&a.agent, &a.commit_ref).await; + ApprovalView { + id: a.id, + agent: a.agent, + kind: "apply_commit", + sha_short: Some(sha), + diff_html: Some(render_diff_lines(&diff)), + } + } + hive_sh4re::ApprovalKind::Spawn => ApprovalView { + id: a.id, + agent: a.agent, + kind: "spawn", + sha_short: None, + diff_html: None, + }, + }; + approval_views.push(view); + } + + axum::Json(StateSnapshot { + hostname, + manager_port: MANAGER_PORT, + any_stale, + containers, + transients, + approvals: approval_views, + }) } async fn messages_stream( @@ -236,140 +363,12 @@ async fn post_destroy(State(state): State, AxumPath(name): AxumPath Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Html(format!( - "\n\n{STYLE}\n{BANNER}\n

◆ ERR0R ◆

\n
{message}
\n

← back

\n", - message = html_escape(message), - )), - ) - .into_response() + // Plain text — the JS app surfaces this in an alert(), so HTML + // wrapping would just clutter the message. + (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() } -fn render_containers( - containers: &[String], - running: &std::collections::HashMap, - transient: &std::collections::HashMap, - current_rev: Option<&str>, - hostname: &str, -) -> String { - let mut out = String::from( - "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", - ); - // "update all" header button only when at least one container is stale. - if let Some(rev) = current_rev { - let any_stale = containers.iter().any(|c| { - let logical = c.strip_prefix(AGENT_PREFIX).unwrap_or(c); - crate::auto_update::agent_needs_update(logical, rev) - }); - if any_stale { - out.push_str("\n"); - } - } - out.push_str("
\n \n \n
\n

spawn requests queue as approvals. operator approves below to actually create the container.

\n"); - // Render in-flight spawns first so the operator sees feedback immediately. - if !transient.is_empty() { - out.push_str("
    \n"); - for (name, state) in transient { - // Skip names that already exist in `containers` (race: spawn finished - // between transient set and list refresh). - if containers.iter().any(|c| c == &format!("h-{name}")) { - continue; - } - let secs = state.since.elapsed().as_secs(); - let label = match state.kind { - crate::coordinator::TransientKind::Spawning => "spawning…", - }; - let _ = writeln!( - out, - "
  • {name} {label} nixos-container create + start ({secs}s)
  • ", - ); - } - out.push_str("
\n"); - } - if containers.is_empty() && transient.is_empty() { - out.push_str("

▓ no managed containers ▓

\n"); - return out; - } - out.push_str("
    \n"); - for container in containers { - let is_running = running.get(container).copied().unwrap_or(false); - if container == MANAGER_NAME { - let update_badge = update_badge_for(MANAGER_NAME, current_rev); - let restart_btn = if is_running { - format!( - "
    \n", - ) - } else { - String::new() - }; - let _ = writeln!( - out, - "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}\n{restart_btn}
    \n
  • ", - ); - } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { - let port = lifecycle::agent_web_port(name); - let claude_dir = Coordinator::agent_claude_dir(name); - let login_badge = if claude_has_session(&claude_dir) { - String::new() - } else { - format!( - " needs login →", - ) - }; - let update_badge = update_badge_for(name, current_rev); - let running_buttons = if is_running { - format!( - "
    \n
    \n", - ) - } else { - String::new() - }; - let _ = writeln!( - out, - "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n{running_buttons}
    \n
    \n
  • ", - ); - } - } - out.push_str("
\n"); - out -} -async fn render_approvals(approvals: &[Approval]) -> String { - let mut out = String::from( - "

◆ P3NDING APPR0VALS ◆

\n
══════════════════════════════════════════════════════════════
\n", - ); - if approvals.is_empty() { - out.push_str("

▓ queue empty ▓

\n"); - return out; - } - out.push_str("
    \n"); - for a in approvals { - match a.kind { - hive_sh4re::ApprovalKind::ApplyCommit => { - let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; - let diff = approval_diff(&a.agent, &a.commit_ref).await; - let diff_html = render_diff_lines(&diff); - let _ = writeln!( - out, - "
  • \n
    #{id} {agent} apply {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff_html}
    \n
  • ", - id = a.id, - agent = a.agent, - ); - } - hive_sh4re::ApprovalKind::Spawn => { - let _ = writeln!( - out, - "
  • \n
    #{id} {agent} spawn new sub-agent — container will be created on approve\n
    \n
    \n
    \n
  • ", - id = a.id, - agent = a.agent, - ); - } - } - } - out.push_str("
\n"); - out -} /// Filter out approvals whose agent state dir was wiped out from under us /// (e.g. by a test script's cleanup). Marks them failed so they fall out of @@ -417,20 +416,6 @@ fn render_diff_lines(diff: &str) -> String { out } -/// Returns either an empty string (agent is up-to-date / no rev known) or -/// a clickable "needs update" badge whose form POSTs to /rebuild/. -fn update_badge_for(name: &str, current_rev: Option<&str>) -> String { - let Some(rev) = current_rev else { - return String::new(); - }; - if !crate::auto_update::agent_needs_update(name, rev) { - return String::new(); - } - format!( - "
", - ) -} - /// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the /// agent's bound `~/.claude/` dir on disk contains any regular file. The /// dashboard reads this each render so logins driven from the agent web UI @@ -499,38 +484,3 @@ fn html_escape(s: &str) -> String { .replace('>', ">") } -const BANNER: &str = r#""#; - -/// Generic async submit + spinner for any `
`. Replaces -/// the standard form-POST navigation: button shows a spinner during the -/// request, `data-confirm` runs first (skips the action if cancelled), -/// page reloads on success so the new state is reflected. -const ASYNC_FORMS_JS: &str = concat!( - "", -); - -const MSG_FLOW: &str = r#"

◆ MESS4GE FL0W ◆

-
══════════════════════════════════════════════════════════════
-

live tail — newest at the top. tap on every send / recv through the broker.

-
connecting…
"#; - -const MSG_FLOW_JS: &str = concat!( - "", -); - -const FOOTER: &str = r#"
-
══════════════════════════════════════════════════════════════
-

▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲

-
"#; - -const STYLE: &str = concat!( - "", -);