223 lines
8.2 KiB
Rust
223 lines
8.2 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, container resources, and the
|
|
//! audit log.
|
|
|
|
use axum::{
|
|
extract::{Form, Path as AxumPath, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use super::{AppState, Ident, error_response, scan_validated_paths};
|
|
|
|
/// 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 }] }`.
|
|
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 items: Vec<serde_json::Value> = 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)]
|
|
pub(super) struct StatsHiveQuery {
|
|
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). Window defaults to `24h`.
|
|
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.
|
|
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**.
|
|
pub(super) async fn api_audit_log(State(state): State<AppState>) -> 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.
|
|
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(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)]
|
|
pub(super) struct OpSendForm {
|
|
to: String,
|
|
body: String,
|
|
}
|
|
|
|
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::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)]
|
|
pub(super) struct RequestSpawnForm {
|
|
name: String,
|
|
}
|
|
|
|
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::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:#}")),
|
|
}
|
|
}
|