diff --git a/docs/web-ui.md b/docs/web-ui.md index e3829f9f..c7d4ba6e 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -836,6 +836,16 @@ that's a browser-level decision, not ours. `Sent` event re-renders both the message-flow terminal and the operator inbox without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W. +- `GET /api/build-logs/{agent}?limit=N` — most-recent build log + headers for one agent, newest first. Returns + `Vec` (JSON): `id`, `agent`, `kind`, `cmdline`, + `started_at`, `finished_at`, `status` (`"ok"` / `"fail"` / + `null` while in-progress). `limit` defaults to 10, server-side + cap at 50. Agent name validated (`[a-z0-9_-]`, 1-63 chars). +- `GET /api/build-logs/id/{id}` — full build log by id. Returns + `BuildLogFull` (JSON): all header fields plus `stdout` and + `stderr` as plain text (newline-terminated lines, utf-8). HTTP + 404 when the row is missing (vacuum-reaped or stale id). - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for a managed container; rendered in the side panel. - `GET /api/approval-diff/{id}?base=applied|approved|previous` — diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 6a90ffe4..6d892df0 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -66,6 +66,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .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/{agent}", get(get_build_logs_agent)) + .route("/api/build-logs/id/{id}", get(get_build_log_full)) .route("/api/agent/{name}/links", get(get_agent_links)) .route("/api/agent/{name}/mark-all-read", post(post_mark_all_read)) .route("/cancel-reminder/{id}", post(post_cancel_reminder)) @@ -1627,6 +1629,51 @@ async fn api_reminders(State(state): State) -> Response { } } +#[derive(Deserialize)] +struct BuildLogsQuery { + /// Maximum number of rows to return. Capped server-side at 50 + /// (see `build_logs::list_recent_for_agent`). Default 10. + #[serde(default)] + limit: Option, +} + +/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log +/// headers for one agent, newest first. Returns +/// `Vec` (JSON). Limit defaults to 10, server-side +/// cap at 50. Backs the per-agent log chip in the agent card and +/// the side-panel header list. +async fn get_build_logs_agent( + State(state): State, + AxumPath(name): AxumPath, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + if let Some(reason) = validate_agent_name(&name) { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } + let limit = q.limit.unwrap_or(10); + match state.coord.build_logs.list_recent_for_agent(&name, limit) { + Ok(rows) => axum::Json(rows).into_response(), + Err(e) => error_response(&format!("build-logs {name}: {e:#}")), + } +} + +/// `GET /api/build-logs/id/{id}` — full build log row (stdout + +/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or +/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the +/// operator passed a stale id from a refresh race). +async fn get_build_log_full( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.coord.build_logs.get_full(id) { + Ok(Some(log)) => axum::Json(log).into_response(), + Ok(None) => { + (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response() + } + Err(e) => error_response(&format!("build-log {id}: {e:#}")), + } +} + /// `GET /api/schedules` — snapshot of every schedule for the /// scheduled-prompts tab. Returns the wire shape directly /// so the frontend can render without an extra translation layer.