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:
parent
79a46f359a
commit
0385d96bf3
4 changed files with 173 additions and 11 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue