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:
parent
e70ae7776c
commit
222a5b4dc6
5 changed files with 243 additions and 71 deletions
|
|
@ -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
|
instead of an empty box — a real load-failure fallback, not a
|
||||||
guess from container state.
|
guess from container state.
|
||||||
|
|
||||||
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, status
|
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
|
||||||
badges — `⊘ rate limited` (red, while the harness is parked
|
**icon-only nav strip** populated async from the agent backend
|
||||||
after a 429), `needs login`, `needs update` — in-flight `◐
|
(`📊 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:<container.port>`,
|
||||||
|
`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 /
|
pending-state…` pill (replaces buttons during start / stop /
|
||||||
restart / rebuild / destroy), container name + port, and a
|
restart / rebuild / destroy), container name + port, and a
|
||||||
`ctx · Nk` chip showing the agent's last-turn context size
|
`ctx · Nk` chip showing the agent's last-turn context size
|
||||||
|
|
@ -209,12 +219,12 @@ guess from container state.
|
||||||
(`journalctl -M <container> -b --no-pager --output=short-iso`).
|
(`journalctl -M <container> -b --no-pager --output=short-iso`).
|
||||||
A unit dropdown (harness service / full machine journal) and
|
A unit dropdown (harness service / full machine journal) and
|
||||||
a refresh button live in the panel.
|
a refresh button live in the panel.
|
||||||
- `↳ config repo ↗` — link to the agent's applied config repo
|
- Plain navigation links (config repo, forge profile,
|
||||||
on the bundled forge (`agent-configs/<name>`), opened in a
|
`dashboardLinks` extras) now live in the icon-only nav strip
|
||||||
new tab. Shown only when `forge_present`. Replaces the old
|
on Line 1 — see above (issue #262). The agent's `config` link
|
||||||
one-file `agent.nix` viewer — the forge shows the full repo
|
goes to the repo root; the deployed sha shows separately on
|
||||||
with history. Mutating config still requires
|
Line 1 as the `deployed:<sha>` chip, since the agent harness
|
||||||
`request_apply_commit` + approval.
|
can't know its own deployed commit.
|
||||||
|
|
||||||
`↻ UPD4TE 4LL` button appears above the containers list when any
|
`↻ UPD4TE 4LL` button appears above the containers list when any
|
||||||
agent is stale. Banner pulses on each broker SSE event
|
agent is stale. Banner pulses on each broker SSE event
|
||||||
|
|
@ -399,10 +409,21 @@ Layout, top to bottom:
|
||||||
|
|
||||||
- Banner (gradient shimmer while state=thinking).
|
- Banner (gradient shimmer while state=thinking).
|
||||||
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`.
|
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`.
|
||||||
- Meta links row: `📊 stats →`, `🖥 screen →` (shown when
|
- Meta links row: backend-supplied `StateSnapshot.links` (issue
|
||||||
`gui_enabled`), and `⬡ forge ↗` (shown when `/api/state`
|
#262) rendered as `<icon> label →`. Always includes `📊 stats`
|
||||||
supplies a non-null `forge_url` — the agent's Forgejo profile,
|
(`kind = Container`); `🖥 screen` when the VNC compositor is
|
||||||
assembled server-side from the request `Host` header, new tab).
|
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
|
- Status section: empty when online (alive-badge in the state
|
||||||
row carries the signal), populated with the login form /
|
row carries the signal), populated with the login form /
|
||||||
OAuth URL when `status` is `needs_login_*`.
|
OAuth URL when `status` is `needs_login_*`.
|
||||||
|
|
|
||||||
|
|
@ -674,14 +674,33 @@
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
|
||||||
currentLabel = s.label;
|
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');
|
const metaLinks = $('meta-links');
|
||||||
if (metaLinks && s.links) {
|
if (metaLinks && Array.isArray(s.links)) {
|
||||||
metaLinks.innerHTML = (s.links || []).map((lnk, i) => {
|
metaLinks.replaceChildren();
|
||||||
const margin = i === 0 ? '' : ' margin-left: 1em;';
|
const forgeBase = `http://${window.location.hostname}:3000`;
|
||||||
const target = lnk.url.startsWith('http') ? ' target="_blank" rel="noopener"' : '';
|
s.links.forEach((lnk, i) => {
|
||||||
return `<a href="${lnk.url}"${target} style="color: var(--cyan); text-decoration: none;${margin}">${lnk.icon} ${lnk.label} →</a>`;
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||||||
}).join('');
|
: 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');
|
renderTermInput(s.label, s.status === 'online');
|
||||||
renderInbox(s.inbox || []);
|
renderInbox(s.inbox || []);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, Router,
|
Form, Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, StatusCode},
|
http::StatusCode,
|
||||||
response::{
|
response::{
|
||||||
IntoResponse, Response,
|
IntoResponse, Response,
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
|
|
@ -383,21 +383,56 @@ struct StateSnapshot {
|
||||||
/// Cumulative token usage across the most recent turn's inferences
|
/// Cumulative token usage across the most recent turn's inferences
|
||||||
/// (cost signal). `null` until the first turn finishes.
|
/// (cost signal). `null` until the first turn finishes.
|
||||||
cost_usage: Option<crate::events::TokenUsage>,
|
cost_usage: Option<crate::events::TokenUsage>,
|
||||||
/// Navigation links for this agent page. Always includes at least
|
/// Navigation links for this agent page (issue #262). Stats is
|
||||||
/// the stats link; conditionally includes screen (gui enabled) and
|
/// always present; screen when the VNC compositor is enabled; the
|
||||||
/// forge (agent has account). Extra agent-specific links land here
|
/// forge profile + the agent-configs mirror repo when the agent
|
||||||
/// once the declarative `hyperhive.dashboardLinks` option (#191)
|
/// has a forge account; followed by any agent-declared
|
||||||
/// ships. Frontend renders only the entries present — no client-side
|
/// `hyperhive.dashboardLinks` extras (read from
|
||||||
/// URL construction or icon assignment.
|
/// `{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>,
|
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)]
|
#[derive(Serialize)]
|
||||||
struct AgentLink {
|
struct AgentLink {
|
||||||
|
/// `kind = Container | Forge` → path; `kind = External` → full URL.
|
||||||
|
/// The frontend prepends the right base before rendering.
|
||||||
url: String,
|
url: String,
|
||||||
icon: String,
|
icon: String,
|
||||||
label: 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)]
|
#[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()
|
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
|
// Capture seq *before* any reads so the dedupe contract is
|
||||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||||
let seq = state.bus.current_seq();
|
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,
|
context_window_tokens,
|
||||||
ctx_usage,
|
ctx_usage,
|
||||||
cost_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.
|
/// Build the navigation link list for the agent page header
|
||||||
/// Stats is always present. Screen is included when the VNC compositor
|
/// (issue #262). Single source of truth: the dashboard's icon-only
|
||||||
/// is enabled. Forge is included when the agent has a forge account.
|
/// nav strip consumes the same list via the host's
|
||||||
fn agent_links(headers: &HeaderMap, label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
/// `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();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
links.push(AgentLink {
|
links.push(AgentLink {
|
||||||
url: "/stats".to_owned(),
|
url: "/stats".to_owned(),
|
||||||
icon: "📊".to_owned(),
|
icon: "📊".to_owned(),
|
||||||
label: "stats".to_owned(),
|
label: "stats".to_owned(),
|
||||||
|
kind: AgentLinkKind::Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
if gui_enabled {
|
if gui_enabled {
|
||||||
|
|
@ -523,25 +568,60 @@ fn agent_links(headers: &HeaderMap, label: &str, gui_enabled: bool) -> Vec<Agent
|
||||||
url: "/screen".to_owned(),
|
url: "/screen".to_owned(),
|
||||||
icon: "🖥".to_owned(),
|
icon: "🖥".to_owned(),
|
||||||
label: "screen".to_owned(),
|
label: "screen".to_owned(),
|
||||||
|
kind: AgentLinkKind::Container,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if crate::paths::state_dir().join("forge-token").is_file() {
|
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 {
|
links.push(AgentLink {
|
||||||
url: format!("http://{hostname}:3000/{label}"),
|
url: format!("/{label}"),
|
||||||
icon: "⬡".to_owned(),
|
icon: "⬡".to_owned(),
|
||||||
label: "forge".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
|
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
|
/// Best-effort: pull the last 30 messages addressed to us via the
|
||||||
/// per-agent / manager socket. Empty list on any transport / decode
|
/// per-agent / manager socket. Empty list on any transport / decode
|
||||||
/// failure — the inbox section is decorative, not authoritative.
|
/// failure — the inbox section is decorative, not authoritative.
|
||||||
|
|
|
||||||
|
|
@ -642,14 +642,38 @@
|
||||||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
||||||
c.is_manager ? 'm1nd' : 'ag3nt'),
|
c.is_manager ? 'm1nd' : 'ag3nt'),
|
||||||
el('a', {
|
|
||||||
class: 'meta',
|
|
||||||
href: url + 'stats',
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
title: 'per-agent stats page (turn rate, durations, tokens, tool mix)',
|
|
||||||
}, '📊'),
|
|
||||||
);
|
);
|
||||||
|
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
|
||||||
|
// a same-origin proxy that forwards the agent backend's own link list
|
||||||
|
// (stats / screen-if-gui / forge profile / agent-configs / extras).
|
||||||
|
// The agent backend is the single source of truth; no hardcoded link
|
||||||
|
// list here (issue #262). DOM-built — link strings come from the
|
||||||
|
// agent's process and must never reach the HTML parser.
|
||||||
|
const navStrip = el('span', { class: 'nav-strip' });
|
||||||
|
head.append(navStrip);
|
||||||
|
const forgeBase = `http://${hostname}:3000`;
|
||||||
|
const containerBase = `http://${hostname}:${c.port}`;
|
||||||
|
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
|
||||||
|
.then((r) => (r.ok ? r.json() : []))
|
||||||
|
.then((links) => {
|
||||||
|
if (!Array.isArray(links)) return;
|
||||||
|
for (const lnk of links) {
|
||||||
|
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||||||
|
: lnk.kind === 'external' ? (lnk.url || '')
|
||||||
|
: /* container */ containerBase + (lnk.url || '');
|
||||||
|
const a = el('a', {
|
||||||
|
class: 'meta nav-link',
|
||||||
|
href,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
|
title: lnk.label || '',
|
||||||
|
});
|
||||||
|
// Plain text — agent-controlled strings stay out of innerHTML.
|
||||||
|
a.textContent = lnk.icon || lnk.label || '';
|
||||||
|
navStrip.append(a);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* graceful: agent down → no strip */ });
|
||||||
if (pending) {
|
if (pending) {
|
||||||
head.append(el('span', { class: 'pending-state' },
|
head.append(el('span', { class: 'pending-state' },
|
||||||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||||||
|
|
@ -751,32 +775,14 @@
|
||||||
// narrows to the harness service (or empty = full machine).
|
// narrows to the harness service (or empty = full machine).
|
||||||
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
||||||
drill.append(buildJournalTrigger(c.container, journalUnit));
|
drill.append(buildJournalTrigger(c.container, journalUnit));
|
||||||
// Applied config now lives on the forge — link to the
|
// The hardcoded config-repo trigger and the agent-declared
|
||||||
// agent-configs mirror repo instead of a one-file viewer.
|
// extras block both moved into the unified nav strip in the
|
||||||
if (s && s.forge_present) {
|
// head row above (sourced from the agent backend via
|
||||||
drill.append(el('a', {
|
// `/api/agent/{name}/links` — issue #262). Only the journald
|
||||||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
// trigger stays here since it opens the side panel rather
|
||||||
href: `http://${hostname}:3000/agent-configs/${c.name}`,
|
// than a link.
|
||||||
title: 'applied config repo on the hive forge',
|
|
||||||
}, '↳ config repo ↗'));
|
|
||||||
}
|
|
||||||
body.append(drill);
|
body.append(drill);
|
||||||
|
|
||||||
// ── extra agent-declared links ───────────────────────────────
|
|
||||||
if (c.extra_links && c.extra_links.length > 0) {
|
|
||||||
const extras = el('div', { class: 'drill-ins drill-ins-extra' });
|
|
||||||
for (const lnk of c.extra_links) {
|
|
||||||
extras.append(el('a', {
|
|
||||||
class: 'panel-trigger',
|
|
||||||
href: lnk.url,
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
title: lnk.url,
|
|
||||||
}, (lnk.icon ? lnk.icon + ' ' : '') + lnk.label + ' ↗'));
|
|
||||||
}
|
|
||||||
body.append(extras);
|
|
||||||
}
|
|
||||||
|
|
||||||
li.append(icon, body);
|
li.append(icon, body);
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
.route("/api/approval-diff/{id}", get(get_approval_diff))
|
||||||
.route("/api/state-file", get(get_state_file))
|
.route("/api/state-file", get(get_state_file))
|
||||||
.route("/api/reminders", get(api_reminders))
|
.route("/api/reminders", get(api_reminders))
|
||||||
|
.route("/api/agent/{name}/links", get(get_agent_links))
|
||||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
|
|
@ -1381,6 +1382,51 @@ async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same-origin proxy that fetches the named agent's
|
||||||
|
/// `GET /api/state` and forwards only the `links` field to the
|
||||||
|
/// dashboard JS (issue #262). Lets the agent backend stay the
|
||||||
|
/// single source of truth for its own nav links: the dashboard
|
||||||
|
/// card's icon-only strip and the per-agent page's labelled row
|
||||||
|
/// render the same list, no shared Rust wire type required, no
|
||||||
|
/// CORS surface on the agent side.
|
||||||
|
///
|
||||||
|
/// Failure modes (agent down, slow response, malformed JSON) all
|
||||||
|
/// degrade to an empty list so the dashboard still renders.
|
||||||
|
async fn get_agent_links(AxumPath(name): AxumPath<String>) -> Response {
|
||||||
|
let port = lifecycle::agent_web_port(&name);
|
||||||
|
let url = format!("http://127.0.0.1:{port}/api/state");
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%name, error = %e, "agent-links proxy: client build failed");
|
||||||
|
return axum::Json(serde_json::json!([])).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(body) => {
|
||||||
|
let links = body.get("links").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||||
|
axum::Json(links).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(%name, error = %e, "agent-links proxy: response parse failed");
|
||||||
|
axum::Json(serde_json::json!([])).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(resp) => {
|
||||||
|
tracing::debug!(%name, status = %resp.status(), "agent-links proxy: non-2xx");
|
||||||
|
axum::Json(serde_json::json!([])).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(%name, error = %e, "agent-links proxy: fetch failed");
|
||||||
|
axum::Json(serde_json::json!([])).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_cancel_reminder(
|
async fn post_cancel_reminder(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<i64>,
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue