From cd41e1d51018d52d188742825e4821e6a82fe775 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 25 Oct 2026 15:05:35 +0100 Subject: [PATCH] swarm-ui, hive dashboard, agent page: one profile menu with the signed-in user, avatar and authelia links The swarm UI's user menu moves to @hive/shared and takes the whoami URL and authelia's URL as props; the swarm UI keeps a thin wrapper, and the hive dashboard and the per-agent page mount the same component. The dashboard's nginx vhost proxies /api/whoami to authelia (the agent page is served from the same vhost), and hive-c0re's state carries authelia_public_url, set from services.hyperhive.swarm.authelia.url. The agent page shows the menu only behind the gateway. --- docs/swarm/ui.md | 4 + docs/web-ui/agent.md | 7 + docs/web-ui/dashboard.md | 9 + frontend/packages/agent/src/Root.tsx | 6 + .../agent/src/components/AgentUserMenu.tsx | 44 +++++ .../packages/agent/src/lib/dashboardBase.ts | 7 +- frontend/packages/dashboard/src/dashboard.css | 7 + frontend/packages/dashboard/src/index.html | 2 + frontend/packages/dashboard/src/tabs.js | 2 + frontend/packages/dashboard/src/user-menu.js | 24 +++ frontend/packages/shared/package.json | 4 +- .../src/user-menu}/UserMenu.css | 2 +- .../shared/src/user-menu/UserMenu.tsx | 122 +++++++++++++ .../packages/shared/src/user-menu/identity.ts | 63 +++++++ .../packages/swarm-ui/src/shell/UserMenu.tsx | 160 ++---------------- .../swarm-ui/test/user-identity.test.mjs | 67 ++++++++ hive-c0re/src/dashboard/state_snapshot.rs | 9 + nix/host-modules/hive-c0re/environment.nix | 6 + nix/host-modules/hive-c0re/vhosts.nix | 26 ++- 19 files changed, 417 insertions(+), 154 deletions(-) create mode 100644 frontend/packages/agent/src/components/AgentUserMenu.tsx create mode 100644 frontend/packages/dashboard/src/user-menu.js rename frontend/packages/{swarm-ui/src/shell => shared/src/user-menu}/UserMenu.css (96%) create mode 100644 frontend/packages/shared/src/user-menu/UserMenu.tsx create mode 100644 frontend/packages/shared/src/user-menu/identity.ts create mode 100644 frontend/packages/swarm-ui/test/user-identity.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 7786a906..5da6dcb0 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -7,6 +7,10 @@ domain, covers host-level detail for one hive. ## What it shows +The signed-in user's avatar sits at the right end of the header on every page. +It opens a menu with the user's name and links to authelia's settings and +sign-out pages; the hive dashboard and the per-agent page have the same menu. + | route | what | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here | diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index de91478f..59dc82bf 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -70,6 +70,13 @@ at a given viewport width. Two columns: unprivileged process can't make directly). - **Inbox / todos pills** (`πŸ“¬ inbox Β· N` / `πŸ“‹ todos Β· N`): hidden when empty; selecting it opens the matching flyout in the side panel. + - **Profile avatar**: the signed-in user's initial, opening a popover with + their name and links to authelia's settings and sign-out pages, as on the + dashboard (`AgentUserMenu.tsx`). It shows only behind the gateway, where + the page shares the dashboard's origin and session; opened on the agent's + own port there is no sign-in and no avatar. The name comes from the + dashboard's `/api/whoami`, authelia's URL from the dashboard's + `/api/state`. - **Links badge** (`πŸ”—`): opens a popover listing `↑ dashboard` (back-link to the host dashboard, `${dashboardBase}`) followed by this agent's `agent_links()`-sourced navigation (stats, diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index a305e2b3..780e5aa9 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -24,6 +24,15 @@ dashboard tab strip. comes from `state.swarm_public_url`, which hive-c0re reads from `HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`); with no domain set there is no swarm entry. The standalone pages link back to `/`. +- **Profile menu**: the signed-in user's avatar at the right end of the + chrome (`user-menu.js`, the same component as the swarm UI's and the agent + page's). It opens a popover with the user's name and links to authelia's + settings and sign-out pages. The name comes from `/api/whoami`, which the + dashboard's nginx vhost proxies to authelia, and the links from + `state.authelia_public_url`, which hive-c0re reads from + `HIVE_AUTHELIA_PUBLIC_URL` (set from `services.hyperhive.swarm.authelia.url`). + If the name doesn't load the avatar shows a question mark, and without the URL the + popover has no links. - **Tab strip**: `β—† 4G3NTS β—†`, `β—† R3BU1LD QU3U3 β—†`, `β—† M3T4 1NPUTS β—†`, `β—† BUILD L0GS β—†`, `β—† K3PT ST4T3 β—†`. In-page tabs only β€” ST4TS lives on its own page, reached from the pages menu. Count pills on diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index f4eec760..831e97ec 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -11,6 +11,7 @@ import { useApplyThemeOverride } from "@hive/shared/theme-apply.js"; import { useApplyMotionOverride } from "@hive/shared/motion-apply.js"; import { Header } from "./components/Header.js"; import { MetaNav } from "./components/MetaNav.js"; +import { AgentUserMenu } from "./components/AgentUserMenu.js"; import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js"; import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js"; import { StatusChips } from "./components/StatusChips.js"; @@ -176,6 +177,11 @@ export function Root() { + {state ? ( + + ) : null} ); // Kept mounted regardless of `openPanel` (open/closed toggles just the diff --git a/frontend/packages/agent/src/components/AgentUserMenu.tsx b/frontend/packages/agent/src/components/AgentUserMenu.tsx new file mode 100644 index 00000000..d0a566fa --- /dev/null +++ b/frontend/packages/agent/src/components/AgentUserMenu.tsx @@ -0,0 +1,44 @@ +// β€” the profile menu (`@hive/shared/user-menu.js`) in this +// page's header. Only behind the gateway: the page is then on the dashboard's +// own origin, where the session cookie is valid and nginx proxies +// `/api/whoami` to authelia. Opened directly on the agent's own port there is +// no sign-in to show, so the page has no menu. +// +// Authelia's URL, for the settings and sign-out links, comes from the +// dashboard's `/api/state` (`authelia_public_url`), the one place hive-c0re +// states it. +import { useEffect, useState } from "preact/hooks"; +import { UserMenu } from "@hive/shared/user-menu.js"; +import "@hive/shared/user-menu.css"; +import { isBehindGateway } from "../lib/dashboardBase.js"; + +export function AgentUserMenu({ dashboardBase }: { dashboardBase: string }) { + const behind = isBehindGateway(); + const [autheliaUrl, setAutheliaUrl] = useState(null); + + useEffect(() => { + if (!behind) return; + let cancelled = false; + fetch(`${dashboardBase}api/state`) + .then((r) => + r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`)), + ) + .then((state: { authelia_public_url?: string | null }) => { + if (!cancelled) setAutheliaUrl(state.authelia_public_url ?? null); + }) + .catch(() => { + /* cosmetic only: the menu then shows the name without the links */ + }); + return () => { + cancelled = true; + }; + }, [behind, dashboardBase]); + + if (!behind) return null; + return ( + + ); +} diff --git a/frontend/packages/agent/src/lib/dashboardBase.ts b/frontend/packages/agent/src/lib/dashboardBase.ts index 61d31446..a4622608 100644 --- a/frontend/packages/agent/src/lib/dashboardBase.ts +++ b/frontend/packages/agent/src/lib/dashboardBase.ts @@ -4,8 +4,13 @@ // a relative path. Behind the gateway this page lives at // `/agent//` on the dashboard's own origin (`location.origin`); // accessed directly, it's the same host on `dashboardPort`. +/** Whether this page is served through the gateway, on the dashboard's origin. */ +export function isBehindGateway(): boolean { + return location.pathname.startsWith("/agent/"); +} + export function resolveDashboardBase(dashboardPort: number): string { - return location.pathname.startsWith("/agent/") + return isBehindGateway() ? location.origin + "/" : `${location.protocol}//${location.hostname}:${dashboardPort}/`; } diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index e11e27bd..d9487002 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -7,6 +7,7 @@ @import "./system-sections.css"; @import "@hive/shared/jobq-rollup.css"; @import "@hive/shared/jobq-graph.css"; +@import "@hive/shared/user-menu.css"; /* ─── tabbed dashboard chrome ────────────────────────────────────── Top-of-page sticky header with banner + tab strip. SSE stays @@ -42,6 +43,12 @@ body.dashboard-shell { padding: 0.3em 1em; margin: 0 0 1em; } +/* Profile menu (user-menu.js): the avatar at the right end of the chrome. + Its own look is `@hive/shared/user-menu.css`'s. */ +.user-menu-slot { + flex: none; + margin-left: auto; +} /* Pages menu (links-menu.js): one icon button at the left of the chrome that opens a popover of the standalone pages and the swarm UI. Same shape as the swarm UI's LinksMenu β€” quiet until hovered or open. The diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 9587234d..a50f5234 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -86,6 +86,8 @@ menu at the left of this strip, not tabs β€” the strip holds only in-page tabs. --> + +