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
|
|
@ -12,6 +12,7 @@
|
||||||
// has exactly one place that needs to know its own nav, and this is
|
// 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
|
// it. Grows additively as real routes land (a swarm-wide agent roster
|
||||||
// is next); no speculative entries.
|
// is next); no speculative entries.
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { Link, useRoute } from 'wouter-preact';
|
import { Link, useRoute } from 'wouter-preact';
|
||||||
import { LinksMenu } from './LinksMenu.js';
|
import { LinksMenu } from './LinksMenu.js';
|
||||||
|
|
@ -24,6 +25,12 @@ const NAV_ITEMS: { href: string; label: string }[] = [
|
||||||
{ href: '/components', label: 'components' },
|
{ href: '/components', label: 'components' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Static fallback — matches `index.html`'s `<title>` 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 }) {
|
function NavLink({ href, label }: { href: string; label: string }) {
|
||||||
const [active] = useRoute(href);
|
const [active] = useRoute(href);
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,10 +41,34 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ children }: { children: ComponentChildren }) {
|
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 (
|
return (
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="shell-header">
|
<header class="shell-header">
|
||||||
<span class="shell-brand">hyperhive swarm</span>
|
<span class="shell-brand">{brand}</span>
|
||||||
<nav class="shell-nav">
|
<nav class="shell-nav">
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<NavLink key={item.href} href={item.href} label={item.label} />
|
<NavLink key={item.href} href={item.href} label={item.label} />
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,14 @@ let
|
||||||
SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl;
|
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
|
# Wrapped rather than documented: every one of these values is derived
|
||||||
# from an option this deployment already set, so making the operator
|
# 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
|
# re-supply them on the command line would be asking them to repeat the
|
||||||
|
|
@ -653,7 +661,8 @@ in
|
||||||
// queueEnv
|
// queueEnv
|
||||||
// forgeEnv
|
// forgeEnv
|
||||||
// webhookEnv
|
// webhookEnv
|
||||||
// authBridgeEnv;
|
// authBridgeEnv
|
||||||
|
// swarmNameEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
# A systemd credential is a SNAPSHOT: it is materialised into `%d` once,
|
# A systemd credential is a SNAPSHOT: it is materialised into `%d` once,
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,13 @@ struct AppState {
|
||||||
/// startup, and this way `as_deref()` yields the `&str` the verifier
|
/// startup, and this way `as_deref()` yields the `&str` the verifier
|
||||||
/// takes without a second hop through `String`.
|
/// takes without a second hop through `String`.
|
||||||
webhook_secret: Option<Arc<str>>,
|
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
|
/// 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())
|
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.
|
/// Why the status route answers 503 rather than an empty list.
|
||||||
///
|
///
|
||||||
/// "I cannot reach the store" and "every hive is silent" are different
|
/// "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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
|
|
@ -740,46 +828,7 @@ async fn main() -> Result<()> {
|
||||||
.with_context(|| format!("chmod {}", path.display()))?;
|
.with_context(|| format!("chmod {}", path.display()))?;
|
||||||
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
||||||
|
|
||||||
// Connect to the swarm queue when this deployment wired one up.
|
let status = connect_status_reader().await?;
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Same "not fatal, log and carry on" shape as the queue connect above:
|
// Same "not fatal, log and carry on" shape as the queue connect above:
|
||||||
// a controller with no bridge wired up still serves everything else,
|
// a controller with no bridge wired up still serves everything else,
|
||||||
|
|
@ -836,6 +885,7 @@ async fn main() -> Result<()> {
|
||||||
status,
|
status,
|
||||||
jobq,
|
jobq,
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
|
swarm_name: load_swarm_name().map(Arc::from),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
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))
|
||||||
.routes(routes!(get_hives_status))
|
.routes(routes!(get_hives_status))
|
||||||
.routes(routes!(get_links))
|
.routes(routes!(get_links))
|
||||||
|
.routes(routes!(get_swarm_info))
|
||||||
.routes(routes!(get_jobq_graph))
|
.routes(routes!(get_jobq_graph))
|
||||||
.routes(routes!(get_jobq_rollup))
|
.routes(routes!(get_jobq_rollup))
|
||||||
.routes(routes!(create_agent))
|
.routes(routes!(create_agent))
|
||||||
|
|
@ -866,8 +917,8 @@ async fn main() -> Result<()> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
|
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, NAME_ENV, ServiceLink, StatusUnavailable,
|
||||||
SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
|
SwarmNodeKind, WorkerDeps, load_hives, load_links, load_swarm_name, run_swarm_node,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -1091,4 +1142,30 @@ mod tests {
|
||||||
std::env::remove_var(LINKS_ENV);
|
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