harness+dashboard: declarative dashboardLinks option (closes #191)

This commit is contained in:
damocles 2026-05-21 23:06:44 +02:00 committed by Mara
parent f510a321df
commit 66c481a07a
3 changed files with 109 additions and 1 deletions

View file

@ -723,6 +723,21 @@
} }
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);
} }

View file

@ -9,11 +9,22 @@ use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
/// An agent-declared extra navigation link surfaced on the dashboard card.
/// Written by the `hive-dashboard-links` NixOS oneshot into
/// `{state_dir}/hyperhive-dashboard-links.json` and read by `build_all`.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
pub struct DashboardLink {
pub label: String,
#[serde(default)]
pub icon: String,
pub url: String,
}
#[derive(Serialize, Clone, PartialEq, Eq, Debug)] #[derive(Serialize, Clone, PartialEq, Eq, Debug)]
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
pub struct ContainerView { pub struct ContainerView {
@ -50,6 +61,13 @@ pub struct ContainerView {
/// removes when it resumes. Stale by up to one crash-watch cycle. /// removes when it resumes. Stale by up to one crash-watch cycle.
#[serde(default)] #[serde(default)]
pub rate_limited: bool, pub rate_limited: bool,
/// Extra navigation links declared by the agent via
/// `hyperhive.dashboardLinks` in `agent.nix`. Written to
/// `{state_dir}/hyperhive-dashboard-links.json` by the
/// `hive-dashboard-links` oneshot at container boot. Empty when
/// the file is absent or the agent declares no links.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_links: Vec<DashboardLink>,
} }
/// Build the full container list. Wraps `lifecycle::list()` and /// Build the full container list. Wraps `lifecycle::list()` and
@ -87,6 +105,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.unwrap_or(0); .unwrap_or(0);
let ctx_tokens = read_last_ctx_tokens(&logical); let ctx_tokens = read_last_ctx_tokens(&logical);
let rate_limited = is_rate_limited(&logical); let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical);
out.push(ContainerView { out.push(ContainerView {
port: lifecycle::agent_web_port(&logical), port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await, running: lifecycle::is_running(&logical).await,
@ -99,6 +118,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
pending_reminders, pending_reminders,
ctx_tokens, ctx_tokens,
rate_limited, rate_limited,
extra_links,
}); });
} }
out out
@ -117,6 +137,18 @@ 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()))
} }
/// Read agent-declared extra dashboard links from
/// `{state_dir}/hyperhive-dashboard-links.json`. Returns an empty vec when
/// the file is absent, empty, or unparseable — best-effort, never panics.
fn read_dashboard_links(name: &str) -> Vec<DashboardLink> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-dashboard-links.json");
let text = match std::fs::read_to_string(&path) {
Ok(t) if !t.trim().is_empty() => t,
_ => return Vec::new(),
};
serde_json::from_str::<Vec<DashboardLink>>(&text).unwrap_or_default()
}
/// Returns true if the agent's harness is currently parked after an API /// Returns true if the agent's harness is currently parked after an API
/// rate-limit response. Detected via the sentinel file written by /// rate-limit response. Detected via the sentinel file written by
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`. /// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.

View file

@ -173,6 +173,39 @@
''; '';
}; };
options.hyperhive.dashboardLinks = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
label = lib.mkOption {
type = lib.types.str;
description = "Display label for the link.";
};
icon = lib.mkOption {
type = lib.types.str;
default = "";
description = "Optional icon emoji or short glyph.";
};
url = lib.mkOption {
type = lib.types.str;
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
};
};
});
default = [ ];
example = lib.literalExpression ''
[
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
]
'';
description = ''
Extra navigation links surfaced on the hive-c0re dashboard card for
this agent. Declare any additional web UI pages the agent exposes
stats pages, custom UIs, etc. hive-c0re reads the JSON file this
option produces at each container-view snapshot and attaches the
links to the agent card without any code changes.
'';
};
options.hyperhive.claudeMarketplaces = lib.mkOption { options.hyperhive.claudeMarketplaces = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = [ "anthropics/claude-plugins-official" ]; default = [ "anthropics/claude-plugins-official" ];
@ -480,6 +513,34 @@
''; '';
}; };
# Write declared dashboardLinks to the state dir so hive-c0re can read
# them without accessing the container's /etc/ from the host.
# Runs every boot; idempotent (overwrite). Always exits 0.
systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) {
description = "write declarative dashboardLinks to agent state dir";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks;
script = ''
STATE_DIR=""
for d in /state /agents/*/state; do
if [ -d "$d" ]; then
STATE_DIR="$d"
break
fi
done
if [ -z "$STATE_DIR" ]; then
echo "hive-dashboard-links: no state dir found; skipping"
exit 0
fi
printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json"
echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json"
'';
};
# Git is needed by claude's Bash tool (for the agent <-> manager config # Git is needed by claude's Bash tool (for the agent <-> manager config
# request flow) and by hive-c0re's own setup_applied / setup_proposed. # request flow) and by hive-c0re's own setup_applied / setup_proposed.
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and # The per-agent `applied/<name>/flake.nix` overrides `user.name` and