fix(#1112): build agent nav-links from disk; drop broken TCP proxy
get_agent_links was proxying to http://127.0.0.1:{port}/api/state. Since all agents now bind exclusively to a unix socket (HIVE_WEB_SOCKET set unconditionally in harness-base.nix), the TCP fetch always fails silently, returning [] — so the nav-strip icons on every card disappear. Replace with container_view::build_nav_links(), which reconstructs the same link list from disk: - stats.html always (container kind) - /{name} and /agent-configs/{name} when forge-token is present (forge kind) - extras from hyperhive-dashboard-links.json (external kind) The GUI screen link is intentionally omitted — /etc/hyperhive/gui.json lives inside the agent container and is not host-visible. GUI agents are rare; the omission is acceptable until a host-visible marker lands. No new dependencies. reqwest is no longer used in dashboard.rs for this handler (still used by forge.rs and hivectl.rs).
This commit is contained in:
parent
84b42f5751
commit
cd3ba24c3d
2 changed files with 58 additions and 39 deletions
|
|
@ -202,6 +202,57 @@ pub fn claude_has_session(dir: &Path) -> bool {
|
||||||
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the navigation link list for an agent's dashboard card.
|
||||||
|
///
|
||||||
|
/// Mirrors the logic in `hive_ag3nt::web_ui::agent_links` but runs on
|
||||||
|
/// the host via disk reads — no network call to the agent web UI needed.
|
||||||
|
/// The GUI screen link is omitted here; `/etc/hyperhive/gui.json` lives
|
||||||
|
/// inside the agent container and is not host-visible.
|
||||||
|
///
|
||||||
|
/// Returns a `serde_json::Value` array matching the `AgentLink` JSON
|
||||||
|
/// shape the harness returns from `GET /api/state`, so
|
||||||
|
/// `dashboard::get_agent_links` can serve it directly.
|
||||||
|
pub fn build_nav_links(name: &str) -> serde_json::Value {
|
||||||
|
let state_dir = Coordinator::agent_notes_dir(name);
|
||||||
|
let mut links: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
// Stats page — always present.
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": "stats.html",
|
||||||
|
"icon": "📊",
|
||||||
|
"label": "stats",
|
||||||
|
"kind": "container",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Forge profile + config mirror — only when the agent has a forge account.
|
||||||
|
if state_dir.join("forge-token").is_file() {
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": format!("/{name}"),
|
||||||
|
"icon": "⬡",
|
||||||
|
"label": "forge",
|
||||||
|
"kind": "forge",
|
||||||
|
}));
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": format!("/agent-configs/{name}"),
|
||||||
|
"icon": "↳",
|
||||||
|
"label": "config",
|
||||||
|
"kind": "forge",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent-declared extras (absolute URLs — passed through verbatim).
|
||||||
|
for lnk in read_dashboard_links(name) {
|
||||||
|
links.push(serde_json::json!({
|
||||||
|
"url": lnk.url,
|
||||||
|
"icon": lnk.icon,
|
||||||
|
"label": lnk.label,
|
||||||
|
"kind": "external",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::Value::Array(links)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read agent-declared extra dashboard links from
|
/// Read agent-declared extra dashboard links from
|
||||||
/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when
|
/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when
|
||||||
/// the file is absent, empty, or unparseable — best-effort, never panics.
|
/// the file is absent, empty, or unparseable — best-effort, never panics.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ use tokio_stream::{Stream, StreamExt};
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::actions;
|
use crate::actions;
|
||||||
use crate::container_view::{ContainerView, claude_has_session};
|
use crate::container_view::{self, ContainerView, claude_has_session};
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
use crate::lifecycle::{self, MANAGER_NAME};
|
use crate::lifecycle::{self, MANAGER_NAME};
|
||||||
|
|
||||||
|
|
@ -2122,47 +2122,15 @@ async fn post_schedule_cancel(
|
||||||
/// Failure modes (agent down, slow response, malformed JSON) all
|
/// Failure modes (agent down, slow response, malformed JSON) all
|
||||||
/// degrade to an empty list so the dashboard still renders.
|
/// degrade to an empty list so the dashboard still renders.
|
||||||
async fn get_agent_links(AxumPath(name): AxumPath<String>) -> Response {
|
async fn get_agent_links(AxumPath(name): AxumPath<String>) -> Response {
|
||||||
// Format-only guard. GET routes return empty gracefully on
|
// Format-only guard. Unknown/malformed names get an empty list.
|
||||||
// unknown names; bad format we reject early so the downstream
|
|
||||||
// port-hash + HTTP fetch never sees garbage.
|
|
||||||
if validate_agent_name(&name).is_some() {
|
if validate_agent_name(&name).is_some() {
|
||||||
return axum::Json(serde_json::json!([])).into_response();
|
return axum::Json(serde_json::json!([])).into_response();
|
||||||
}
|
}
|
||||||
let port = lifecycle::agent_web_port(&name);
|
// Links are built from disk — no TCP call to the agent web UI.
|
||||||
let url = format!("http://127.0.0.1:{port}/api/state");
|
// The old TCP proxy broke when all agents switched to unix-socket
|
||||||
let client = match reqwest::Client::builder()
|
// binding (HIVE_WEB_SOCKET). See container_view::build_nav_links
|
||||||
.timeout(std::time::Duration::from_secs(2))
|
// for the full rationale.
|
||||||
.build()
|
axum::Json(container_view::build_nav_links(&name)).into_response()
|
||||||
{
|
|
||||||
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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue