hyperhive/hive-c0re/src/dashboard/misc_api.rs

287 lines
9.7 KiB
Rust

//! Remaining single-endpoint dashboard handlers: the operator inbox
//! (`Y3R C4LL`) + 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, scan_validated_paths};
use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats;
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxItem {
id: i64,
from: String,
body: String,
at: chrono::DateTime<chrono::Utc>,
in_reply_to: Option<i64>,
file_refs: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxBody {
messages: Vec<OperatorInboxItem>,
}
/// 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.
#[utoipa::path(
get,
path = "/api/operator-inbox",
responses(
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
(status = 500, description = "broker read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100;
match state
.coord
.broker
.unread_for_recipient("operator", INBOX_LIMIT)
{
Ok(messages) => {
let messages: Vec<OperatorInboxItem> = 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(OperatorInboxItem {
id,
from,
at: hive_sh4re::wire_time::from_secs(at),
body,
in_reply_to,
file_refs,
})
})
.collect();
axum::Json(OperatorInboxBody { messages }).into_response()
}
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
}
}
#[derive(Deserialize, IntoParams)]
pub(super) struct StatsHiveQuery {
/// Stats window; defaults to `24h`.
window: Option<String>,
}
/// 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<AppState>,
axum::extract::Query(q): axum::extract::Query<StatsHiveQuery>,
) -> 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<ContainerResource>)),
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<AppState>,
AxumPath(name): AxumPath<String>,
) -> 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<AppState>,
Form(form): Form<OpSendForm>,
) -> 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 + 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,
}
/// 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<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> 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::approvals::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:#}")),
}
}