diff --git a/Cargo.lock b/Cargo.lock index 57e40e5a..bcab7b39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4427,6 +4427,8 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 6909ef8c..87b66170 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -1,15 +1,57 @@ -// Root shell component. Real route (`/`) is still a placeholder — the -// swarm's hive roster is the next piece of work to land into it — but -// it now mounts inside and uses the ui/ primitives, so that -// page lands as a content change, not a structural one. +// Root shell component. `/` is now the real hive-roster overview page — +// fetches swarm-controller's `GET /api/hives` and renders it through the +// shared primitives. Status starts as a static "configured" chip; grows +// into a real online/stale/offline tone once a status rollup exists +// server-side — same component, richer data later, no rebuild. +import { useEffect, useState } from 'preact/hooks'; import { Route, Switch } from 'wouter-preact'; import { Shell } from './shell/Shell.js'; import { Panel } from './ui/panel/Panel.js'; +import { StatusChip } from './ui/status-chip/StatusChip.js'; +import { Table, type TableColumn } from './ui/table/Table.js'; + +interface Hive { + name: string; + domain: string; +} + +const COLUMNS: TableColumn[] = [ + { key: 'name', header: 'name', render: (h) => h.name }, + { + key: 'domain', + header: 'domain', + render: (h) => ( + + {h.domain} + + ), + }, + // Static "configured" tone until a swarm-wide status rollup exists — + // there is no data source for online/stale/offline yet, and a chip + // that always renders "positive" would misreport a genuinely offline + // hive. See StatusChip's own doc comment for the same reasoning. + { key: 'status', header: 'status', render: () => }, +]; function Home() { + const [hives, setHives] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/hives') + .then((r) => { + if (!r.ok) throw new Error(`http ${r.status}`); + return r.json() as Promise; + }) + .then(setHives) + .catch((e: unknown) => setError(String(e))); + }, []); + return ( -

hive roster lands here, sequenced after this shell.

