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:
parent
d14963ad2b
commit
3643eccf22
3 changed files with 161 additions and 44 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue