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 `
`. +// 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/dashboard.css b/hive-c0re/assets/dashboard.css index b5bbaaf2..f2f17415 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -98,8 +98,6 @@ ul form.inline { display: inline-block; } .btn-deny { color: var(--red); border-color: var(--red); } .btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } -.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } -.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } .btn-spawn { color: var(--amber); border-color: var(--amber); } .spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; } diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html deleted file mode 100644 index 501117f8..00000000 --- a/hive-c0re/assets/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - 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 new file mode 100644 index 00000000..b7930737 --- /dev/null +++ b/hive-c0re/assets/msg_flow.js @@ -0,0 +1,30 @@ +(() => { + 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 0a1918ed..30c90ee2 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, Serialize}; +use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -38,17 +38,11 @@ struct AppState { pub async fn serve(port: u16, coord: Arc) -> Result<()> { let app = Router::new() - .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("/", get(index)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) - .route("/kill/{name}", post(post_kill)) - .route("/restart/{name}", post(post_restart)) .route("/rebuild/{name}", post(post_rebuild)) - .route("/update-all", post(post_update_all)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -61,170 +55,36 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// 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 { +async fn index(headers: HeaderMap, State(state): State) -> Html { 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 raw_containers = lifecycle::list().await.unwrap_or_default(); + let containers = lifecycle::list().await.unwrap_or_default(); + let transient = state.coord.transient_snapshot(); let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); - 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; - 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, - }); - } + // 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 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, - }) + 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, &transient, current_rev.as_deref(), &hostname), + )) } async fn messages_stream( @@ -294,67 +154,6 @@ async fn post_rebuild(State(state): State, AxumPath(name): AxumPath, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - if logical == lifecycle::MANAGER_NAME { - return error_response("kill: refusing to stop the manager"); - } - match lifecycle::kill(&logical).await { - Ok(()) => { - state.coord.unregister_agent(&logical); - Redirect::to("/").into_response() - } - Err(e) => error_response(&format!("kill {logical} failed: {e:#}")), - } -} - -async fn post_restart(State(_state): State, AxumPath(name): AxumPath) -> Response { - let logical = strip_container_prefix(&name); - match lifecycle::restart(&logical).await { - Ok(()) => Redirect::to("/").into_response(), - Err(e) => error_response(&format!("restart {logical} failed: {e:#}")), - } -} - -async fn post_update_all(State(state): State) -> Response { - let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake) - else { - return error_response("update-all: hyperhive_flake has no canonical path"); - }; - let containers = lifecycle::list().await.unwrap_or_default(); - let mut errors = Vec::new(); - for container in containers { - let logical = if container == lifecycle::MANAGER_NAME { - lifecycle::MANAGER_NAME.to_owned() - } else if let Some(n) = container.strip_prefix(lifecycle::AGENT_PREFIX) { - n.to_owned() - } else { - continue; - }; - if !crate::auto_update::agent_needs_update(&logical, ¤t_rev) { - continue; - } - if let Err(e) = - crate::auto_update::rebuild_agent(&state.coord, &logical, ¤t_rev).await - { - errors.push(format!("{logical}: {e:#}")); - } - } - if errors.is_empty() { - Redirect::to("/").into_response() - } else { - error_response(&format!("update-all partial failure:\n{}", errors.join("\n"))) - } -} - -/// Convert either a logical name or a container name back to the logical -/// name. Sub-agents are `h-foo` → `foo`; manager stays `hm1nd`. -fn strip_container_prefix(name: &str) -> String { - name.strip_prefix(lifecycle::AGENT_PREFIX) - .unwrap_or(name) - .to_owned() -} - async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { match actions::destroy(&state.coord, &name).await { Ok(()) => Redirect::to("/").into_response(), @@ -363,12 +162,114 @@ async fn post_destroy(State(state): State, AxumPath(name): AxumPath 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() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(format!( + "\n\n{STYLE}\n{BANNER}\n

◆ ERR0R ◆

\n
{message}
\n

← back

\n", + message = html_escape(message), + )), + ) + .into_response() } +fn render_containers( + containers: &[String], + transient: &std::collections::HashMap, + current_rev: Option<&str>, + hostname: &str, +) -> String { + let mut out = String::from( + "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\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 { + if container == MANAGER_NAME { + let update_badge = update_badge_for(MANAGER_NAME, current_rev); + let _ = writeln!( + out, + "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}\n
    \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 _ = writeln!( + out, + "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
    \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 @@ -416,6 +317,20 @@ 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 @@ -484,3 +399,38 @@ 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!( + "", +); diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 6cbef1be..3602119e 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -126,32 +126,6 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } -pub async fn start(name: &str) -> Result<()> { - validate(name)?; - let container = container_name(name); - run(&["start", &container]).await -} - -/// Stop + start without regenerating any config. For "kick the container" -/// without touching the flake or nspawn flags. -pub async fn restart(name: &str) -> Result<()> { - kill(name).await?; - start(name).await -} - -/// True when the container's systemd unit is active. Used by the dashboard -/// to gate stop/restart buttons. -pub async fn is_running(name: &str) -> bool { - let container = container_name(name); - let unit = format!("container@{container}.service"); - Command::new("systemctl") - .args(["is-active", "--quiet", &unit]) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false) -} - /// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// destroy`, then clean our own systemd drop-in. Leaves it to the caller to /// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir.