From d3ca857659361be66498778a58c5d91b859ce2db Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 21:53:33 +0200 Subject: [PATCH] fix(#888): use gateway forge URL in dashboard links Dashboard links to the forge were hardcoded as http://:3000, which breaks when the operator accesses the dashboard through hive-gateway (forge is served at forge. with no port). - nix/modules/hive-c0re.nix: inject HIVE_FORGE_PUBLIC_URL when forge.behindGateway = true (e.g. http://forge.pr1ma.darkest.space) - hive-c0re/src/dashboard.rs: expose forge_url: Option in StateSnapshot, reading from HIVE_FORGE_PUBLIC_URL - frontend/packages/dashboard/src/tabs.js: use state.forge_url when present; fall back to http://:3000 for gateway-off / local-dev deploys --- frontend/packages/agent/src/app.js | 4 +++- frontend/packages/dashboard/src/tabs.js | 12 ++++++++++-- hive-ag3nt/src/web_ui.rs | 7 +++++++ hive-c0re/src/dashboard.rs | 7 +++++++ nix/modules/hive-c0re.nix | 10 ++++++++++ 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 0df8c3d6..2e11ba6d 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1063,7 +1063,9 @@ window.marked = marked; const metaLinks = $('meta-links'); if (metaLinks && Array.isArray(s.links)) { metaLinks.replaceChildren(); - const forgeBase = `http://${window.location.hostname}:3000`; + // 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.links.forEach((lnk, i) => { const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 7afb27ba..0f36b1ba 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -580,7 +580,11 @@ window.marked = marked; // and must never reach the HTML parser. const navStrip = el('span', { class: 'nav-strip' }); head.append(navStrip); - const forgeBase = `http://${hostname}:3000`; + // Forge public URL: prefer state.forge_public_url (set by the NixOS + // module when forge.behindGateway=true, e.g. + // "https://forge.pr1ma.darkest.space"), fall back to + // ":3000" for gateway-off / local-dev deploys. + const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`; // Container nav-strip links resolve against the same base as the // primary `name` link — gateway-prefixed when the gateway is in // front, direct TCP otherwise. Strips the trailing slash so @@ -1786,7 +1790,11 @@ window.marked = marked; // forge link base — only when the hive-forge container is up. const fs = window.__hyperhive_state; const hostname = (fs && fs.hostname) || window.location.hostname; - const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null; + // 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; const ul = el('ul', { class: 'approvals' }); for (const a of pending) { diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 77c2ece3..b9d8b829 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -436,6 +436,12 @@ struct StateSnapshot { /// this directly. 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. + /// `"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`. + forge_public_url: Option, /// Human name of this hive instance (e.g. `"pr1ma"`). Sourced /// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend /// uses this for the page `` and header label so browser @@ -585,6 +591,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> { ctx_usage, cost_usage, links: agent_links(&state.label, state.gui_vnc_port.is_some()), + forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), hive_name: crate::identity::hive_name(), swarm_name: crate::identity::swarm_name(), }) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 1e470a46..b859e8c0 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -250,6 +250,12 @@ struct StateSnapshot { /// local-dev deploys keep working. See `docs/gateway.md::Vhost /// map`. gateway_enabled: bool, + /// Public URL of the forge vhost served by hive-gateway (e.g. + /// `"http://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`. + 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 /// NixOS module from `services.hyperhive.hiveName`. `None` when @@ -466,6 +472,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J let s = v.to_string_lossy().to_ascii_lowercase(); matches!(s.as_str(), "1" | "true" | "yes") }), + forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()), hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()), swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()), }) diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index e260e3ec..c8a98dd3 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -299,6 +299,16 @@ in # When true the dashboard builds same-origin `/agent/<name>/` # links; when false it falls back to direct `<host>:<port>` TCP. HIVE_GATEWAY_ENABLED = "1"; + } + // lib.optionalAttrs (config.services.hyperhive.forge.enable && 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}"; }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}";