dashboard: per-container journald viewer

new GET /api/journal/{name}?unit=&lines= shells out journalctl -M
<container> -b --no-pager --output=short-iso --lines=<N> (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 · <container>' 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.
This commit is contained in:
müde 2026-05-15 20:42:56 +02:00
parent 79a46f359a
commit 0385d96bf3
4 changed files with 173 additions and 11 deletions

View file

@ -53,6 +53,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<String>,
/// Number of trailing lines to return. Capped at 5000.
#[serde(default)]
lines: Option<u32>,
}
/// Shell out to `journalctl -M <container> -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<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
) -> 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<AppState>,
AxumPath(name): AxumPath<String>,