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
|
|
@ -642,14 +642,38 @@
|
|||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-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) {
|
||||
head.append(el('span', { class: 'pending-state' },
|
||||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||||
|
|
@ -751,32 +775,14 @@
|
|||
// narrows to the harness service (or empty = full machine).
|
||||
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
||||
drill.append(buildJournalTrigger(c.container, journalUnit));
|
||||
// Applied config now lives on the forge — link to the
|
||||
// agent-configs mirror repo instead of a one-file viewer.
|
||||
if (s && s.forge_present) {
|
||||
drill.append(el('a', {
|
||||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||
href: `http://${hostname}:3000/agent-configs/${c.name}`,
|
||||
title: 'applied config repo on the hive forge',
|
||||
}, '↳ config repo ↗'));
|
||||
}
|
||||
// The hardcoded config-repo trigger and the agent-declared
|
||||
// extras block both moved into the unified nav strip in the
|
||||
// head row above (sourced from the agent backend via
|
||||
// `/api/agent/{name}/links` — issue #262). Only the journald
|
||||
// trigger stays here since it opens the side panel rather
|
||||
// than a link.
|
||||
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);
|
||||
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/state-file", get(get_state_file))
|
||||
.route("/api/reminders", get(api_reminders))
|
||||
.route("/api/agent/{name}/links", get(get_agent_links))
|
||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||
.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(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue