From 222a5b4dc67606f0e68f02b438ade10e8181a46e Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 23 May 2026 01:28:46 +0200 Subject: [PATCH] dashboard+agent: agent backend owns its nav links; dashboard proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous take put a shared NavLink wire type in hive-sh4re and duplicated the link-building logic across crates. Per @mara on #326: that doesn't fit the eventual frontend/backend split goal (#273). The agent backend is the natural source of truth for what links its own page exposes; hive-c0re just passes the list through to the dashboard. * hive-ag3nt/src/web_ui.rs: agent_links now also serves the config-repo link + reads agent-declared dashboardLinks extras from {state_dir}/hyperhive-dashboard-links.json. AgentLink gains a kind enum (Container | Forge | External) so the frontend can build the right href no matter which surface is rendering. The host header is no longer used — URLs are paths for Container/Forge, absolute for External. * hive-c0re/src/dashboard.rs: new GET /api/agent/{name}/links route, a same-origin proxy that fetches the agent's /api/state and forwards just the links field. No shared wire type — hive-c0re treats the payload as opaque JSON (serde_json::Value). All failure modes degrade to an empty list so the dashboard still renders. * hive-c0re/assets/app.js: container card head row gets an async- populated icon-only nav strip from the proxy. The hardcoded stats link, the standalone config-repo trigger, and the extras block are gone. The deployed: chip stays — the agent harness can't know its own deployed sha, so this chip is how the operator sees what's live alongside the agent's (root-only) config link. * hive-ag3nt/assets/app.js: agent page meta-links rendered via el() / textContent (DOM build) so agent-declared icon / label / url strings never reach innerHTML. kind-based href resolution mirrors the dashboard side. * docs/web-ui.md: dashboard + per-agent sections updated for the new architecture. Closes #262. --- docs/web-ui.md | 47 +++++++++++---- hive-ag3nt/assets/app.js | 33 +++++++--- hive-ag3nt/src/web_ui.rs | 120 ++++++++++++++++++++++++++++++------- hive-c0re/assets/app.js | 68 +++++++++++---------- hive-c0re/src/dashboard.rs | 46 ++++++++++++++ 5 files changed, 243 insertions(+), 71 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index b1ecb86..30139fe 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -184,9 +184,19 @@ falls it back to the dimmed hyperhive mark (`/favicon.svg`) instead of an empty box — a real load-failure fallback, not a guess from container state. -- Line 1: agent name (link → new tab), m1nd/ag3nt chip, status - badges — `⊘ rate limited` (red, while the harness is parked - after a 429), `needs login`, `needs update` — in-flight `◐ +- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an + **icon-only nav strip** populated async from the agent backend + (`📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge profile`, + `↳ agent-configs mirror`, plus any agent-declared + `dashboardLinks` extras — issue #262). The dashboard JS fetches + `GET /api/agent/{name}/links`, a same-origin passthrough proxy + that forwards the agent's own link list; the agent backend is + the single source of truth. The frontend resolves each + `AgentLink.kind` (`container` → `http://host:`, + `forge` → `http://host:3000`, `external` → already absolute). + Status badges follow — `⊘ rate limited` (red, while the harness + is parked after a 429), `needs login`, `needs update` — in-flight + `◐ pending-state…` pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port, and a `ctx · Nk` chip showing the agent's last-turn context size @@ -209,12 +219,12 @@ guess from container state. (`journalctl -M -b --no-pager --output=short-iso`). A unit dropdown (harness service / full machine journal) and a refresh button live in the panel. - - `↳ config repo ↗` — link to the agent's applied config repo - on the bundled forge (`agent-configs/`), opened in a - new tab. Shown only when `forge_present`. Replaces the old - one-file `agent.nix` viewer — the forge shows the full repo - with history. Mutating config still requires - `request_apply_commit` + approval. + - Plain navigation links (config repo, forge profile, + `dashboardLinks` extras) now live in the icon-only nav strip + on Line 1 — see above (issue #262). The agent's `config` link + goes to the repo root; the deployed sha shows separately on + Line 1 as the `deployed:` chip, since the agent harness + can't know its own deployed commit. `↻ UPD4TE 4LL` button appears above the containers list when any agent is stale. Banner pulses on each broker SSE event @@ -399,10 +409,21 @@ Layout, top to bottom: - Banner (gradient shimmer while state=thinking). - Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. -- Meta links row: `📊 stats →`, `🖥 screen →` (shown when - `gui_enabled`), and `⬡ forge ↗` (shown when `/api/state` - supplies a non-null `forge_url` — the agent's Forgejo profile, - assembled server-side from the request `Host` header, new tab). +- Meta links row: backend-supplied `StateSnapshot.links` (issue + #262) rendered as ` label →`. Always includes `📊 stats` + (`kind = Container`); `🖥 screen` when the VNC compositor is + enabled; `⬡ forge` (profile) + `↳ config` (agent-configs + mirror, repo root since the agent doesn't know its own deployed + sha) when the agent has a forge account, both `kind = Forge`; + followed by any `hyperhive.dashboardLinks` extras + (`kind = External`) read from + `{state_dir}/hyperhive-dashboard-links.json`. The same list + feeds the dashboard card's icon strip via the host's + `GET /api/agent/{name}/links` passthrough proxy — agent backend + is the single source of truth for what links it exposes. + Frontend resolves each `kind` (`container` → same-origin path, + `forge` → `http://host:3000`, `external` → absolute) via DOM + building, so agent-declared strings never reach `innerHTML`. - Status section: empty when online (alive-badge in the state row carries the signal), populated with the login form / OAuth URL when `status` is `needs_login_*`. diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index ec712a8..eb47893 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -674,14 +674,33 @@ const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } currentLabel = s.label; - // Render server-supplied navigation links (stats, screen, forge, extras). + // Render server-supplied navigation links — stats, screen, the + // forge profile, the agent-configs mirror, plus any + // agent-declared `dashboardLinks` extras (issue #262). Each + // NavLink's `kind` says how to resolve `url`: Container → + // same-origin path (the agent page is itself container-local); + // Forge → `http://:3000`; External → already + // absolute. DOM-built via el() — agent-declared icon / label / + // url strings must NEVER reach innerHTML. const metaLinks = $('meta-links'); - if (metaLinks && s.links) { - metaLinks.innerHTML = (s.links || []).map((lnk, i) => { - const margin = i === 0 ? '' : ' margin-left: 1em;'; - const target = lnk.url.startsWith('http') ? ' target="_blank" rel="noopener"' : ''; - return `${lnk.icon} ${lnk.label} →`; - }).join(''); + if (metaLinks && Array.isArray(s.links)) { + metaLinks.replaceChildren(); + const forgeBase = `http://${window.location.hostname}:3000`; + s.links.forEach((lnk, i) => { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ (lnk.url || ''); + const a = el('a', { + class: 'agent-nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + if (i > 0) a.style.marginLeft = '1em'; + a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →'); + metaLinks.append(a); + }); } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 9c4594c..fde54d3 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{ IntoResponse, Response, sse::{Event, KeepAlive, Sse}, @@ -383,21 +383,56 @@ struct StateSnapshot { /// Cumulative token usage across the most recent turn's inferences /// (cost signal). `null` until the first turn finishes. cost_usage: Option, - /// Navigation links for this agent page. Always includes at least - /// the stats link; conditionally includes screen (gui enabled) and - /// forge (agent has account). Extra agent-specific links land here - /// once the declarative `hyperhive.dashboardLinks` option (#191) - /// ships. Frontend renders only the entries present — no client-side - /// URL construction or icon assignment. + /// Navigation links for this agent page (issue #262). Stats is + /// always present; screen when the VNC compositor is enabled; the + /// forge profile + the agent-configs mirror repo when the agent + /// has a forge account; followed by any agent-declared + /// `hyperhive.dashboardLinks` extras (read from + /// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is + /// already absolute — built server-side from the request `Host` + /// header — so the frontend just renders. + /// + /// This same list is the **source of truth** for the per-agent + /// page *and* the dashboard card's icon-only nav strip: hive-c0re + /// proxies it via `GET /api/agent/{name}/links` (same-origin from + /// the dashboard JS), avoiding CORS and centralising the link + /// definitions in the agent backend. links: Vec, } -/// One navigation link in the agent page header row. +/// One navigation link in the agent page header row. Same JSON +/// shape feeds the dashboard's icon-only nav strip via the host's +/// `GET /api/agent/{name}/links` passthrough proxy, so the agent +/// backend is the single source of truth for what links an agent +/// exposes (issue #262). #[derive(Serialize)] struct AgentLink { + /// `kind = Container | Forge` → path; `kind = External` → full URL. + /// The frontend prepends the right base before rendering. url: String, icon: String, label: String, + kind: AgentLinkKind, +} + +/// Resolution hint for `AgentLink.url`. The agent backend can't know +/// which hostname the browser sees (especially when the dashboard +/// proxies the call from a different origin), so it labels each link +/// and lets the frontend prepend the right base. +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum AgentLinkKind { + /// `url` is a path on the agent's container web UI (`/stats`, + /// `/screen`). Agent page: same-origin path. Dashboard: + /// `http://:`. + Container, + /// `url` is a path on the local Forgejo (`/