dashboard: hide forge links instead of guessing <hostname>:3000

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://<browser-hostname>: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.
This commit is contained in:
iris 2026-08-03 00:30:22 +02:00 committed by mara
commit 3512e4b019
10 changed files with 96 additions and 42 deletions

View file

@ -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://<host>:3000<url>`; `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 `<host>:3000`;
`External` → already absolute.
All anchors are built via `el()` — agent-declared icon /
label / url strings never reach `innerHTML` (XSS-safe by
construction).

View file

@ -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://<host>:<container.port>` (direct TCP fallback). Forge
links resolve against `http://<host>: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 `<host>: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.

View file

@ -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 "<hostname>: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 || '');

View file

@ -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 "<hostname>: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) {

View file

@ -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;
}
}

View file

@ -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
// "<hostname>: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 "<hostname>: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 —

View file

@ -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<AgentLink>,
/// 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 `<hostname>: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 `<hostname>:3000`.
forge_public_url: Option<String>,
/// Human name of this hive instance (e.g. `"pr1ma"`). Sourced
/// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend

View file

@ -107,11 +107,15 @@ pub(super) struct StateSnapshot {
/// `http://<hostname>:<port>/` 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://<hostname>: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://<hostname>:3000`,
/// which is only right by accident on deployments that aren't
/// plain localhost.
forge_public_url: Option<String>,
/// Human name of this single-host hive instance (e.g. `"pr1ma"`).
/// Sourced from `HYPERHIVE_HIVE_NAME` env var, set by the c0re

View file

@ -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 `<hostname>: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 `<hostname>: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 `<hostname>: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

View file

@ -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://<lan-host>:''${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;