feat(#986): dedicated logs page with build/agent/system sub-tabs
Add /logs.html as a standalone page (same back-link pattern as flow.html): - BUILD tab: all-agents build log history via new GET /api/build-logs endpoint - AGENT tab: per-container journald viewer with agent selector + unit filter - SYSTEM tab: host-side hive-c0re.service logs via new GET /api/journal-host endpoint Remove inline log drill-ins from SW4RM container rows (buildJournalTrigger and buildBuildLogsTrigger) — log viewing now lives on the dedicated page. flow.html: strip the full dashboard tabbar, replace with a simple back link matching the new logs page chrome. index.html: add L0GS tab link to /logs.html in the tab strip. Backend additions: - build_logs::list_recent_all — cross-agent query (newest first, cap 100) - GET /api/build-logs — all-agents variant backed by list_recent_all - GET /api/journal-host — host journald (no -M container flag), restricted to allow-listed units (hive-c0re.service)
This commit is contained in:
parent
fd87cf9924
commit
0b15cad93f
9 changed files with 534 additions and 299 deletions
|
|
@ -318,6 +318,25 @@ impl BuildLogs {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// Return the most recent `limit` rows across all agents, newest first.
|
||||
/// Same header-only shape as `list_recent_for_agent`. Limit clamped to 100.
|
||||
pub fn list_recent_all(&self, limit: usize) -> Result<Vec<BuildLogHeader>> {
|
||||
let limit = limit.min(100);
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, agent, kind, cmdline, started_at, finished_at, status
|
||||
FROM build_logs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(100)], row_to_header)?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fetch a single full row (with stdout/stderr text) by id.
|
||||
/// Returns `None` when the id doesn't exist (vacuum sweep already
|
||||
/// reaped it, or the operator passed a stale id from a refresh
|
||||
|
|
|
|||
|
|
@ -63,9 +63,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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/journal-host", get(get_journal_host))
|
||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||
.route("/api/state-file", get(get_state_file))
|
||||
.route("/api/reminders", get(api_reminders))
|
||||
.route("/api/build-logs", get(get_build_logs_all))
|
||||
.route("/api/build-logs/{agent}", get(get_build_logs_agent))
|
||||
.route("/api/build-logs/id/{id}", get(get_build_log_full))
|
||||
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
|
||||
|
|
@ -1183,6 +1185,72 @@ async fn get_journal(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JournalHostQuery {
|
||||
/// Service unit name to filter to. If omitted, returns all logs.
|
||||
#[serde(default)]
|
||||
unit: Option<String>,
|
||||
/// Number of trailing lines. Capped at 5000. Default 500.
|
||||
#[serde(default)]
|
||||
lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// `GET /api/journal-host?unit=<unit>&lines=N` — host-side journald (no
|
||||
/// `-M` container flag). Restricted to an allow-list of known host services
|
||||
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
|
||||
/// dashboard binding to a host-only port.
|
||||
async fn get_journal_host(
|
||||
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
||||
) -> Response {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
let allowed = ["hive-c0re.service"];
|
||||
let mut cmd = tokio::process::Command::new("journalctl");
|
||||
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
||||
.arg(lines.to_string());
|
||||
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
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-host: unknown unit {unit:?}"));
|
||||
}
|
||||
cmd.args(["-u", &unit]);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(out) => {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BuildLogsAllQuery {
|
||||
/// Max rows to return. Capped at 100. Default 30.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /api/build-logs?limit=N` — most-recent build log headers across
|
||||
/// all agents, newest first. Same JSON shape as the per-agent endpoint.
|
||||
async fn get_build_logs_all(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
|
||||
) -> Response {
|
||||
let limit = q.limit.unwrap_or(30);
|
||||
match state.coord.build_logs.list_recent_all(limit) {
|
||||
Ok(rows) => axum::Json(rows).into_response(),
|
||||
Err(e) => error_response(&format!("build-logs all: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StateFileQuery {
|
||||
path: String,
|
||||
|
|
|
|||
Loading…
Reference in a new issue