diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 006e5b22..4835d0d5 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -89,24 +89,6 @@ 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 deleted file mode 100644 index 68dfc5fd..00000000 --- a/frontend/packages/swarm-ui/src/shell/LinksMenu.css +++ /dev/null @@ -1,54 +0,0 @@ -/* — 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 deleted file mode 100644 index 725a0d4d..00000000 --- a/frontend/packages/swarm-ui/src/shell/LinksMenu.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// — 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 297ba0d2..efdee4be 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -14,7 +14,6 @@ // 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 }[] = [ @@ -41,7 +40,6 @@ 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 33d096f2..2527f453 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -409,16 +409,6 @@ 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 b532c5c6..93b6973d 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -508,20 +508,6 @@ 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 35653e48..3d8a3af5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -485,18 +485,6 @@ 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 30f7ca86..80993a63 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -112,53 +112,6 @@ 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) { @@ -231,10 +184,6 @@ 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 62096f90..87c8f8a1 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -124,19 +124,6 @@ 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 49ab4141..a30bec24 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -65,7 +65,6 @@ 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; @@ -98,9 +97,6 @@ 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 @@ -140,58 +136,6 @@ 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() @@ -232,13 +176,11 @@ 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 @@ -257,9 +199,7 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{ - DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, load_hives, load_links, - }; + use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives}; use std::path::Path; /// The socket must not share a directory with anything else, because @@ -337,49 +277,4 @@ 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); - } - } }