dashboard+agent: agent backend owns its nav links; dashboard proxies

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:<sha> 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.
This commit is contained in:
iris 2026-05-23 01:28:46 +02:00 committed by Mara
parent e70ae7776c
commit 222a5b4dc6
5 changed files with 243 additions and 71 deletions

View file

@ -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://<host>:3000<url>`; 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 `<a href="${lnk.url}"${target} style="color: var(--cyan); text-decoration: none;${margin}">${lnk.icon} ${lnk.label} →</a>`;
}).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 || []);

View file

@ -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<crate::events::TokenUsage>,
/// 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<AgentLink>,
}
/// 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://<host>:<container.port><url>`.
Container,
/// `url` is a path on the local Forgejo (`/<label>`,
/// `/agent-configs/<label>`). Both surfaces:
/// `http://<host>:3000<url>`.
Forge,
/// `url` is already a fully-qualified absolute URL — use as-is.
/// Agent-declared `hyperhive.dashboardLinks` extras arrive here.
External,
}
#[derive(Serialize)]
@ -455,7 +490,7 @@ async fn api_loose_ends(State(state): State<AppState>) -> Response {
axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response()
}
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
// Capture seq *before* any reads so the dedupe contract is
// "events with seq > snapshot.seq are post-snapshot, never missed."
let seq = state.bus.current_seq();
@ -502,20 +537,30 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
context_window_tokens,
ctx_usage,
cost_usage,
links: agent_links(&headers, &state.label, state.gui_vnc_port.is_some()),
links: agent_links(&state.label, state.gui_vnc_port.is_some()),
})
}
/// Build the ordered list of navigation links for the agent page header.
/// Stats is always present. Screen is included when the VNC compositor
/// is enabled. Forge is included when the agent has a forge account.
fn agent_links(headers: &HeaderMap, label: &str, gui_enabled: bool) -> Vec<AgentLink> {
/// Build the navigation link list for the agent page header
/// (issue #262). Single source of truth: the dashboard's icon-only
/// nav strip consumes the same list via the host's
/// `GET /api/agent/{name}/links` proxy. URLs are paths (relative)
/// for Container/Forge targets and absolute for External; the
/// frontend resolves each against its `kind` so the backend never
/// has to guess the operator's browser host.
///
/// The agent harness doesn't know its own deployed sha (the meta
/// flake lock lives on the host), so the `config` link points at
/// the repo root; the dashboard renders a `deployed:<sha>` chip
/// alongside the strip so the operator still sees what's live.
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
let mut links = Vec::new();
links.push(AgentLink {
url: "/stats".to_owned(),
icon: "📊".to_owned(),
label: "stats".to_owned(),
kind: AgentLinkKind::Container,
});
if gui_enabled {
@ -523,25 +568,60 @@ fn agent_links(headers: &HeaderMap, label: &str, gui_enabled: bool) -> Vec<Agent
url: "/screen".to_owned(),
icon: "🖥".to_owned(),
label: "screen".to_owned(),
kind: AgentLinkKind::Container,
});
}
if crate::paths::state_dir().join("forge-token").is_file() {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host);
links.push(AgentLink {
url: format!("http://{hostname}:3000/{label}"),
url: format!("/{label}"),
icon: "".to_owned(),
label: "forge".to_owned(),
kind: AgentLinkKind::Forge,
});
links.push(AgentLink {
url: format!("/agent-configs/{label}"),
icon: "".to_owned(),
label: "config".to_owned(),
kind: AgentLinkKind::Forge,
});
}
// Agent-declared extras (`hyperhive.dashboardLinks` → the
// `hive-dashboard-links` NixOS oneshot writes them to
// `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk
// is `{label, icon, url}` with absolute URLs — those become
// `kind = External` links, passed through verbatim.
let extras_path =
crate::paths::state_dir().join("hyperhive-dashboard-links.json");
if let Ok(text) = std::fs::read_to_string(&extras_path)
&& !text.trim().is_empty()
&& let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text)
{
for e in extras {
links.push(AgentLink {
url: e.url,
icon: e.icon,
label: e.label,
kind: AgentLinkKind::External,
});
}
}
links
}
/// On-disk shape of `hyperhive-dashboard-links.json` (the
/// `hive-dashboard-links` NixOS oneshot's output). Mapped to
/// `AgentLink { kind: External }` inside `agent_links`.
#[derive(serde::Deserialize)]
struct ExtraLink {
label: String,
#[serde(default)]
icon: String,
url: String,
}
/// Best-effort: pull the last 30 messages addressed to us via the
/// per-agent / manager socket. Empty list on any transport / decode
/// failure — the inbox section is decorative, not authoritative.