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

@ -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>,