diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 07ca0712..cda6b4fe 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -12,6 +12,7 @@ // has exactly one place that needs to know its own nav, and this is // it. Grows additively as real routes land (a swarm-wide agent roster // is next); no speculative entries. +import { useEffect, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Link, useRoute } from 'wouter-preact'; import { LinksMenu } from './LinksMenu.js'; @@ -24,6 +25,12 @@ const NAV_ITEMS: { href: string; label: string }[] = [ { href: '/components', label: 'components' }, ]; +// Static fallback — matches `index.html`'s `` default, so a page +// never flashes something else before the fetch below resolves, and an +// operator who never set `services.hyperhive.swarm.name` sees the exact +// same generic label the pre-fetch page already showed, not a blank. +const DEFAULT_BRAND = 'hyperhive swarm'; + function NavLink({ href, label }: { href: string; label: string }) { const [active] = useRoute(href); return ( @@ -34,10 +41,34 @@ function NavLink({ href, label }: { href: string; label: string }) { } export function Shell({ children }: { children: ComponentChildren }) { + const [swarmName, setSwarmName] = useState<string | null>(null); + + // Fetched once here, not per-page: every route mounts inside one + // `<Shell>`, and the swarm's name doesn't change within a page + // visit. A fetch failure is silently ignored — `swarmName` just stays + // `null` and the header/title fall back to `DEFAULT_BRAND`, same as + // an operator who never configured a name; this label is cosmetic, + // not worth an `ApiErrorPanel`. + useEffect(() => { + (async () => { + const r = await fetch('/api/swarm'); + if (!r.ok) return; + const data = (await r.json()) as { name: string | null }; + if (data.name) setSwarmName(data.name); + })().catch(() => { + /* cosmetic only — see comment above */ + }); + }, []); + + const brand = swarmName ?? DEFAULT_BRAND; + useEffect(() => { + document.title = brand; + }, [brand]); + return ( <div class="shell"> <header class="shell-header"> - <span class="shell-brand">hyperhive swarm</span> + <span class="shell-brand">{brand}</span> <nav class="shell-nav"> {NAV_ITEMS.map((item) => ( <NavLink key={item.href} href={item.href} label={item.label} /> diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index e57f6b97..f2708b5f 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -141,6 +141,14 @@ let SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl; }; + # The swarm's own display name, for `GET /api/swarm` (swarm-ui's chrome). + # Gated on the option resolving, not defaulted to an empty string: an + # operator who never named the swarm gets `name: null` from the + # endpoint, not a blank label rendered as if it meant something. + swarmNameEnv = lib.optionalAttrs (config.services.hyperhive.swarm.name != null) { + SWARM_CONTROLLER_NAME = config.services.hyperhive.swarm.name; + }; + # Wrapped rather than documented: every one of these values is derived # from an option this deployment already set, so making the operator # re-supply them on the command line would be asking them to repeat the @@ -653,7 +661,8 @@ in // queueEnv // forgeEnv // webhookEnv - // authBridgeEnv; + // authBridgeEnv + // swarmNameEnv; }; # A systemd credential is a SNAPSHOT: it is materialised into `%d` once, diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 5b4539d0..090194e9 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -345,6 +345,13 @@ struct AppState { /// startup, and this way `as_deref()` yields the `&str` the verifier /// takes without a second hop through `String`. webhook_secret: Option<Arc<str>>, + /// The swarm's human display name (`services.hyperhive.swarm.name`), + /// loaded once at startup (`load_swarm_name`). `None` when the + /// operator never set it — a swarm without a display name is a + /// supported, if less friendly, state, not a startup failure. Same + /// `Arc<str>` rationale as `webhook_secret`: never mutated, so a + /// clone per request is just a refcount bump. + swarm_name: Option<Arc<str>>, } /// Env var the controller's NixOS module sets from @@ -435,6 +442,43 @@ async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> { Json((*state.links).clone()) } +/// Env var the controller's NixOS module sets from +/// `services.hyperhive.swarm.name` — unset (rather than an empty string) +/// when the operator never configured it. Consumed by `GET /api/swarm`. +const NAME_ENV: &str = "SWARM_CONTROLLER_NAME"; + +/// Reads [`NAME_ENV`]. `None` on absence — no fallback-and-warn shape like +/// `load_hives`/`load_links` because there is nothing to parse and fail: +/// an unset env var and an operator who never named the swarm are the same +/// state, not an error. +fn load_swarm_name() -> Option<String> { + std::env::var(NAME_ENV).ok() +} + +/// Body of `GET /api/swarm`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +struct SwarmInfo { + /// `None` when the operator never set `services.hyperhive.swarm.name` + /// — a caller (swarm-ui's chrome) falls back to a generic label rather + /// than treating this as an error. + name: Option<String>, +} + +/// The swarm's own display name, for UI chrome (page title, nav) that +/// wants to say which swarm it's showing — distinct from [`get_hives`], +/// which lists the *hives inside* the swarm, not the swarm itself. +#[utoipa::path( + get, + path = "/api/swarm", + responses((status = 200, description = "the swarm's display name, if the operator set one", body = SwarmInfo)), + tag = "hives" +)] +async fn get_swarm_info(State(state): State<AppState>) -> Json<SwarmInfo> { + Json(SwarmInfo { + name: state.swarm_name.as_deref().map(str::to_owned), + }) +} + /// Why the status route answers 503 rather than an empty list. /// /// "I cannot reach the store" and "every hive is silent" are different @@ -702,6 +746,50 @@ fn register_swarm_webhooks(forge: Option<Arc<forge::Client>>, secret: Option<Arc }); } +/// Connect to the swarm queue when this deployment wired one up, extracted +/// out of `main` purely to keep that function under clippy's line-count +/// lint — no behavior split, every comment below is unchanged from where +/// it used to sit inline. +/// +/// Deliberately NOT fatal on failure: the controller's HTTP surface is +/// useful without the queue, and a hive that cannot be read from renders +/// as `unknown` rather than as an outage of this daemon. What IS fatal is +/// a half-set environment — `QueueConfig::from_env` refuses that, because +/// silently behaving like an unconfigured host is how every hive ends up +/// reading `never_reported` with nothing to point at. +async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> { + let Some(cfg) = swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER")? else { + tracing::info!("no swarm queue configured; status aggregation is off"); + return Ok(None); + }; + match swarm_queue_client::connect(cfg).await { + Ok(client) => { + // NOT "connected": `retry_on_initial_connect` returns a client + // before any connection has been established, so claiming a + // connection here would put "connected to the swarm queue" in + // the journal moments before every request 503s with "not + // connected" — and a reader would rightly distrust the second + // line rather than the first. The connection's real state is + // reported by the status endpoint, which checks it per request. + tracing::info!("swarm queue configured; connecting in the background"); + Ok(Some(Arc::new(status::StatusReader::new( + client, + status::StatusReader::stale_after_from_env(), + )))) + } + Err(e) => { + // `chain`, not `{:#}`: this is the queue client's own + // error type, and thiserror's Display ignores the + // alternate flag — the source would be dropped silently. + tracing::warn!( + error = swarm_queue_client::chain(&e), + "swarm queue unreachable" + ); + Ok(None) + } + } +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -740,46 +828,7 @@ async fn main() -> Result<()> { .with_context(|| format!("chmod {}", path.display()))?; tracing::info!(socket = %path.display(), "swarm-controller listening"); - // Connect to the swarm queue when this deployment wired one up. - // - // Deliberately NOT fatal on failure: the controller's HTTP surface is - // useful without the queue, and a hive that cannot be read from renders - // as `unknown` rather than as an outage of this daemon. What IS fatal is - // a half-set environment — `QueueConfig::from_env` refuses that, because - // silently behaving like an unconfigured host is how every hive ends up - // reading `never_reported` with nothing to point at. - let status = match swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER")? { - None => { - tracing::info!("no swarm queue configured; status aggregation is off"); - None - } - Some(cfg) => match swarm_queue_client::connect(cfg).await { - Ok(client) => { - // NOT "connected": `retry_on_initial_connect` returns a client - // before any connection has been established, so claiming a - // connection here would put "connected to the swarm queue" in - // the journal moments before every request 503s with "not - // connected" — and a reader would rightly distrust the second - // line rather than the first. The connection's real state is - // reported by the status endpoint, which checks it per request. - tracing::info!("swarm queue configured; connecting in the background"); - Some(Arc::new(status::StatusReader::new( - client, - status::StatusReader::stale_after_from_env(), - ))) - } - Err(e) => { - // `chain`, not `{:#}`: this is the queue client's own - // error type, and thiserror's Display ignores the - // alternate flag — the source would be dropped silently. - tracing::warn!( - error = swarm_queue_client::chain(&e), - "swarm queue unreachable" - ); - None - } - }, - }; + let status = connect_status_reader().await?; // Same "not fatal, log and carry on" shape as the queue connect above: // a controller with no bridge wired up still serves everything else, @@ -836,6 +885,7 @@ async fn main() -> Result<()> { status, jobq, webhook_secret, + swarm_name: load_swarm_name().map(Arc::from), }; let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()) @@ -843,6 +893,7 @@ async fn main() -> Result<()> { .routes(routes!(get_hives)) .routes(routes!(get_hives_status)) .routes(routes!(get_links)) + .routes(routes!(get_swarm_info)) .routes(routes!(get_jobq_graph)) .routes(routes!(get_jobq_rollup)) .routes(routes!(create_agent)) @@ -866,8 +917,8 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { use super::{ - DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable, - SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node, + DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, NAME_ENV, ServiceLink, StatusUnavailable, + SwarmNodeKind, WorkerDeps, load_hives, load_links, load_swarm_name, run_swarm_node, }; use std::path::Path; @@ -1091,4 +1142,30 @@ mod tests { std::env::remove_var(LINKS_ENV); } } + + /// Two states, not three like `load_hives`/`load_links` above: there is + /// nothing to parse here, so no malformed-input branch exists to cover. + /// + /// SAFETY: single-threaded mutation of a process env var no other test + /// in this crate reads; restored (removed) before returning. + #[test] + fn load_swarm_name_covers_missing_and_set() { + unsafe { + std::env::remove_var(NAME_ENV); + } + assert_eq!( + load_swarm_name(), + None, + "unset env var is an unnamed swarm, not a startup failure" + ); + + unsafe { + std::env::set_var(NAME_ENV, "constellat1on"); + } + assert_eq!(load_swarm_name(), Some("constellat1on".to_string())); + + unsafe { + std::env::remove_var(NAME_ENV); + } + } }