hive-c0re: wire up openapi spec + swagger ui (#2872)

This commit is contained in:
damocles 2026-07-31 21:39:46 +02:00 committed by mara
commit 44651544a8
7 changed files with 256 additions and 8 deletions

View file

@ -24,10 +24,17 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
use utoipa::ToSchema;
use crate::host_stats::ServerWarning;
/// `GET /health/live` — liveness. Always `200`; no further checks.
#[utoipa::path(
get,
path = "/health/live",
responses((status = 200, description = "process is up", body = serde_json::Value)),
tag = "health"
)]
pub(super) async fn get_health_live() -> Response {
(
StatusCode::OK,
@ -36,7 +43,7 @@ pub(super) async fn get_health_live() -> Response {
.into_response()
}
#[derive(Serialize)]
#[derive(Serialize, ToSchema)]
struct ReadyBody {
status: &'static str,
warnings: Vec<ServerWarning>,
@ -48,6 +55,15 @@ struct ReadyBody {
/// `{"status":"degraded", ...}`. `warnings` always carries the full
/// current list (including `warn`-level entries not affecting the
/// status) so a poller gets detail either way.
#[utoipa::path(
get,
path = "/health/ready",
responses(
(status = 200, description = "no crit-level warning set", body = ReadyBody),
(status = 503, description = "at least one crit-level warning set", body = ReadyBody),
),
tag = "health"
)]
pub(super) async fn get_health_ready() -> Response {
let warnings = crate::warnings::snapshot();
let degraded = warnings.iter().any(|w| w.level == "crit");

View file

@ -15,13 +15,14 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::IntoParams;
use problem_details::ProblemDetails;
use super::{Ident, error_problem, strip_container_prefix};
use crate::lifecycle;
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct JournalQuery {
/// Optional systemd unit filter — e.g. `hive-agent.service`. When
/// omitted, returns the full machine journal.
@ -43,6 +44,20 @@ pub(super) struct JournalQuery {
/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the
/// per-agent hive daemons, so `unit` is ignored for them — always the
/// full machine journal.
#[utoipa::path(
get,
path = "/api/journal/{name}",
params(
("name" = String, Path, description = "agent name, or one of the four infra container names"),
JournalQuery,
),
responses(
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
(status = 400, description = "bad agent name or unknown unit"),
(status = 404, description = "no such managed container"),
),
tag = "journal"
)]
pub(super) async fn get_journal(
AxumPath(name): AxumPath<String>,
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
@ -134,7 +149,7 @@ async fn read_journal_response(
}
}
#[derive(Deserialize)]
#[derive(Deserialize, IntoParams)]
pub(super) struct JournalHostQuery {
/// Service unit name to filter to. If omitted, returns all logs.
#[serde(default)]
@ -148,6 +163,16 @@ pub(super) struct JournalHostQuery {
/// `-M` container flag). Restricted to an allow-list of known host services
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
/// dashboard binding to a host-only port.
#[utoipa::path(
get,
path = "/api/journal-host",
params(JournalHostQuery),
responses(
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
(status = 400, description = "unknown unit"),
),
tag = "journal"
)]
pub(super) async fn get_journal_host(
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
) -> Result<Response, ProblemDetails> {

View file

@ -12,10 +12,35 @@ use axum::{
response::{IntoResponse, Response},
routing::{get, post},
};
use utoipa::OpenApi;
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
use crate::coordinator::Coordinator;
use crate::lifecycle;
/// Root of the auto-generated `OpenAPI` spec (`/api/openapi.json`, browsable
/// at `/api/docs`) — see [`utoipa`]. Only routes carrying a
/// `#[utoipa::path(...)]` annotation show up; the rest of the (much
/// larger) route table below is undocumented for now. Deliberately
/// incremental: an unannotated route just doesn't appear in the spec,
/// nothing breaks, so routes get annotated as a series of small
/// follow-ups rather than one mega-diff.
#[derive(OpenApi)]
#[openapi(
info(
title = "hyperhive dashboard API",
description = "hive-c0re's HTTP surface, served on the loopback \
dashboard port behind the gateway's /api/ + \
/health/ proxy prefixes."
),
tags(
(name = "health", description = "hive-wide liveness/readiness probes"),
(name = "journal", description = "container + host journal reads"),
)
)]
struct ApiDoc;
mod approvals;
mod build_logs;
mod extra_forges;
@ -79,12 +104,11 @@ 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.
// The four `#[utoipa::path]`-annotated routes are registered further
// down via `OpenApiRouter` instead of the plain `.route(...)` calls
// below — see the `router`/`api` split at the end of this fn.
let app = Router::new()
.route("/health/live", get(health::get_health_live))
.route("/health/ready", get(health::get_health_ready))
.route("/api/state", get(state_snapshot::api_state))
.route("/api/journal/{name}", get(journal::get_journal))
.route("/api/journal-host", get(journal::get_journal_host))
.route("/api/state-file", get(state_files::get_state_file))
.route(
"/api/matrix-accounts",
@ -239,6 +263,19 @@ pub async fn serve(
get(state_snapshot::dashboard_history),
)
// No static fallback — the gateway owns the dist; unmatched paths 404.
;
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.routes(routes!(
health::get_health_live,
health::get_health_ready,
journal::get_journal,
journal::get_journal_host,
))
.merge(app.into())
.split_for_parts();
let app = router
.merge(SwaggerUi::new("/api/docs").url("/api/openapi.json", api))
.with_state(AppState {
coord,
webhook_secret,

View file

@ -18,11 +18,12 @@
use std::collections::HashMap;
use serde::Serialize;
use utoipa::ToSchema;
use crate::container_view::ContainerView;
/// One server-level warning for the dashboard's top-of-page banner.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ServerWarning {
/// Stable kind id (e.g. `"disk_pressure"`) — lets the frontend dedupe
/// or special-case without parsing the message.