//! Remaining single-endpoint dashboard handlers: mark-all-read, operator //! compose (`op-send`), spawn-request, hive-wide turn stats, and container //! resources. use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use super::{AppState, Ident, error_response}; use crate::container_stats::ContainerResource; use crate::hive_stats::HiveStats; #[derive(Deserialize, IntoParams)] pub(super) struct StatsHiveQuery { /// Stats window; defaults to `24h`. 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). #[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() } #[derive(Serialize, ToSchema)] pub(super) struct MarkAllReadBody { marked: u64, } /// 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). `marked` lets the frontend 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 = MarkAllReadBody), (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(marked) => { tracing::info!(%name, marked, "operator marked all messages read"); axum::Json(MarkAllReadBody { marked }).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, } /// 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::manager::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::inbox::Message { from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::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 derives 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() }