From 8542f2ca42d09bcfc00fa007a795a69b7e9de71e Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 15 Aug 2026 13:47:31 +0200 Subject: [PATCH] swarm-ui: header links menu for swarm-wide services (hyperhive#3289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single 🔗 button in the Shell header (direction 1 of 3 proposed on the issue) — one fixed element regardless of link count, so it can't clutter as the list grows, and visible on every route since Shell wraps all of them. Opens a popover listing whatever GET /api/links returns (icon + label, opens in a new tab); hidden entirely when the list is empty, same "don't render a dead affordance" rule the old dashboard's H0M3 tiles follow for Forge/Matrix. Docs: docs/swarm/ui.md gets a short section on the feature + the contribute-your-own-entry idiom. Verified: npm run build + npm run typecheck clean, headless-chromium screenshots of both the closed and (temporarily forced open for the screenshot only) open states, pixel-sampled to confirm the popover is actually using the dark theme vars and not just looking that way in a downscaled preview. --- docs/swarm/ui.md | 18 ++++ .../packages/swarm-ui/src/shell/LinksMenu.css | 54 ++++++++++++ .../packages/swarm-ui/src/shell/LinksMenu.tsx | 87 +++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.tsx | 2 + 4 files changed, 161 insertions(+) create mode 100644 frontend/packages/swarm-ui/src/shell/LinksMenu.css create mode 100644 frontend/packages/swarm-ui/src/shell/LinksMenu.tsx 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}