swarm-ui: show the swarm's name as the page title and top-left brand

New GET /api/swarm on swarm-controller, backed by
services.hyperhive.swarm.name (SWARM_CONTROLLER_NAME env var, same
optionalAttrs-gated-on-option-resolving shape queueEnv/forgeEnv/etc.
already use). swarm-ui's <Shell> fetches it once and sets both
document.title and the header's brand text; falls back to the
existing static "hyperhive swarm" label when the operator never set
a name or the fetch fails.

Extracted the swarm-queue connect block out of main() into its own
connect_status_reader() fn to keep main() under clippy's line-count
lint after adding the new field wiring — no behavior change, same
comments moved as-is.
This commit is contained in:
iris 2026-08-18 17:58:30 +02:00 committed by mara
commit 3643eccf22
3 changed files with 161 additions and 44 deletions

View file

@ -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);
}
}
}