From 0385d96bf3ff58da76156911cb771efd8810b1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:42:56 +0200 Subject: [PATCH] dashboard: per-container journald viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new GET /api/journal/{name}?unit=&lines= shells out journalctl -M -b --no-pager --output=short-iso --lines= (cap 5000). optional unit filter, restricted to hive-ag3nt.service / hive-m1nd.service so the shell-out can't be coerced into reading unrelated units. validates the container name against the live list before invoking journalctl. frontend renders a collapsed '↳ logs · ' details block on each container row. expanding triggers a lazy fetch; refresh button re-fetches; unit dropdown switches between the harness service (default) and the full machine journal. output sits in a 24em-tall monospace pre, auto-scrolled to the bottom on fresh fetch. hive-c0re's systemd unit already runs as root, so journalctl has the access it needs. --- TODO.md | 11 ------ hive-c0re/assets/app.js | 55 ++++++++++++++++++++++++++ hive-c0re/assets/dashboard.css | 47 ++++++++++++++++++++++ hive-c0re/src/dashboard.rs | 71 ++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index a84af5f..e545ba6 100644 --- a/TODO.md +++ b/TODO.md @@ -107,17 +107,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Lifecycle / reliability -- **journald viewer per container in the dashboard.** Surface the - equivalent of `journalctl -M h-coder -b` in the dashboard so the - operator can see container logs without ssh-ing in. Optional - filter by hive-specific systemd unit (`hive-ag3nt.service`, - `hive-m1nd.service`). Implementation: backend shells out to - `journalctl -M -b --output=short-iso --no-pager` - (optionally `-u `), streams or paginates the result over a - new dashboard endpoint. Could be a `
` per container row - or a dedicated page. Honest journalctl, not the in-container - events stream — those are different surfaces (events = claude turn - loop; journalctl = systemd-wide logs incl. boot, network, etc.). - **Container crash events.** Watch `container@*.service` via D-Bus, push `HelperEvent::ContainerCrash` to the manager's inbox so the manager can react (restart, escalate, etc.). diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 61ee03e..3f9aff8 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -161,11 +161,66 @@ } 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 = ''; diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index b48cac3..d1a1841 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -143,6 +143,53 @@ a:hover { opacity: 0.85; } .container-row.tombstone .name { color: var(--muted); } +/* Per-container journald viewer: collapsed by default, fetches + lazily on expand. The output is in monospace inside a bordered +
; controls (unit select + refresh) sit above. */
+.journal {
+  margin-top: 0.5em;
+  font-size: 0.85em;
+}
+.journal > summary {
+  cursor: pointer;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+}
+.journal > summary:hover { color: var(--cyan); }
+.journal .journal-body {
+  margin-top: 0.4em;
+  padding-top: 0.4em;
+  border-top: 1px dashed var(--border);
+}
+.journal-controls {
+  display: flex;
+  gap: 0.5em;
+  margin-bottom: 0.4em;
+  align-items: center;
+}
+.journal-unit {
+  font-family: inherit;
+  font-size: 0.9em;
+  background: var(--bg-elev);
+  color: var(--fg);
+  border: 1px solid var(--border);
+  padding: 0.2em 0.4em;
+}
+.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
+.journal-output {
+  margin: 0;
+  background: #11111b;
+  color: var(--fg);
+  border: 1px solid var(--purple-dim);
+  padding: 0.5em 0.7em;
+  max-height: 24em;
+  overflow: auto;
+  font-size: 0.85em;
+  line-height: 1.4;
+  white-space: pre;
+  word-break: normal;
+}
+
 .pending-state {
   color: var(--amber);
   font-size: 0.85em;
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 1165e99..6430d7c 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -53,6 +53,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> {
         .route("/answer-question/{id}", post(post_answer_question))
         .route("/cancel-question/{id}", post(post_cancel_question))
         .route("/purge-tombstone/{name}", post(post_purge_tombstone))
+        .route("/api/journal/{name}", get(get_journal))
         .route("/request-spawn", post(post_request_spawn))
         .route("/messages/stream", get(messages_stream))
         .with_state(AppState { coord });
@@ -467,6 +468,76 @@ async fn post_cancel_question(
     }
 }
 
+#[derive(Deserialize)]
+struct JournalQuery {
+    /// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When
+    /// omitted, returns the full machine journal.
+    #[serde(default)]
+    unit: Option,
+    /// Number of trailing lines to return. Capped at 5000.
+    #[serde(default)]
+    lines: Option,
+}
+
+/// Shell out to `journalctl -M  -b` and return its text
+/// output. Operator-only by virtue of the dashboard being host-bound;
+/// hive-c0re already runs as root in its systemd unit so journalctl
+/// has the access it needs.
+async fn get_journal(
+    AxumPath(name): AxumPath,
+    axum::extract::Query(q): axum::extract::Query,
+) -> Response {
+    // Validate the container name against the list of managed
+    // containers so we don't shell out with arbitrary input.
+    let container = strip_container_prefix(&name);
+    let prefixed = if container == lifecycle::MANAGER_NAME {
+        container.clone()
+    } else {
+        format!("{}{container}", lifecycle::AGENT_PREFIX)
+    };
+    let live = lifecycle::list().await.unwrap_or_default();
+    if !live.iter().any(|c| c == &prefixed) {
+        return error_response(&format!("journal: no managed container {prefixed:?}"));
+    }
+    let lines = q.lines.unwrap_or(500).min(5000);
+    let mut cmd = tokio::process::Command::new("journalctl");
+    cmd.args([
+        "-M",
+        &prefixed,
+        "-b",
+        "--no-pager",
+        "--output=short-iso",
+        "--lines",
+    ])
+    .arg(lines.to_string());
+    if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
+        // accept hive-ag3nt[.service] / hive-m1nd[.service] — anything
+        // else we refuse, again to keep the shell-out tight.
+        let allowed = ["hive-ag3nt.service", "hive-m1nd.service"];
+        let unit = if u.ends_with(".service") {
+            u.to_owned()
+        } else {
+            format!("{u}.service")
+        };
+        if !allowed.contains(&unit.as_str()) {
+            return error_response(&format!("journal: unknown unit {unit:?}"));
+        }
+        cmd.args(["-u", &unit]);
+    }
+    match cmd.output().await {
+        Ok(out) => {
+            // Combine stdout + stderr — journalctl emits to both on errors.
+            let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
+            if !out.status.success() {
+                body.push_str("\n--- stderr ---\n");
+                body.push_str(&String::from_utf8_lossy(&out.stderr));
+            }
+            ([("content-type", "text/plain; charset=utf-8")], body).into_response()
+        }
+        Err(e) => error_response(&format!("journalctl spawn: {e}")),
+    }
+}
+
 async fn post_purge_tombstone(
     State(state): State,
     AxumPath(name): AxumPath,