+ {error ?

failed to load the hive roster: {error}

: null} + {!error && hives === null ?

loading…

: null} + {hives ? h.name} /> : null} ); } diff --git a/frontend/packages/swarm-ui/src/swarm-ui.css b/frontend/packages/swarm-ui/src/swarm-ui.css index 5289a7a2..e8bdca3f 100644 --- a/frontend/packages/swarm-ui/src/swarm-ui.css +++ b/frontend/packages/swarm-ui/src/swarm-ui.css @@ -5,3 +5,10 @@ every page needs regardless of which components a route happens to use. */ @import "@hive/shared/base.css"; + +/* base.css covers body/typography but not links — every page here links + out (hive-roster rows to each hive's own dashboard, nav, footer…), so + this is genuinely page-level, not any one component's concern. */ +a { + color: var(--blue); +} diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 8cf0280d..80993a63 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -172,6 +172,18 @@ in }; environment.SWARM_CONTROLLER_SOCKET = cfg.socketPath; + # The swarm's hive directory, JSON-encoded — same shape hive-c0re + # already builds for HYPERHIVE_PEERS (../hive-c0re/environment.nix), + # just the full directory (this daemon has no "self" hive to + # exclude, unlike a per-hive c0re's peer list) rather than + # peers-minus-self. Consumed by `GET /api/hives` + # (swarm-controller/src/main.rs::load_hives). + environment.SWARM_CONTROLLER_HIVES = builtins.toJSON ( + lib.mapAttrsToList (name: h: { + inherit name; + inherit (h) domain; + }) config.services.hyperhive.swarm.hives + ); }; }; } diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 038587e8..0c3b628f 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true axum.workspace = true +serde.workspace = true +serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index cea02ed4..6ff68409 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -3,11 +3,11 @@ //! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a //! unix socket that the hive-gateway's nginx proxies to. //! -//! **Today it serves one endpoint and owns no state.** That is deliberate: -//! this slice exists to make the *unit* real — service user, runtime and -//! state directories, socket, nginx reachability — so the swarm-level -//! surfaces that follow have somewhere to land. Guessing those surfaces -//! now would bake in a shape nobody has agreed to. +//! Holds one piece of read-only state: the swarm's hive directory, loaded +//! once at startup from an env var the NixOS module sets +//! (`services.hyperhive.swarm.controller`) — see [`load_hives`]. Still no +//! persistence and no writes; a config change means a redeploy, same as +//! every other option this process reads. //! //! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on //! one host, this owns what is true across hives. @@ -23,10 +23,12 @@ use std::os::unix::fs::PermissionsExt as _; use std::path::PathBuf; +use std::sync::Arc; use anyhow::{Context, Result}; -use axum::{Json, routing::get}; -use utoipa::OpenApi; +use axum::{Json, extract::State, routing::get}; +use serde::{Deserialize, Serialize}; +use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; /// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`. @@ -50,9 +52,9 @@ fn socket_path() -> PathBuf { } /// Root of the auto-generated `OpenAPI` spec, served raw at -/// `/api/openapi.json` — see the module doc comment above. One tag today -/// (`health`); grows alongside the swarm-level surfaces this daemon picks -/// up, same as `hive-c0re::dashboard::ApiDoc`'s tag list did. +/// `/api/openapi.json` — see the module doc comment above. Tag list +/// grows alongside the swarm-level surfaces this daemon picks up, same +/// as `hive-c0re::dashboard::ApiDoc`'s tag list did. #[derive(OpenApi)] #[openapi( info( @@ -60,7 +62,10 @@ fn socket_path() -> PathBuf { description = "swarm-controller's HTTP surface, served over its unix \ socket behind the gateway's swarm-UI vhost." ), - tags((name = "health", description = "liveness probe")) + tags( + (name = "health", description = "liveness probe"), + (name = "hives", description = "the swarm's hive directory"), + ) )] struct ApiDoc; @@ -76,6 +81,61 @@ async fn health() -> &'static str { concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n") } +/// One hive in the swarm's directory — the same `name`/`domain` pair +/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares, +/// carried across unchanged rather than reshaped, so this stays a direct +/// mirror of the nix source of truth instead of a second vocabulary for +/// the same two fields. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +struct HiveEntry { + name: String, + domain: String, +} + +#[derive(Clone)] +struct AppState { + /// Loaded once at startup ([`load_hives`]); never mutated, so an + /// `Arc` clone per request is the whole synchronization story. + hives: Arc>, +} + +/// Env var the controller's NixOS module sets from +/// `services.hyperhive.swarm.hives`, JSON-encoded — same shape hive-c0re +/// already builds for `HYPERHIVE_PEERS` +/// (nix/host-modules/hive-c0re/environment.nix), just the full directory +/// (this daemon has no "self" to exclude) rather than peers-minus-self. +const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES"; + +/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or +/// unparseable both fall back to an empty list with a warning rather than +/// failing startup — a swarm-controller that can't yet see its own config +/// (dev run, a module not wired up yet) should still serve `/health`. +fn load_hives() -> Vec { + let Some(raw) = std::env::var_os(HIVES_ENV) else { + return Vec::new(); + }; + match serde_json::from_str(&raw.to_string_lossy()) { + Ok(hives) => hives, + Err(e) => { + tracing::warn!(error = %e, env = HIVES_ENV, "failed to parse hive directory, serving an empty list"); + Vec::new() + } + } +} + +/// The swarm's hive directory — every hive, including whichever one this +/// controller instance happens to run on (there is no "self" to exclude +/// at the swarm level, unlike `hive-c0re`'s peer list). +#[utoipa::path( + get, + path = "/api/hives", + responses((status = 200, description = "every hive in the swarm", body = Vec)), + tag = "hives" +)] +async fn get_hives(State(state): State) -> Json> { + Json((*state.hives).clone()) +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -114,17 +174,24 @@ async fn main() -> Result<()> { .with_context(|| format!("chmod {}", path.display()))?; tracing::info!(socket = %path.display(), "swarm-controller listening"); - let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + let state = AppState { + hives: Arc::new(load_hives()), + }; + + let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health)) + .routes(routes!(get_hives)) .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). `api` is // `Clone`; each request gets its own owned copy for `Json` to // serialize, same as `hive-c0re::dashboard::serve`. - let app = router.route( - "/api/openapi.json", - get(move || async move { Json(api.clone()) }), - ); + let app = router + .route( + "/api/openapi.json", + get(move || async move { Json(api.clone()) }), + ) + .with_state(state); axum::serve(listener, app) .await .context("serving swarm-controller") @@ -132,7 +199,7 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::DEFAULT_SOCKET; + use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives}; use std::path::Path; /// The socket must not share a directory with anything else, because @@ -156,4 +223,58 @@ mod tests { exposes everything else in that directory to the gateway's nginx" ); } + + /// One test, not three, deliberately: `HIVES_ENV` is a real process + /// env var, and `cargo test`'s default parallel runner would race + /// separate missing/malformed/valid tests against each other. Driving + /// all three states sequentially inside one test needs no mutex and + /// still proves each branch of `load_hives`. + /// + /// SAFETY: single-threaded mutation of a process env var no other + /// test in this crate reads; restored (removed) before returning. + #[test] + fn load_hives_covers_missing_malformed_and_valid() { + unsafe { + std::env::remove_var(HIVES_ENV); + } + assert_eq!( + load_hives(), + Vec::::new(), + "unset env var is an empty directory, not a startup failure" + ); + + unsafe { + std::env::set_var(HIVES_ENV, "not json"); + } + assert_eq!( + load_hives(), + Vec::::new(), + "unparseable env var falls back to empty rather than panicking — \ + /health must still answer even if this daemon's own config is wrong" + ); + + unsafe { + std::env::set_var( + HIVES_ENV, + r#"[{"name":"pr1ma","domain":"pr1ma.example.com"},{"name":"umbra","domain":"umbra.example.com"}]"#, + ); + } + assert_eq!( + load_hives(), + vec![ + HiveEntry { + name: "pr1ma".to_string(), + domain: "pr1ma.example.com".to_string(), + }, + HiveEntry { + name: "umbra".to_string(), + domain: "umbra.example.com".to_string(), + }, + ] + ); + + unsafe { + std::env::remove_var(HIVES_ENV); + } + } }