//! Hyperhive dashboard. Lists managed containers (with deep-links to each //! container's web UI), pending approvals (with unified diff vs the applied //! repo, plus approve/deny buttons), and the manager. use std::net::SocketAddr; use std::sync::Arc; use anyhow::{Context, Result}; use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::coordinator::Coordinator; use crate::lifecycle; /// Root of the auto-generated `OpenAPI` spec, served raw at /// `/api/openapi.json` — see [`utoipa`]. Swagger UI itself (browsable at /// `/api/docs`) is nginx-hosted from the nix store (see /// `nix/host-modules/hive-gateway/vhosts.nix`'s `swaggerUiLocations`). /// Only routes carrying a `#[utoipa::path(...)]` annotation show up in /// the spec — an unannotated route just doesn't appear, nothing breaks. #[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"), (name = "approvals", description = "approve/deny pending approval rows"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "extra_forges", description = "external (non-internal) forge account provisioning"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "matrix_accounts", description = "matrix + github account provisioning for agents"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"), (name = "permissions", description = "tool-group + capability assignment for agents"), (name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "tombstones", description = "purge of retained state for destroyed agents"), (name = "topology", description = "operator-driven agent reparenting"), (name = "webhook", description = "forgejo webhook receivers"), ) )] struct ApiDoc; mod approvals; mod build_logs; mod extra_forges; // The single validated identifier type — homed in `hive-host-sock` (the crate // owning agent-path facts) so every dashboard path-param validates through the // same type used to build agent paths. Re-exported so submodules + the socket // server reach it as `crate::dashboard::Ident`. pub(crate) use hive_types::Ident; mod health; mod journal; mod lifecycle_ops; mod matrix_accounts; mod meta_inputs; mod misc_api; pub(crate) mod permissions; mod schedules; mod state_files; mod state_snapshot; mod tombstones; mod topology; mod webhook; // Run after lock bumps by the job queue (`job_queue/exec.rs`); the view // type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`). // Re-exported to preserve the `crate::dashboard::*` paths across the split. pub use meta_inputs::MetaInputView; pub(crate) use meta_inputs::emit_meta_inputs_snapshot; // Run at broker-message ingest by the coordinator + the operator-msg path // (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths` // path across the split. pub use state_files::scan_validated_paths; // Called after destroy/purge/spawn finalisation (`actions.rs`); the view // type feeds `DashboardEvent::TombstonesChanged` (`dashboard_events.rs`). // Re-exported to preserve the `crate::dashboard::*` paths across the split. pub use tombstones::TombstoneView; pub(crate) use tombstones::emit_tombstones_snapshot; #[derive(Clone)] struct AppState { coord: Arc, /// HMAC-SHA256 secret shared with Forgejo webhook registrations. /// Verified on every incoming `/webhook/*` POST. /// `None` when the secret could not be loaded at startup — all /// `/webhook/*` requests are rejected with 503 in that case. webhook_secret: Option, } #[allow( clippy::too_many_lines, reason = "the body is dominated by the flat axum route table — one line \ per endpoint mapping a URL to its (now per-concern submodule) \ handler; splitting that exhaustive list across helpers would \ obscure the route map for no readability gain" )] pub async fn serve( port: u16, coord: Arc, webhook_secret: Option, ) -> Result<()> { // API-only: the gateway static-serves the dashboard dist and proxies // non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`). // 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 // distinct path panics at runtime ("Overlapping method route") // the moment two of them share an HTTP method, which most // GET-vs-GET or POST-vs-POST pairs across different paths do. Each // call below is therefore scoped to exactly one path — two handlers // in the same call only when they genuinely share a path with // different methods (`schedules::api_schedules`/`post_schedule_new` // on `/api/schedules`, `matrix_accounts::get_github_account`/ // `post_github_account` on `/api/github-account`) — chained via // repeated `.routes(...)` calls instead of one giant `routes!(...)` // with everything in it. let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_ready)) .routes(routes!(journal::get_journal)) .routes(routes!(journal::get_journal_host)) .routes(routes!(state_snapshot::api_state)) .routes(routes!(state_files::get_state_file)) .routes(routes!(matrix_accounts::get_matrix_accounts)) .routes(routes!(matrix_accounts::post_matrix_account_login)) .routes(routes!( matrix_accounts::post_github_account, matrix_accounts::get_github_account )) .routes(routes!(extra_forges::get_extra_forges)) .routes(routes!(extra_forges::post_extra_forge_account)) .routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_op_send)) .routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_raw_for_node)) .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(topology::post_set_parent)) .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) .routes(routes!(permissions::post_capabilities)) .routes(routes!(permissions::post_permissions)) .routes(routes!(permissions::get_stale_permissions)) .routes(routes!(permissions::delete_agent_permissions)) .routes(routes!( schedules::api_schedules, schedules::post_schedule_new )) .routes(routes!(schedules::patch_schedule)) .routes(routes!(schedules::post_schedule_cancel)) .routes(routes!(schedules::post_schedule_pause)) .routes(routes!(schedules::post_schedule_resume)) .routes(routes!(schedules::post_schedule_fire_now)) .routes(routes!(schedules::post_rebuild_queue_cancel)) .routes(routes!(webhook::post_webhook_config_pr)) .routes(routes!(approvals::post_approve)) .routes(routes!(approvals::post_deny)) .routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_kill)) .routes(routes!(lifecycle_ops::post_restart)) .routes(routes!(lifecycle_ops::post_start)) .routes(routes!(lifecycle_ops::post_rebuild)) .routes(routes!(lifecycle_ops::post_pause)) .routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(tombstones::post_purge_tombstone)) .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)) .routes(routes!(state_snapshot::jobq_graph)) .routes(routes!(state_snapshot::jobq_rollup)) .split_for_parts(); // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from // the nix store (see the module doc comment above `ApiDoc`). `api` // is `Clone`; each request gets its own owned copy for `Json` to // serialize. let app = router .route( "/api/openapi.json", get(move || async move { Json(api.clone()) }), ) .with_state(AppState { coord, webhook_secret, }); // Binds loopback-only; external access via gateway. // Rationale: docs/networking/gateway.md::Firewall posture. let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = bind_with_retry(addr).await?; tracing::info!(%addr, "dashboard listening"); axum::serve(listener, app).await?; Ok(()) } // SPA shape + SSE channels: docs/web-ui/shape.md. /// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap /// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`. async fn bind_with_retry(addr: SocketAddr) -> Result { let mut delay_ms = 250u64; let mut attempts = 0u32; loop { match try_bind(addr) { Ok(l) => { if attempts > 0 { tracing::info!( %addr, attempts, "dashboard: bind succeeded after retry" ); } return Ok(l); } Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { let attempt = attempts + 1; if attempt <= 12 { tracing::warn!( %addr, attempt, "dashboard: AddrInUse, retrying in {delay_ms}ms" ); } else { tracing::info!( %addr, attempt, "dashboard: AddrInUse still holding, retrying in {delay_ms}ms" ); } tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; attempts += 1; delay_ms = (delay_ms * 2).min(2000); } Err(e) => { return Err(e).with_context(|| format!("bind dashboard on {addr}")); } } } } fn try_bind(addr: SocketAddr) -> std::io::Result { let sock = match addr { SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?, SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?, }; sock.set_reuseaddr(true)?; sock.bind(addr)?; sock.listen(1024) } /// Two-axis path-param guard for write routes. Combines: /// /// 1. **format validation** ([`Ident::parse`]) — rejects /// path traversal / unicode homoglyphs / empty + too-long names with /// HTTP 400. /// 2. **existence check** — looks up `name` in the coordinator's /// container snapshot; unknown name → HTTP 404 with a clear /// "no such agent" message. catches the operator-typo case where /// a destructive POST would otherwise hit silently (mark-all-read /// returning 0) or hit downstream lifecycle code that fails with /// a confusing nspawn error. /// /// Returns `None` when both checks pass (caller proceeds), `Some(Response)` /// when the request should be rejected. Use at the top of every write /// handler taking a name path-param. Read-only GET handlers and /// handlers that legitimately operate on tombstoned agents (e.g. /// `mark-all-read` on broker rows for a destroyed agent) call /// [`Ident::parse`] directly and skip the existence check. async fn guard_agent_name(state: &AppState, name: &str) -> Option { if let Err(reason) = Ident::parse(name) { return Some( (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(), ); } let snapshot = state.coord.containers_snapshot().await; if !snapshot.iter().any(|c| c.name == name) { return Some((StatusCode::NOT_FOUND, format!("no such agent: {name}")).into_response()); } None } /// Convert either a logical name or a container name back to the logical /// name. Sub-agents are `h-foo` → `foo`; manager stays `root`. fn strip_container_prefix(name: &str) -> String { name.strip_prefix(lifecycle::AGENT_PREFIX) .unwrap_or(name) .to_owned() } /// The common internal-error case as a `ProblemDetails`: a 500 RFC 9457 /// (`application/problem+json`) value via the `problem_details` crate. /// `from_status_code` sets `status` + `title` (the canonical reason phrase) /// and leaves `type` as the default `about:blank`; `with_detail` carries the /// caller message; the crate's axum `IntoResponse` emits the /// `application/problem+json` body the frontend parses (it reads `detail`). /// Handlers that surface client failures return `Result<_, ProblemDetails>` /// and hand this (or an inline `from_status_code(4xx)`) straight to `Err` — /// no manual `.into_response()`. fn error_problem(message: &str) -> problem_details::ProblemDetails { problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) .with_detail(message) } /// `Response` wrapper around [`error_problem`] for the many handlers typed /// `-> Response` whose only failure mode is a 500 — they funnel errors /// through here rather than threading a `Result` return type. fn error_response(message: &str) -> Response { error_problem(message).into_response() } #[cfg(test)] mod tests { use super::*; #[test] fn problem_details_carry_rfc9457_status_and_detail() { // Contract the frontend depends on: the problem_details crate // serialises the RFC 9457 members we rely on — `status` (numeric) // and `detail` (the caller message; the FE reads `.detail`). let pd = problem_details::ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail("bad input"); let v = serde_json::to_value(&pd).expect("problem details serialise"); assert_eq!(v["status"], 400); assert_eq!(v["detail"], "bad input"); // The 500 wrapper path carries the internal-error status. let five = problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) .with_detail("boom"); let fv = serde_json::to_value(&five).expect("problem details serialise"); assert_eq!(fv["status"], 500); } } #[cfg(test)] mod router_build_probe { use super::*; /// Regression test for the exact panic this file's `serve()` used to /// hit at startup: `routes!()` folds every handler passed to ONE /// macro invocation into a single shared `MethodRouter`, so two /// handlers on different paths but the same HTTP method panic with /// "Overlapping method route" the moment they're in the same /// `routes!(...)` call — see `serve()`'s comment above its own /// `.routes(...)` chain. This mirrors that exact chain (one /// `.routes()` call per distinct path, multi-method pairs grouped /// only where they share a path) so a future regression that /// accidentally merges two single-path calls back into one /// multi-path `routes!(...)` call fails here instead of at daemon /// startup. #[test] fn probe_multi_path_routes_macro_does_not_panic() { let _ = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health::get_health_live)) .routes(routes!(health::get_health_ready)) .routes(routes!(journal::get_journal)) .routes(routes!(journal::get_journal_host)) .routes(routes!(state_snapshot::api_state)) .routes(routes!(state_files::get_state_file)) .routes(routes!(matrix_accounts::get_matrix_accounts)) .routes(routes!(matrix_accounts::post_matrix_account_login)) .routes(routes!( matrix_accounts::post_github_account, matrix_accounts::get_github_account )) .routes(routes!(extra_forges::get_extra_forges)) .routes(routes!(extra_forges::post_extra_forge_account)) .routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_op_send)) .routes(routes!(build_logs::get_build_logs_all)) .routes(routes!(build_logs::get_build_log_for_node)) .routes(routes!(build_logs::get_build_log_raw_for_node)) .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(topology::post_set_parent)) .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) .routes(routes!(permissions::post_capabilities)) .routes(routes!(permissions::post_permissions)) .routes(routes!(permissions::get_stale_permissions)) .routes(routes!(permissions::delete_agent_permissions)) .routes(routes!( schedules::api_schedules, schedules::post_schedule_new )) .routes(routes!(schedules::patch_schedule)) .routes(routes!(schedules::post_schedule_cancel)) .routes(routes!(schedules::post_schedule_pause)) .routes(routes!(schedules::post_schedule_resume)) .routes(routes!(schedules::post_schedule_fire_now)) .routes(routes!(schedules::post_rebuild_queue_cancel)) .routes(routes!(webhook::post_webhook_config_pr)) .routes(routes!(approvals::post_approve)) .routes(routes!(approvals::post_deny)) .routes(routes!(lifecycle_ops::post_destroy)) .routes(routes!(lifecycle_ops::post_kill)) .routes(routes!(lifecycle_ops::post_restart)) .routes(routes!(lifecycle_ops::post_start)) .routes(routes!(lifecycle_ops::post_rebuild)) .routes(routes!(lifecycle_ops::post_pause)) .routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(tombstones::post_purge_tombstone)) .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)) .routes(routes!(state_snapshot::jobq_graph)) .routes(routes!(state_snapshot::jobq_rollup)); } }