From d10eebd45598da395ce5f3d522db63af3ec74b15 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 1 Aug 2026 00:01:07 +0200 Subject: [PATCH] hive-c0re: annotate the 3 SSE/stream dashboard routes with utoipa --- hive-c0re/src/dashboard/build_logs.rs | 12 +++++++ hive-c0re/src/dashboard/mod.rs | 38 ++++++++--------------- hive-c0re/src/dashboard/state_snapshot.rs | 26 +++++++++++++++- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/hive-c0re/src/dashboard/build_logs.rs b/hive-c0re/src/dashboard/build_logs.rs index 990af3ef..7c741a52 100644 --- a/hive-c0re/src/dashboard/build_logs.rs +++ b/hive-c0re/src/dashboard/build_logs.rs @@ -207,6 +207,18 @@ struct BuildLogFrame { /// always carries the full accumulated log so far (cursors start at 0); /// subsequent frames carry only new bytes. `done: true` on the final /// frame signals the browser to close the `EventSource`. +#[utoipa::path( + get, + path = "/api/build-logs/id/{id}/stream", + params(("id" = i64, Path, description = "build log row id")), + responses( + (status = 200, description = "server-sent event stream; each event's \ + `data` is a JSON-serialised `BuildLogFrame` \ + (stdout_append/stderr_append/status/done)", + body = String, content_type = "text/event-stream"), + ), + tag = "build_logs" +)] pub(super) async fn get_build_log_stream( State(state): State, AxumPath(id): AxumPath, diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 5491e8cd..9e2a12e6 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -7,10 +7,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; use axum::{ - Router, http::StatusCode, response::{IntoResponse, Response}, - routing::get, }; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -120,27 +118,12 @@ pub async fn serve( ) -> Result<()> { // API-only: the gateway static-serves the dashboard dist and proxies // non-static requests here (see hive-gateway.nix). Unmatched paths 404. - // Every `#[utoipa::path]`-annotated route is registered further down - // via `OpenApiRouter` instead of a plain `.route(...)` call here — see - // the `router`/`api` split below. What's left in this plain chain is - // exactly the SSE/streaming endpoints utoipa can't model (see the - // `ApiDoc` doc comment) plus anything not yet annotated. - let app = Router::new() - .route( - "/api/build-logs/id/{id}/stream", - get(build_logs::get_build_log_stream), - ) - .route( - "/api/dashboard/stream", - get(state_snapshot::dashboard_stream), - ) - .route( - "/api/dashboard/history", - get(state_snapshot::dashboard_history), - ) - // No static fallback — the gateway owns the dist; unmatched paths 404. - ; - + // No static fallback needed here either. Every route (SSE streams + // included — `utoipa::path` can document an SSE body as an opaque + // `text/event-stream` string, it just can't model individual frame + // shapes) is registered below via `OpenApiRouter`; see the `router`/ + // `api` split. + // // `routes!()` folds every handler it's given into ONE shared // `MethodRouter` for the whole macro invocation — it does NOT group // by path internally, so passing it handlers for more than one @@ -219,7 +202,9 @@ pub async fn serve( .routes(routes!(questions::post_cancel_question)) .routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(meta_inputs::post_meta_update)) - .merge(app.into()) + .routes(routes!(build_logs::get_build_log_stream)) + .routes(routes!(state_snapshot::dashboard_stream)) + .routes(routes!(state_snapshot::dashboard_history)) .split_for_parts(); let app = router .merge(SwaggerUi::new("/api/docs").url("/api/openapi.json", api)) @@ -453,6 +438,9 @@ mod router_build_probe { .routes(routes!(questions::post_answer_question)) .routes(routes!(questions::post_cancel_question)) .routes(routes!(tombstones::post_purge_tombstone)) - .routes(routes!(meta_inputs::post_meta_update)); + .routes(routes!(meta_inputs::post_meta_update)) + .routes(routes!(build_logs::get_build_log_stream)) + .routes(routes!(state_snapshot::dashboard_stream)) + .routes(routes!(state_snapshot::dashboard_history)); } } diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 37006cc1..f86000cb 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -18,6 +18,7 @@ use hive_sh4re::Approval; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; +use utoipa::IntoParams; use crate::container_view::ContainerView; @@ -655,6 +656,17 @@ fn build_approval_views(approvals: Vec) -> Vec { out } +#[utoipa::path( + get, + path = "/api/dashboard/history", + responses( + (status = 200, description = "`{ seq, events }` — up to the last 200 \ + broker messages as `DashboardEvent::Sent`/`Delivered` JSON, plus \ + `seq`: the dashboard channel's high-water mark at fetch time \ + (used by clients to dedupe against buffered live SSE frames)"), + ), + tag = "state_snapshot" +)] pub(super) async fn dashboard_history(State(state): State) -> Response { // Backfill source for the dashboard terminal. Returns up to ~200 // historical broker messages (no other event kinds are persisted) @@ -731,7 +743,7 @@ pub(super) async fn dashboard_history(State(state): State) -> Response /// Useful for narrow pages (e.g. `flow.js` only cares about `sent` /// / `delivered` / `container_state_changed` / `container_removed`) /// that want to drop the dispatch overhead on every unrelated mutation. -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, IntoParams)] pub(super) struct DashboardStreamQuery { /// Comma-separated event kinds to forward. Each token is /// trimmed; unknown kinds are silently ignored on lookup @@ -739,6 +751,18 @@ pub(super) struct DashboardStreamQuery { kinds: Option, } +#[utoipa::path( + get, + path = "/api/dashboard/stream", + params(DashboardStreamQuery), + responses( + (status = 200, description = "server-sent event stream; each event's \ + `data` is a JSON-serialised `DashboardEvent` (seq-tagged; pair \ + with `/api/dashboard/history` to backfill + dedupe on connect)", + body = String, content_type = "text/event-stream"), + ), + tag = "state_snapshot" +)] pub(super) async fn dashboard_stream( State(state): State, axum::extract::Query(q): axum::extract::Query,