From 3512e4b0194f3807520c42573077e6c5d1a6aee3 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 3 Aug 2026 00:30:22 +0200 Subject: [PATCH] dashboard: hide forge links instead of guessing :3000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds services.hyperhive.forge.publicUrl (defaults to the gateway vhost URL when behindGateway=true, null otherwise). HIVE_FORGE_PUBLIC_URL is now sourced from it instead of hardcoding https://${forge.domain} whenever behindGateway is on. The 4 frontend call sites that built a forge link from state.forge_public_url now hide the link when that's absent, rather than guessing http://:3000 — a guess that's only correct by accident once the operator isn't on plain localhost. Fixes the dashboard H0M3 tile, per-agent-row forge links + agent menu, the approval-queue PR link, and the per-agent page's own meta-nav forge link (found during this pass, same defect, not in the original 3-site inventory). Docs + doc-comments updated to match. --- docs/web-ui/agent.md | 5 +++- docs/web-ui/dashboard.md | 17 +++++++----- frontend/packages/agent/src/app.js | 9 ++++--- frontend/packages/dashboard/src/call.js | 10 +++---- frontend/packages/dashboard/src/home.js | 14 +++++----- frontend/packages/dashboard/src/swarm.js | 13 ++++++--- hive-agent/src/web_ui/state.rs | 9 ++++--- hive-c0re/src/dashboard/state_snapshot.rs | 10 ++++--- nix/host-modules/hive-c0re/environment.nix | 20 +++++++------- nix/host-modules/hive-forge/default.nix | 31 ++++++++++++++++++++++ 10 files changed, 96 insertions(+), 42 deletions(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index ff51a5d9..33c3aeb4 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -35,7 +35,10 @@ through. Three flex columns: Each `NavLink.kind` resolves differently in the frontend: `Container` → same-origin path (the agent page is itself container-local); `Forge` → - `http://:3000`; `External` → already absolute. + `state.forge_public_url + url` (sourced from + `services.hyperhive.forge.publicUrl`), and the link is omitted + entirely when that's unset — never guessed from `:3000`; + `External` → already absolute. All anchors are built via `el()` — agent-declared icon / label / url strings never reach `innerHTML` (XSS-safe by construction). diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 989b4692..e2bed64c 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -728,11 +728,14 @@ is a pure portal with no tab-bar or SSE subscriptions. Typography + colours inherit from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`). Optional tiles are hidden until `home.js` confirms their availability: Matrix is hidden until `home.js` confirms `matrix_gui_enabled` (same gating as -the dashboard's M4TR1X tab); Forge is hidden until `home.js` confirms -`state.forge_present` and fills the href from `state.forge_public_url` (the -gateway-served public URL when `services.hyperhive.forge.behindGateway=true`) -or falls back to the direct `:3000` port. Operators without matrix or forge -enabled never see dead links. `home.js` also fills the swarm/hive identity +the dashboard's M4TR1X tab); Forge is hidden until `home.js` confirms both +`state.forge_present` **and** `state.forge_public_url` (sourced from +`services.hyperhive.forge.publicUrl`, which defaults to the gateway vhost URL +when `behindGateway=true` and is `null` otherwise) and fills the href from the +latter — never guessed from the operator's browser hostname + a container +port, which is only right by accident off plain localhost. Operators without +matrix or forge enabled — or with forge on but no public URL configured — +never see a dead or wrong link. `home.js` also fills the swarm/hive identity line at the top. Dashboard is now served at `/dashboard.html` (route swap completed in #1464 step 2); the home page at `/` replaces the old dashboard root. All dashboard sub-pages include a `← Home` back-link for navigation. @@ -835,7 +838,9 @@ fetch entirely. `docs/gateway.md::Per-agent unix-socket upstream`). Gateway-off (legacy / local dev): base URL is `http://:` (direct TCP fallback). Forge - links resolve against `http://:3000`, external links are + links resolve against `state.forge_public_url` (sourced from + `services.hyperhive.forge.publicUrl`) and are omitted entirely when + that's unset — never guessed from `:3000`. External links are already absolute. The same base URL drives the primary agent-name link + favicon fetch, so the whole row routes through the gateway as a unit. diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 94661e0e..d5862ee5 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1230,10 +1230,13 @@ window.marked = marked; const metaLinks = $('meta-links'); if (metaLinks && Array.isArray(s.links)) { metaLinks.replaceChildren(); - // Prefer forge_public_url from state (set when the gateway serves - // forge at its sub-domain); fall back to :3000 for local-dev. - const forgeBase = s.forge_public_url || `http://${window.location.hostname}:3000`; + // s.forge_public_url (set from services.hyperhive.forge.publicUrl) + // or falsy — never guessed from ":3000". A forge-kind + // link is skipped entirely below when there's no public URL to + // point it at. + const forgeBase = s.forge_public_url || null; s.links.forEach((lnk, i) => { + if (lnk.kind === 'forge' && !forgeBase) return; const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') : /* container */ (lnk.url || ''); diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 34902847..3ad1f7c1 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -242,12 +242,10 @@ export function renderApprovals() { } // forge link base — only when the hive-forge container is up. const fs = window.__hyperhive_state; - const hostname = (fs && fs.hostname) || window.location.hostname; - // Prefer state.forge_public_url (set when forge.behindGateway=true, - // e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port. - const forgeBase = (fs && fs.forge_present) - ? (fs.forge_public_url || `http://${hostname}:3000`) - : null; + // state.forge_public_url (set from services.hyperhive.forge.publicUrl) + // or null — never guessed from ":3000". The PR-link builder + // below already gates on forgeBase being truthy. + const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null; const ul = el('ul', { class: 'approvals' }); for (const a of pending) { diff --git a/frontend/packages/dashboard/src/home.js b/frontend/packages/dashboard/src/home.js index fc4918e1..ce020a77 100644 --- a/frontend/packages/dashboard/src/home.js +++ b/frontend/packages/dashboard/src/home.js @@ -30,14 +30,16 @@ async function init() { if (tile) tile.hidden = false; } - // Forge tile: reveal + point at the live forge only when the - // hive-forge container is up. Prefer the gateway-served public URL - // (set when forge.behindGateway=true), fall back to the direct :3000 - // port — same precedence the dashboard uses for forge links. - if (state.forge_present) { + // Forge tile: reveal only when the hive-forge container is up AND + // the operator has stated a public URL for it + // (services.hyperhive.forge.publicUrl, surfaced as + // state.forge_public_url) — hidden rather than guessed from + // `location.hostname` + the direct :3000 port, which is only + // correct by accident on deployments that aren't plain localhost. + if (state.forge_present && state.forge_public_url) { const tile = $('home-tile-forge'); if (tile) { - tile.href = state.forge_public_url || `http://${location.hostname}:3000`; + tile.href = state.forge_public_url; tile.hidden = false; } } diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index fbfdb266..120313bb 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -414,6 +414,9 @@ function buildContainerLi(c, node, opts) { // -- nav strip ----------------------------------------------- if (Array.isArray(ds.links)) { for (const lnk of ds.links) { + // No public forge URL to link to — hide rather than build + // a broken/guessed href (see forgeBase above). + if (lnk.kind === 'forge' && !forgeBase) continue; const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') : /* container */ containerBase + '/' + (lnk.url || ''); @@ -637,10 +640,12 @@ export function renderContainers(s) { // loopback port while the socket marker is absent). See // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`. const gatewayLinks = !!(s && s.gateway_enabled); - // Forge public URL: prefer state.forge_public_url (set by the NixOS - // module when forge.behindGateway=true), fall back to - // ":3000" for gateway-off / local-dev deploys. - const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; + // Forge public URL: state.forge_public_url (set by the NixOS module + // from services.hyperhive.forge.publicUrl) or null — never guessed + // from ":3000". `null` propagates down to + // buildContainerLi/buildAgentMenu, which already hide forge links + // rather than emit a possibly-wrong one. + const forgeBase = (s && s.forge_public_url) || null; const ul = existingUl ?? el('ul', { class: 'containers' }); const tree = buildAgentTree(containers); // In-flight rebuild / meta-update / destroy ops per agent name — diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index 9cb001be..c2296477 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -153,11 +153,12 @@ pub(super) struct StateSnapshot { /// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`] /// for the frontend resolver + which links appear in which conditions. links: Vec, - /// Public URL of the forge served by hive-gateway (e.g. + /// Public URL of the forge (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from - /// `HIVE_FORGE_PUBLIC_URL`; `None` when `forge.behindGateway=false` - /// or the env var is absent. The frontend uses this to build forge - /// nav-strip links instead of hardcoding `:3000`. + /// `HIVE_FORGE_PUBLIC_URL` (set from `services.hyperhive.forge. + /// publicUrl`); `None` when unset. The frontend uses this to build + /// forge nav-strip links, and **hides** the forge link entirely + /// when absent rather than guessing `:3000`. forge_public_url: Option, /// Human name of this hive instance (e.g. `"pr1ma"`). Sourced /// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index ee668f79..8b4c5d1a 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -107,11 +107,15 @@ pub(super) struct StateSnapshot { /// `http://:/` TCP links) is retained as a defensive /// fallback for the env being unset. See `docs/gateway.md::Vhost map`. gateway_enabled: bool, - /// Public URL of the forge vhost served by hive-gateway (e.g. + /// Public URL of the forge (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from the /// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module - /// sets when `forge.behindGateway = true`. `None` when absent — - /// the frontend falls back to `http://:3000`. + /// sets from `services.hyperhive.forge.publicUrl` (defaults to + /// the gateway vhost URL when `forge.behindGateway = true`, + /// `null` otherwise). `None` when absent — the frontend **hides** + /// forge links rather than guessing `http://:3000`, + /// which is only right by accident on deployments that aren't + /// plain localhost. forge_public_url: Option, /// Human name of this single-host hive instance (e.g. `"pr1ma"`). /// Sourced from `HYPERHIVE_HIVE_NAME` env var, set by the c0re diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 2e2fc22e..3021eb18 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -127,15 +127,17 @@ in # dashboard doesn't need to learn the gateway is unconditional. HIVE_GATEWAY_ENABLED = "1"; } -// lib.optionalAttrs config.services.hyperhive.forge.behindGateway { - # Public URL of the forge vhost served by hive-gateway. The - # dashboard uses this to build browser-facing forge links - # instead of hardcoding `:3000`, which breaks when - # the operator accesses the dashboard through the gateway - # (forge sub-domain has no port; direct port URL would be - # wrong). Absent when `behindGateway = false` — dashboard - # falls back to `:3000`. - HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}"; +// lib.optionalAttrs (config.services.hyperhive.forge.publicUrl != null) { + # Public URL of the forge, for the dashboard to build browser-facing + # forge links from instead of guessing `:3000` (which + # breaks the moment the operator's browser hostname isn't the forge + # host, e.g. through the gateway or a reverse proxy). Sourced from + # `services.hyperhive.forge.publicUrl`, which itself defaults to the + # gateway vhost URL when `behindGateway = true` and `null` otherwise + # — see that option's doc for the "hide, don't guess" rationale. + # Absent here whenever `publicUrl` is `null`; the dashboard hides + # forge links rather than emitting one it can't justify. + HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.forge.publicUrl; } // lib.optionalAttrs diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index d8ff1d4f..ea1fe885 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -130,6 +130,37 @@ in ''; }; + publicUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = + if config.services.hyperhive.enable && cfg.behindGateway then "https://${cfg.domain}" else null; + defaultText = lib.literalExpression '' + if behindGateway then "https://''${domain}" else null + ''; + example = "https://forge.example.com"; + description = '' + Browser-facing forge URL the dashboard uses to build clickable + forge links (the H0M3 Forge tile, per-agent-row forge links, + the approval-queue's "review PR on forge" link) — sourced into + every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`. + + Defaults to `https://''${cfg.domain}` when `behindGateway = + true` (the gateway vhost is genuinely reachable at that URL) + and `null` otherwise. When `null`, the dashboard **hides** + forge links rather than guessing one — see + `docs/web-ui/dashboard.md::H0M3 page` for the rationale (a + link built from the operator's own browser hostname + a + container port is only an accident away from wrong on any + deployment that isn't plain localhost). + + **Set this explicitly if `behindGateway = false`** and the + forge is still reachable at a stable URL you want linked from + the dashboard (e.g. `http://:''${toString cfg.httpPort}` + for an all-LAN deployment) — leaving it unset there means the + dashboard's forge links are simply absent, not broken. + ''; + }; + package = lib.mkOption { type = lib.types.package; default = pkgs.forgejo;