//! Remaining single-endpoint dashboard handlers: the operator inbox //! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`), //! spawn-request, hive-wide turn stats, container resources, and the //! audit log. use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; use super::{AppState, Ident, error_response, scan_validated_paths}; use crate::container_stats::ContainerResource; use crate::hive_stats::HiveStats; /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing /// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped /// tokens are validated so the client renders file links like the /// terminal does. Shape: `{ "messages": [{ id, from, body, at, /// in_reply_to, file_refs }] }`. #[utoipa::path( get, path = "/api/operator-inbox", responses( (status = 200, description = "unread operator-directed messages", body = serde_json::Value), (status = 500, description = "broker read failed"), ), tag = "misc_api" )] pub(super) async fn api_operator_inbox(State(state): State) -> Response { const INBOX_LIMIT: u64 = 100; match state .coord .broker .unread_for_recipient("operator", INBOX_LIMIT) { Ok(messages) => { let items: Vec = messages .into_iter() .filter_map(|m| { let crate::broker::MessageEvent::Sent { id, from, body, at, in_reply_to, .. } = m else { return None; }; let file_refs = scan_validated_paths(&body); Some(serde_json::json!({ "id": id, "from": from, "body": body, "at": hive_sh4re::wire_time::from_secs(at), "in_reply_to": in_reply_to, "file_refs": file_refs, })) }) .collect(); axum::Json(serde_json::json!({ "messages": items })).into_response() } Err(e) => error_response(&format!("operator-inbox failed: {e:#}")), } } #[derive(Deserialize, IntoParams)] pub(super) struct StatsHiveQuery { window: Option, } /// Hive-wide turn-stats rollup for the dashboard swarm-stats view. /// Aggregates every agent's `hyperhive-turn-stats.sqlite` read-only /// (skips missing/unreadable ones). Window defaults to `24h`. #[utoipa::path( get, path = "/api/stats-hive", params(StatsHiveQuery), responses((status = 200, description = "hive-wide turn-stats rollup", body = HiveStats)), tag = "misc_api" )] pub(super) async fn api_stats_hive( State(state): State, axum::extract::Query(q): axum::extract::Query, ) -> Response { let window = crate::hive_stats::Window::parse(q.window.as_deref().unwrap_or("24h")); axum::Json(crate::hive_stats::hive_snapshot( window, &state.coord.model_prices, )) .into_response() } /// Live per-agent-container CPU + memory load from cgroup v2. Samples /// CPU over a short interval (~200 ms), so this call briefly awaits. #[utoipa::path( get, path = "/api/container-resources", responses((status = 200, description = "live per-container CPU + memory load", body = Vec)), tag = "misc_api" )] pub(super) async fn api_container_resources() -> Response { axum::Json(crate::container_stats::gather().await).into_response() } /// `GET /api/audit-log` — most-recent agent-initiated privileged-action /// audit entries, newest first (server-clamped to 500). Backs the /// operator dashboard's audit view. Returns /// `{ "entries": [AuditEntry…], "total": N }` so the UI can show /// "latest 500 of N" rather than silently capping. `ts_unix` is in /// **seconds**. #[utoipa::path( get, path = "/api/audit-log", responses( (status = 200, description = "recent audit entries + total count", body = serde_json::Value), (status = 500, description = "sqlite read failed"), ), tag = "misc_api" )] pub(super) async fn api_audit_log(State(state): State) -> Response { const LIMIT: usize = 500; let entries = match state.coord.audit_log.list_recent(LIMIT) { Ok(rows) => rows, Err(e) => return error_response(&format!("audit-log: {e:#}")), }; let total = match state.coord.audit_log.count_total() { Ok(n) => n, Err(e) => return error_response(&format!("audit-log count: {e:#}")), }; axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response() } /// Operator-driven "clear this agent's inbox" — backs the side-panel /// "mark all read" button. Marks every message addressed to the /// agent as acked (backfilling `delivered_at` for any still-pending /// rows so vacuum can collect them). Returns `{ "marked": N }` so the /// frontend can show "cleared N messages" feedback without an extra /// fetch. #[utoipa::path( post, path = "/api/agent/{name}/mark-all-read", params(("name" = String, Path, description = "agent name")), responses( (status = 200, description = "count of messages marked read", body = serde_json::Value), (status = 400, description = "bad agent name"), (status = 500, description = "broker write failed"), ), tag = "misc_api" )] pub(super) async fn post_mark_all_read( State(state): State, AxumPath(name): AxumPath, ) -> Response { let name = match Ident::parse(&name) { Ok(n) => n, Err(reason) => { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); } }; match state.coord.broker.mark_all_read(name.as_str()) { Ok(n) => { tracing::info!(%name, marked = n, "operator marked all messages read"); axum::Json(serde_json::json!({ "marked": n })).into_response() } Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")), } } /// Operator-side compose form on the dashboard terminal. Drops a /// message into the broker as `{from: "operator", to, body}`. Same /// shape that per-agent web UIs use via `OperatorMsg`, but here the /// operator picks the recipient explicitly with `@name`. No /// validation that `to` resolves to a known agent — broker accepts /// arbitrary recipients (and the agent's inbox grows whether or not /// they exist, which is fine for spawn-then-greet flows). #[derive(Deserialize, ToSchema)] pub(super) struct OpSendForm { to: String, body: String, } /// `POST /api/op-send` — operator compose: drop a message into the /// broker addressed to `to` (or `*` to broadcast). #[utoipa::path( post, path = "/api/op-send", request_body(content = OpSendForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "message sent", body = String), (status = 500, description = "missing to/body, or the broker send failed"), ), tag = "misc_api" )] pub(super) async fn post_op_send( State(state): State, Form(form): Form, ) -> Response { let to = form.to.trim().to_owned(); let body = form.body.trim().to_owned(); if to.is_empty() { return error_response("op-send: `to` required"); } if body.is_empty() { return error_response("op-send: `body` required"); } if to == "*" { let errors = state .coord .broadcast_send(hive_sh4re::OPERATOR_RECIPIENT, &body); if !errors.is_empty() { return error_response(&format!( "op-send broadcast partial fail: {}", errors.join("; ") )); } } else if let Err(e) = state.coord.broker.send(&hive_sh4re::Message { from: hive_sh4re::trusted_sender(hive_sh4re::OPERATOR_RECIPIENT), to: to.clone(), body, in_reply_to: None, }) { return error_response(&format!("op-send to {to} failed: {e:#}")); } // 200 instead of 303 → the client doesn't refetch /api/state. The // broker `send` already emitted a `MessageEvent` which the // dashboard channel forwarder mirrors as `DashboardEvent::Sent`, // and the page's terminal + inbox derive from that stream — so the // operator's send shows up the same way an agent's send does, with // no full-state refresh in between. (axum::http::StatusCode::OK, "ok").into_response() } #[derive(Deserialize, ToSchema)] pub(super) struct RequestSpawnForm { name: String, } /// `POST /api/request-spawn` — queue a spawn approval for `name`. #[utoipa::path( post, path = "/api/request-spawn", request_body(content = RequestSpawnForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "spawn approval queued", body = String), (status = 500, description = "missing name, or the approval submit failed"), ), tag = "misc_api" )] pub(super) async fn post_request_spawn( State(state): State, Form(form): Form, ) -> Response { let name = form.name.trim().to_owned(); if name.is_empty() { return error_response("spawn: `name` required"); } match state.coord.approvals.submit_kind( &name, hive_sh4re::ApprovalKind::Spawn, "", None, "operator", None, ) { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); // Phase 5b: notify the dashboard event channel so live // subscribers can append the row without a snapshot // refetch. Spawn approvals carry no sha. state .coord .emit_approval_added(crate::coordinator::ApprovalAdded { id, agent: &name, approval_kind: "spawn", sha_short: None, description: None, pr_number: None, }); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), } }