From 66c715842f125581bef1b83683d54dad656af622 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 18 Jul 2026 16:28:31 +0200 Subject: [PATCH] dashboard: add hive infra containers to the logs UI agent selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends GET /api/journal/{name} to also accept the four hive infra container names (hive-ci, hive-forge, hive-gateway, hive-matrix — hive_priv_sock::InfraContainer is the allowlist), reusing the same journalctl -M / hive-priv delegation path already used for agent containers. Infra containers don't run the per-agent hive daemons, so the unit filter is skipped for them — always the full machine journal. Frontend: the AGENT tab's agent selector now lists infra containers in a separate optgroup (sourced from /api/state's existing infra_containers field), and disables the unit-filter select when one is chosen. --- frontend/packages/dashboard/src/logs.js | 34 ++++++++++++++++++-- hive-c0re/src/dashboard/journal.rs | 42 ++++++++++++++++++++----- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index 38608f75..c6807244 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -45,8 +45,15 @@ import { createTabStrip } from '@hive/shared/tabs.js'; return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago'); } - // Populate agent selector from /api/state, then honour any `?agent=` / - // `?unit=` URL params (used by the per-agent ⋮ menu's deep-link). + // Names from `state.infra_containers` (hive-ci / hive-forge / hive-gateway + // / hive-matrix) — kept in sync with the fetched list so fetchAgent() can + // tell an infra container apart from an agent and skip the (inapplicable) + // unit filter for it. + let infraContainerNames = new Set(); + + // Populate agent selector from /api/state (agents, then the four infra + // containers in their own optgroup), then honour any `?agent=` / `?unit=` + // URL params (used by the per-agent ⋮ menu's deep-link). async function loadAgentList() { try { const resp = await fetch('/api/state'); @@ -58,6 +65,14 @@ import { createTabStrip } from '@hive/shared/tabs.js'; for (const c of (state.containers || [])) { agentSelect.append(el('option', { value: c.name }, c.name)); } + infraContainerNames = new Set((state.infra_containers || []).map((c) => c.name)); + if (infraContainerNames.size) { + const infraGroup = el('optgroup', { label: 'infra' }); + for (const name of infraContainerNames) { + infraGroup.append(el('option', { value: name }, name)); + } + agentSelect.append(infraGroup); + } // Deep-link: honour ?agent= and ?unit= URL params. const urlAgent = new URLSearchParams(location.search).get('agent'); const urlUnit = new URLSearchParams(location.search).get('unit'); @@ -70,12 +85,24 @@ import { createTabStrip } from '@hive/shared/tabs.js'; .some((o) => o.value === urlUnit); if (unitFound) agentUnitSelect.value = urlUnit; } + syncUnitSelectForSelection(); fetchAgent(); } } } catch { /**/ } } + // Infra containers don't run the per-agent hive daemons, so the unit + // filter is meaningless for them — disable the selector and always fetch + // the full machine journal to avoid a picked unit silently doing nothing. + function syncUnitSelectForSelection() { + if (!agentUnitSelect) return; + const isInfra = infraContainerNames.has(agentSelect ? agentSelect.value : ''); + agentUnitSelect.disabled = isInfra; + if (isInfra) agentUnitSelect.value = ''; + } + if (agentSelect) agentSelect.addEventListener('change', syncUnitSelectForSelection); + let agentFetching = false; let agentLastFetch = 0; async function fetchAgent() { @@ -86,7 +113,8 @@ import { createTabStrip } from '@hive/shared/tabs.js'; agentFetching = true; agentOutput.textContent = 'fetching…'; if (agentFetchTs) agentFetchTs.hidden = true; - const unit = agentUnitSelect ? agentUnitSelect.value : ''; + const isInfra = infraContainerNames.has(name); + const unit = (!isInfra && agentUnitSelect) ? agentUnitSelect.value : ''; const params = new URLSearchParams({ lines: '500' }); if (unit) params.set('unit', unit); try { diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index f579b19c..5eb739f3 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -1,10 +1,13 @@ //! Journal-read endpoints for the dashboard. //! -//! `GET /api/journal/{name}` reads a managed container's journal via the -//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is -//! unprivileged). `GET /api/journal-host` reads host-side journald, both -//! gated by an allow-list of known units so arbitrary unit names can't be -//! probed. Operator-only by virtue of the dashboard binding host-only. +//! `GET /api/journal/{name}` reads a managed agent container's journal, OR +//! one of the four hive infra containers (`hive-ci`, `hive-forge`, +//! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the +//! allowlist), via the root helper (`journalctl -M`, delegated to hive-priv +//! since hive-c0re is unprivileged). `GET /api/journal-host` reads +//! host-side journald, both gated by an allow-list of known units so +//! arbitrary unit names can't be probed. Operator-only by virtue of the +//! dashboard binding host-only. use axum::{ extract::Path as AxumPath, @@ -33,10 +36,23 @@ pub(super) struct JournalQuery { /// Operator-only by virtue of the dashboard being host-bound. hive-c0re /// runs unprivileged (privsep), so the `-M` read — which enters the /// container namespace and needs root — is delegated to hive-priv. +/// +/// `name` is either a managed agent name (`iris`, optionally already +/// carrying the `h-` prefix) or one of the four infra container names +/// (`hive-ci` / `hive-forge` / `hive-gateway` / `hive-matrix` — see +/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the +/// per-agent hive daemons, so `unit` is ignored for them — always the +/// full machine journal. pub(super) async fn get_journal( AxumPath(name): AxumPath, axum::extract::Query(q): axum::extract::Query, ) -> Result { + let lines = q.lines.unwrap_or(500).min(5000); + + if let Ok(infra) = name.parse::() { + return read_journal_response(infra.unit_name(), None, lines).await; + } + // Defense-in-depth format check so weird chars never reach the // shellout below — the `lifecycle::list()` existence check would // catch them anyway, but rejecting at the boundary keeps the @@ -54,7 +70,6 @@ pub(super) async fn get_journal( return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND) .with_detail(format!("journal: no managed container {prefixed:?}"))); } - let lines = q.lines.unwrap_or(500).min(5000); let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { Some(u) => { // accept any of the per-container hive daemons [.service] — @@ -78,8 +93,21 @@ pub(super) async fn get_journal( } None => None, }; + read_journal_response(&prefixed, unit, lines).await +} + +/// Shared `journalctl -M [-u ]` shellout + response +/// formatting for [`get_journal`], factored out so the infra-container +/// branch (no `unit` filtering) and the agent-container branch (allow-listed +/// `unit` filtering) don't duplicate the priv-client call + stdout/stderr +/// combining. +async fn read_journal_response( + machine: &str, + unit: Option, + lines: u32, +) -> Result { match crate::priv_client::read_container_journal( - &prefixed, + machine, hive_priv_sock::JournalQuery { lines, boot: true,