hive-c0re: replace json! with typed structs in dashboard handlers

This commit is contained in:
damocles 2026-08-13 18:12:35 +02:00
commit 351341e87c
5 changed files with 86 additions and 37 deletions

View file

@ -28,19 +28,20 @@ use utoipa::ToSchema;
use crate::host_stats::ServerWarning;
#[derive(Serialize, ToSchema)]
struct LiveBody {
status: &'static str,
}
/// Liveness. Always `200`; no further checks.
#[utoipa::path(
get,
path = "/health/live",
responses((status = 200, description = "process is up", body = serde_json::Value)),
responses((status = 200, description = "process is up", body = LiveBody)),
tag = "health"
)]
pub(super) async fn get_health_live() -> Response {
(
StatusCode::OK,
axum::Json(serde_json::json!({ "status": "ok" })),
)
.into_response()
(StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response()
}
#[derive(Serialize, ToSchema)]

View file

@ -8,26 +8,41 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths};
use crate::audit_log::AuditEntry;
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. Shape: `{ "messages": [{ id, from, body, at,
/// in_reply_to, file_refs }] }`.
/// terminal does.
#[utoipa::path(
get,
path = "/api/operator-inbox",
responses(
(status = 200, description = "unread operator-directed messages", body = serde_json::Value),
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
(status = 500, description = "broker read failed"),
),
tag = "misc_api"
@ -40,7 +55,7 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
.unread_for_recipient("operator", INBOX_LIMIT)
{
Ok(messages) => {
let items: Vec<serde_json::Value> = messages
let messages: Vec<OperatorInboxItem> = messages
.into_iter()
.filter_map(|m| {
let crate::broker::MessageEvent::Sent {
@ -55,17 +70,17 @@ pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Respons
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,
}))
Some(OperatorInboxItem {
id,
from,
at: hive_sh4re::wire_time::from_secs(at),
body,
in_reply_to,
file_refs,
})
})
.collect();
axum::Json(serde_json::json!({ "messages": items })).into_response()
axum::Json(OperatorInboxBody { messages }).into_response()
}
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
}
@ -114,18 +129,23 @@ pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response()
}
#[derive(Serialize, ToSchema)]
pub(super) struct AuditLogBody {
entries: Vec<AuditEntry>,
total: i64,
}
/// 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
/// Backs the operator dashboard's audit view. `total` lets the UI 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 = 200, description = "recent audit entries + total count", body = AuditLogBody),
(status = 500, description = "sqlite read failed"),
),
tag = "misc_api"
@ -140,7 +160,12 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
Ok(n) => n,
Err(e) => return error_response(&format!("audit-log count: {e:#}")),
};
axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response()
axum::Json(AuditLogBody { entries, total }).into_response()
}
#[derive(Serialize, ToSchema)]
pub(super) struct MarkAllReadBody {
marked: u64,
}
/// Operator-driven "clear this agent's inbox" — backs the side-panel
@ -148,14 +173,14 @@ pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
///
/// 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.
/// 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 = serde_json::Value),
(status = 200, description = "count of messages marked read", body = MarkAllReadBody),
(status = 400, description = "bad agent name"),
(status = 500, description = "broker write failed"),
),
@ -172,9 +197,9 @@ pub(super) async fn post_mark_all_read(
}
};
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()
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:#}")),
}

View file

@ -18,6 +18,16 @@ use crate::scheduled_prompts_worker::FireNowReport;
use super::{AppState, error_problem, error_response};
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct NewScheduleBody {
id: i64,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub(super) struct CancelResultBody {
cancelled: bool,
}
/// Snapshot of every schedule for the
/// scheduled-prompts tab.
///
@ -74,7 +84,7 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
// `api_schedules` above.
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
responses(
(status = 200, description = "created; body carries the new row id", body = serde_json::Value),
(status = 200, description = "created; body carries the new row id", body = NewScheduleBody),
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
(status = 500, description = "submit failed"),
),
@ -108,7 +118,7 @@ pub(super) async fn post_schedule_new(
match state.coord.scheduled_prompts.submit(&new) {
Ok(id) => {
state.coord.emit_schedules_snapshot();
Ok(axum::Json(serde_json::json!({"id": id})).into_response())
Ok(axum::Json(NewScheduleBody { id }).into_response())
}
Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))),
}
@ -177,7 +187,7 @@ pub(super) async fn post_schedule_fire_now(
post,
path = "/api/rebuild-queue/{id}/cancel",
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)),
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
tag = "schedules"
)]
pub(super) async fn post_rebuild_queue_cancel(
@ -188,9 +198,9 @@ pub(super) async fn post_rebuild_queue_cancel(
// Any terminal side effect is the DAG's own spared tail node, which the
// scheduler picks up on its next pass — nothing to fire from here.
state.coord.emit_rebuild_queue_snapshot();
axum::Json(serde_json::json!({"cancelled": true})).into_response()
axum::Json(CancelResultBody { cancelled: true }).into_response()
} else {
axum::Json(serde_json::json!({"cancelled": false})).into_response()
axum::Json(CancelResultBody { cancelled: false }).into_response()
}
}

View file

@ -726,6 +726,18 @@ pub(super) async fn jobq_rollup(
axum::Json(state.coord.job_queue.state_rollup())
}
/// Response body for `/api/dashboard/history`. No `ToSchema` — its
/// `events` field wraps [`crate::dashboard_events::DashboardEvent`],
/// which doesn't derive `ToSchema` either (a large enum with many
/// variants; see that type's doc comment for why annotating it is
/// out of scope here). The `responses(...)` doc below spells out the
/// shape in prose instead of a `body = ...` reference.
#[derive(Serialize)]
struct DashboardHistoryBody {
seq: u64,
events: Vec<crate::dashboard_events::DashboardEvent>,
}
#[utoipa::path(
get,
path = "/api/dashboard/history",
@ -798,7 +810,7 @@ pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response
}
})
.collect();
axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response()
axum::Json(DashboardHistoryBody { seq, events }).into_response()
}
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
}

View file

@ -27,6 +27,7 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde::Serialize;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Mirrors
/// `build_logs::GLOBAL` — lets recording sites write without threading an
@ -80,7 +81,7 @@ impl AuditOutcome {
}
/// One audit row as returned to the dashboard.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,