From 91c78d626fe529969f66949d9756ecc27714a445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 21:46:25 +0200 Subject: [PATCH] dashboard: per-container applied agent.nix viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new GET /api/agent-config/{name} returns the contents of /var/lib/hyperhive/applied//agent.nix — the file the container actually builds against. validated against the live container list to avoid arbitrary filesystem reads. frontend mirrors the journald viewer: collapsed
on each container row, lazy-fetches on expand, refresh button re-fetches. restore-keyed (agent-config:) so it survives the dashboard heartbeat refresh. read-only — mutating the applied config goes through the existing request_apply_commit + operator approval flow. --- TODO.md | 9 -------- hive-c0re/assets/app.js | 45 ++++++++++++++++++++++++++++++++++++++ hive-c0re/src/dashboard.rs | 25 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index da96b5f..28ff89e 100644 --- a/TODO.md +++ b/TODO.md @@ -44,15 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Dashboard: show per-agent applied config.** Surface - `/var/lib/hyperhive/applied//agent.nix` (the file the - container actually builds from) as a collapsible `
` - block on each container row, alongside the journald viewer. - Backend: new `GET /api/agent-config/{name}` returns the file - contents (text/plain). Frontend: lazy-fetch on expand, render - inside a `
` with the same theming as the journal panel.
-  Useful for spot-checking what `request_apply_commit` produced
-  without ssh-ing in.
 - **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
   the harness. Pairs well with the unprivileged-container work — would let
   the operator drop into the container without `nixos-container root-login`.
diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js
index 0a7a52b..5c97489 100644
--- a/hive-c0re/assets/app.js
+++ b/hive-c0re/assets/app.js
@@ -290,6 +290,9 @@
       // 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));
+      // Per-container applied config viewer. Shows the agent.nix
+      // the container is actually built against.
+      li.append(buildConfigDetails(c.name));
 
       ul.append(li);
     }
@@ -348,6 +351,48 @@
     return details;
   }
 
+  // Per-container applied-config viewer. Lazy-fetches on expand;
+  // refresh button re-fetches. Read-only — the file is hive-c0re's
+  // applied repo, mutated only via the approval flow.
+  function buildConfigDetails(agentName) {
+    const details = el('details', {
+      class: 'journal',
+      'data-restore-key': 'agent-config:' + agentName,
+    });
+    const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
+    const body = el('div', { class: 'journal-body' });
+    const controls = el('div', { class: 'journal-controls' });
+    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 fetchConfig() {
+      if (fetching) return;
+      fetching = true;
+      pre.textContent = 'fetching…';
+      try {
+        const resp = await fetch('/api/agent-config/' + agentName);
+        const text = await resp.text();
+        if (!resp.ok) {
+          pre.textContent = 'error: ' + resp.status + '\n' + text;
+        } else {
+          pre.textContent = text || '(empty)';
+          pre.scrollTop = 0;
+        }
+      } catch (err) {
+        pre.textContent = 'fetch failed: ' + err;
+      } finally {
+        fetching = false;
+      }
+    }
+    details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
+    refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
+    controls.append(refresh);
+    body.append(controls, pre);
+    details.append(summary, body);
+    return details;
+  }
+
   function renderTombstones(s) {
     const root = $('tombstones-section');
     root.innerHTML = '';
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index ddf700d..46657a7 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -54,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> {
         .route("/cancel-question/{id}", post(post_cancel_question))
         .route("/purge-tombstone/{name}", post(post_purge_tombstone))
         .route("/api/journal/{name}", get(get_journal))
+        .route("/api/agent-config/{name}", get(get_agent_config))
         .route("/request-spawn", post(post_request_spawn))
         .route("/messages/stream", get(messages_stream))
         .with_state(AppState { coord });
@@ -548,6 +549,30 @@ async fn get_journal(
     }
 }
 
+/// Show the current `agent.nix` from the applied repo — the file
+/// the container actually builds against. Read-only; the manager
+/// can't influence what this returns (that path goes through the
+/// approval queue).
+async fn get_agent_config(AxumPath(name): AxumPath) -> Response {
+    let logical = strip_container_prefix(&name);
+    // Constrain to managed containers — same shape as the journal
+    // endpoint, prevents arbitrary filesystem reads.
+    let live = lifecycle::list().await.unwrap_or_default();
+    let prefixed = if logical == lifecycle::MANAGER_NAME {
+        logical.clone()
+    } else {
+        format!("{}{logical}", lifecycle::AGENT_PREFIX)
+    };
+    if !live.iter().any(|c| c == &prefixed) {
+        return error_response(&format!("agent-config: no managed container {prefixed:?}"));
+    }
+    let path = Coordinator::agent_applied_dir(&logical).join("agent.nix");
+    match std::fs::read_to_string(&path) {
+        Ok(body) => ([("content-type", "text/plain; charset=utf-8")], body).into_response(),
+        Err(e) => error_response(&format!("read {}: {e}", path.display())),
+    }
+}
+
 async fn post_purge_tombstone(
     State(state): State,
     AxumPath(name): AxumPath,