diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 4835d0d5..006e5b22 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -89,6 +89,24 @@ parent — no CA in the hierarchy issues for it implicitly. Left out, the vhost falls back to the hive leaf and the swarm's front page opens with a name mismatch. +## Quick links + +The swarm UI's header carries a single 🔗 button, visible on every route, +opening a popover of links to other swarm-wide services — authelia, +matrix, forge, this UI's own swagger docs. Backed by `GET /api/links` +(swarm-controller), which serves `services.hyperhive.swarm.controller.links` +(a `listOf { label, icon, url }`, same shape as the per-agent +`hyperhive.dashboardLinks`). + +Rather than one central hardcoded list, each service's own module +contributes its own entry when it's actually enabled on the controller's +host — `swarm-authelia.nix`, `hive-matrix.nix` and `hive-forge/default.nix` +all do, the same list-merge idiom `gateway.localNames` uses above. Adding a +link for a new service is a nix-only change to that service's own module +(or an operator adding an entry directly); no swarm-controller or swarm-ui +change needed. Empty list hides the button rather than showing an empty +popover. + ## Cross-references - [`sso.md`](sso.md) — the authelia instance itself, and the user store. diff --git a/frontend/packages/swarm-ui/src/shell/LinksMenu.css b/frontend/packages/swarm-ui/src/shell/LinksMenu.css new file mode 100644 index 00000000..68dfc5fd --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/LinksMenu.css @@ -0,0 +1,54 @@ +/* — a single header icon-button + popover. Kept visually + quiet (no border/fill until interacted with) so it reads as chrome, + not another nav item. */ +.links-menu { + position: relative; + margin-left: auto; +} +.links-menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 2em; + height: 2em; + padding: 0; + border: 1px solid transparent; + border-radius: 0.4em; + background: none; + color: var(--fg); + font-size: 1em; + line-height: 1; + cursor: pointer; +} +.links-menu-button:hover, +.links-menu-button[aria-expanded='true'] { + border-color: var(--border); + background: var(--bg-elev); +} +.links-menu-popover { + position: absolute; + top: calc(100% + 0.4em); + right: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 10em; + padding: 0.4em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg-elev); + box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3); +} +.links-menu-item { + display: flex; + align-items: center; + gap: 0.5em; + padding: 0.4em 0.6em; + border-radius: 0.35em; + color: var(--fg); + text-decoration: none; + white-space: nowrap; +} +.links-menu-item:hover { + background: var(--bg); +} diff --git a/frontend/packages/swarm-ui/src/shell/LinksMenu.tsx b/frontend/packages/swarm-ui/src/shell/LinksMenu.tsx new file mode 100644 index 00000000..725a0d4d --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/LinksMenu.tsx @@ -0,0 +1,87 @@ +// — the "accessible from every screen" affordance for +// swarm-wide services (authelia, matrix, forge, this UI's own API +// docs, …): one fixed header button that never grows the header +// itself as the list grows, opening a popover with everything +// `GET /api/links` returns. Lives in `Shell` (not `ui/`) because it +// owns its own fetch — every other `ui/` primitive is presentational +// only, this one genuinely isn't reusable outside this one job. +// +// The list is entirely server-driven (see swarm-controller's +// `services.hyperhive.swarm.controller.links` option) — 0 entries +// hides the button rather than showing an empty popover, same "don't +// render a dead affordance" rule the old dashboard's H0M3 tiles follow +// for Forge/Matrix. +import { useEffect, useRef, useState } from 'preact/hooks'; +import './LinksMenu.css'; + +interface ServiceLink { + label: string; + icon: string; + url: string; +} + +export function LinksMenu() { + const [links, setLinks] = useState(null); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + fetch('/api/links') + .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(`http ${r.status}`)))) + .then(setLinks) + .catch(() => setLinks([])); // best-effort: no button beats a broken one + }, []); + + // Close on an outside click or Escape — only listens while open, so + // this costs nothing on every other render. + useEffect(() => { + if (!open) return; + function onPointerDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + if (!links || links.length === 0) return null; + + return ( + + ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index efdee4be..297ba0d2 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -14,6 +14,7 @@ // is next); no speculative entries. import type { ComponentChildren } from 'preact'; import { Link, useRoute } from 'wouter-preact'; +import { LinksMenu } from './LinksMenu.js'; import './Shell.css'; const NAV_ITEMS: { href: string; label: string }[] = [ @@ -40,6 +41,7 @@ export function Shell({ children }: { children: ComponentChildren }) { ))} +
{children}
diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 2527f453..33d096f2 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -409,6 +409,16 @@ in # vhost nor answer DNS for it. services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain; + # This swarm-ui quick-links entry, same `behindGateway` guard as the + # vhost/DNS name above — with it off, this host doesn't actually + # serve `cfg.domain`, so linking to it would be dead. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = lib.optional cfg.behindGateway { + label = "Forge"; + icon = "⚒"; + url = "https://${cfg.domain}/"; + }; + # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # clones). SSH stays direct on `forge.sshPort`. See diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 93b6973d..b532c5c6 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -508,6 +508,20 @@ in # every clause below carries that guard. services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost; + # This swarm-ui quick-links entry. Gated on `gui.enable` too, not just + # `gatewayHost != null`: `/` on that vhost only serves fluffychat + # (below) when the GUI is on — otherwise the link would 404, the same + # reason the old dashboard's H0M3 page hides its Matrix tile on + # `state.matrix_gui_enabled` rather than `gatewayHost` alone. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = + lib.optional (cfg.gatewayHost != null && cfg.gui.enable) + { + label = "Matrix"; + icon = "💬"; + url = "https://${cfg.gatewayHost}/"; + }; + # Accept-header SPA map, used only by the `/` location below (see # docs/gateway.md "SPA fallback"): text/html → index.html, else a # sentinel so `try_files` falls through to 404. `appendHttpConfig` diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 3d8a3af5..35653e48 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -485,6 +485,18 @@ in # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This swarm-ui quick-links entry, same guard as the vhost/DNS name + # above (only the host actually running the container claims it — + # see `services.hyperhive.swarm.controller.links`'s description for + # the contribute-your-own-entry idiom). + services.hyperhive.swarm.controller.links = [ + { + label = "Authelia"; + icon = "🔑"; + url = "https://${cfg.domain}/"; + } + ]; + # `server_name = authelia.domain`, all of `/` → authelia. # # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 80993a63..30f7ca86 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -112,6 +112,53 @@ in new directory, not just the daemon. ''; }; + + links = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + label = lib.mkOption { + type = lib.types.str; + description = "Display label for the link."; + }; + icon = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional icon emoji or short glyph."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "Full URL."; + }; + }; + } + ); + default = [ ]; + example = lib.literalExpression '' + [ { label = "Wiki"; icon = "📖"; url = "https://wiki.example.com/"; } ] + ''; + description = '' + Quick links to swarm-wide services, surfaced by the swarm UI's + links menu (`GET /api/links`). Same shape and same + zero-code-change-to-extend idea as `hyperhive.dashboardLinks` + (`nix/agent-modules/dashboard-links.nix`), one level up: rather + than one central hardcoded list, each service's own module + contributes its own entry when it is actually enabled on this + host — `swarm-authelia.nix`, `hive-matrix.nix` and + `hive-forge/default.nix` all do — the same list-merge idiom + `services.hyperhive.gateway.localNames` already uses. A future + service module can push its own entry the same way, and an + operator can add arbitrary extra entries here directly; neither + needs a swarm-controller or swarm-ui change. + + Only meaningful on the host that actually runs the controller — + entries contributed on any other host are computed but never + read. In a swarm that splits `swarm-authelia`/`hive-matrix`/ + `hive-forge` across hosts other than the controller's, this list + only reflects what is enabled locally; see each contributing + module's own activation condition. + ''; + }; }; config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { @@ -184,6 +231,10 @@ in inherit (h) domain; }) config.services.hyperhive.swarm.hives ); + # The merged links list — see `links`' description above for who + # contributes to it. Consumed by `GET /api/links` + # (swarm-controller/src/main.rs::load_links). + environment.SWARM_CONTROLLER_LINKS = builtins.toJSON cfg.links; }; }; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 87c8f8a1..62096f90 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -124,6 +124,19 @@ in # makes the name resolve at all. services.hyperhive.gateway.localNames = [ cfg.domain ]; + # This UI's own swagger docs, always same-origin (`/api/docs/` below) + # so — unlike authelia/matrix/forge's entries — this one needs no + # host name and is never conditional on anything but this module + # being enabled at all. See + # `services.hyperhive.swarm.controller.links`'s description. + services.hyperhive.swarm.controller.links = [ + { + label = "API docs"; + icon = "🧬"; + url = "/api/docs/"; + } + ]; + # The swarm's front page, and the FIRST `auth_request` anywhere in # this gateway (everything else is `auth_basic` + htpasswd). # diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a30bec24..49ab4141 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -65,6 +65,7 @@ fn socket_path() -> PathBuf { tags( (name = "health", description = "liveness probe"), (name = "hives", description = "the swarm's hive directory"), + (name = "links", description = "swarm service quick links"), ) )] struct ApiDoc; @@ -97,6 +98,9 @@ struct AppState { /// Loaded once at startup (`load_hives`); never mutated, so an /// `Arc` clone per request is the whole synchronization story. hives: Arc>, + /// Loaded once at startup (`load_links`); same synchronization story + /// as `hives`. + links: Arc>, } /// Env var the controller's NixOS module sets from @@ -136,6 +140,58 @@ async fn get_hives(State(state): State) -> Json> { Json((*state.hives).clone()) } +/// One quick link to a swarm-wide service (authelia, matrix, forge, this +/// daemon's own swagger UI, …). Deliberately generic rather than named +/// fields per service: each service's own nix module contributes its own +/// entry to `services.hyperhive.swarm.controller.links` (same list-merge +/// idiom `services.hyperhive.gateway.localNames` already uses), so adding +/// a new one is a nix-only change — no new field here, no swarm-ui change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +struct ServiceLink { + label: String, + /// Emoji or short glyph. Empty string, not `Option`, when a + /// contributing module has none — one fewer null-vs-absent case for + /// the frontend to handle, and every real contributor sets one today. + icon: String, + url: String, +} + +/// Env var the controller's NixOS module sets from the merged +/// `services.hyperhive.swarm.controller.links` list, JSON-encoded — same +/// shape/rationale as [`HIVES_ENV`]. Consumed by `GET /api/links`. +const LINKS_ENV: &str = "SWARM_CONTROLLER_LINKS"; + +/// Parses [`LINKS_ENV`] into the swarm's service-link list. Same +/// fall-back-to-empty rationale as `load_hives`: a daemon that can't yet +/// see this config should still serve `/health` rather than fail startup. +fn load_links() -> Vec { + let Some(raw) = std::env::var_os(LINKS_ENV) else { + return Vec::new(); + }; + match serde_json::from_str(&raw.to_string_lossy()) { + Ok(links) => links, + Err(e) => { + tracing::warn!(error = %e, env = LINKS_ENV, "failed to parse service links, serving an empty list"); + Vec::new() + } + } +} + +/// Quick links to swarm-wide services (authelia, matrix, forge, this +/// daemon's own swagger UI, …), as contributed by each service's own nix +/// module. Empty when nothing was configured to contribute — a caller +/// renders 0 links the same way it renders any other count, no special +/// "not configured" case. +#[utoipa::path( + get, + path = "/api/links", + responses((status = 200, description = "swarm service quick links", body = Vec)), + tag = "links" +)] +async fn get_links(State(state): State) -> Json> { + Json((*state.links).clone()) +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -176,11 +232,13 @@ async fn main() -> Result<()> { let state = AppState { hives: Arc::new(load_hives()), + links: Arc::new(load_links()), }; let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) .routes(routes!(health)) .routes(routes!(get_hives)) + .routes(routes!(get_links)) .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 @@ -199,7 +257,9 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives}; + use super::{ + DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, load_hives, load_links, + }; use std::path::Path; /// The socket must not share a directory with anything else, because @@ -277,4 +337,49 @@ mod tests { std::env::remove_var(HIVES_ENV); } } + + /// Same three-state coverage as `load_hives_covers_missing_malformed_and_valid`, + /// same reason for one test rather than three (a shared process env var). + /// + /// SAFETY: single-threaded mutation of a process env var no other test + /// in this crate reads; restored (removed) before returning. + #[test] + fn load_links_covers_missing_malformed_and_valid() { + unsafe { + std::env::remove_var(LINKS_ENV); + } + assert_eq!( + load_links(), + Vec::::new(), + "unset env var is an empty list, not a startup failure" + ); + + unsafe { + std::env::set_var(LINKS_ENV, "not json"); + } + assert_eq!( + load_links(), + Vec::::new(), + "unparseable env var falls back to empty rather than panicking" + ); + + unsafe { + std::env::set_var( + LINKS_ENV, + r#"[{"label":"Authelia","icon":"🔑","url":"https://auth.example.com/"}]"#, + ); + } + assert_eq!( + load_links(), + vec![ServiceLink { + label: "Authelia".to_string(), + icon: "🔑".to_string(), + url: "https://auth.example.com/".to_string(), + }] + ); + + unsafe { + std::env::remove_var(LINKS_ENV); + } + } }