dashboard: SPA shell — static index.html + app.js, /api/state JSON

This commit is contained in:
müde 2026-05-15 17:10:57 +02:00
parent 8428c693e0
commit 6fc9862c3c
5 changed files with 464 additions and 280 deletions

View file

@ -21,7 +21,7 @@ use axum::{
routing::{get, post},
};
use hive_sh4re::Approval;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
@ -38,7 +38,10 @@ struct AppState {
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
let app = Router::new()
.route("/", get(index))
.route("/", get(serve_index))
.route("/static/dashboard.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/api/state", get(api_state))
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
.route("/destroy/{name}", post(post_destroy))
@ -58,46 +61,170 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
Ok(())
}
async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String> {
// ---------------------------------------------------------------------------
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
// returns the current snapshot as JSON. The JS app fetches state on load,
// re-fetches after every async-form submit, and listens on
// `/messages/stream` for broker traffic.
// ---------------------------------------------------------------------------
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../assets/index.html"))
}
async fn serve_css() -> impl IntoResponse {
([("content-type", "text/css")], include_str!("../assets/dashboard.css"))
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
#[derive(Serialize)]
struct StateSnapshot {
hostname: String,
manager_port: u16,
any_stale: bool,
containers: Vec<ContainerView>,
transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
}
#[derive(Serialize)]
#[allow(clippy::struct_excessive_bools)]
struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
name: String,
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
container: String,
is_manager: bool,
port: u16,
running: bool,
needs_update: bool,
needs_login: bool,
}
#[derive(Serialize)]
struct TransientView {
name: String,
kind: &'static str,
secs: u64,
}
#[derive(Serialize)]
struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the `commit_ref`, for `ApplyCommit` only.
sha_short: Option<String>,
/// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only.
diff_html: Option<String>,
}
async fn api_state(
headers: HeaderMap,
State(state): State<AppState>,
) -> axum::Json<StateSnapshot> {
let host = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let hostname = host.split(':').next().unwrap_or(host).to_owned();
let containers = lifecycle::list().await.unwrap_or_default();
let transient = state.coord.transient_snapshot();
let raw_containers = lifecycle::list().await.unwrap_or_default();
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
let mut running: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
for c in &containers {
let logical = c
.strip_prefix(lifecycle::AGENT_PREFIX)
.unwrap_or(c.as_str())
.to_owned();
running.insert(c.clone(), lifecycle::is_running(&logical).await);
}
let transient_snapshot = state.coord.transient_snapshot();
let approvals = gc_orphans(
&state.coord,
state.coord.approvals.pending().unwrap_or_default(),
);
let approvals_html = render_approvals(&approvals).await;
// Auto-refresh the dashboard root while there's a spawn in flight, so the
// operator sees the new agent show up in the container list without
// having to reload manually. 2s is a reasonable poll interval for
// nixos-container create + start, which usually finishes in <30s.
let refresh = if transient.is_empty() {
String::new()
} else {
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
};
let mut containers = Vec::new();
let mut any_stale = false;
for c in &raw_containers {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let needs_update = current_rev
.as_deref()
.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
if needs_update {
any_stale = true;
}
let needs_login = if is_manager {
false
} else {
!claude_has_session(&Coordinator::agent_claude_dir(&logical))
};
containers.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
});
}
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers =
render_containers(&containers, &running, &transient, current_rev.as_deref(), &hostname),
))
let transients = transient_snapshot
.into_iter()
.filter(|(name, _)| {
!raw_containers
.iter()
.any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == name)
})
.map(|(name, st)| TransientView {
name,
kind: match st.kind {
crate::coordinator::TransientKind::Spawning => "spawning",
},
secs: st.since.elapsed().as_secs(),
})
.collect();
let mut approval_views = Vec::with_capacity(approvals.len());
for a in approvals {
let view = match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, &a.commit_ref).await;
ApprovalView {
id: a.id,
agent: a.agent,
kind: "apply_commit",
sha_short: Some(sha),
diff_html: Some(render_diff_lines(&diff)),
}
}
hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent,
kind: "spawn",
sha_short: None,
diff_html: None,
},
};
approval_views.push(view);
}
axum::Json(StateSnapshot {
hostname,
manager_port: MANAGER_PORT,
any_stale,
containers,
transients,
approvals: approval_views,
})
}
async fn messages_stream(
@ -236,140 +363,12 @@ async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<St
}
fn error_response(message: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(format!(
"<!doctype html>\n<html>\n<head>{STYLE}</head>\n<body>{BANNER}\n<h2>◆ ERR0R ◆</h2>\n<pre class=\"diff\">{message}</pre>\n<p><a href=\"/\">← back</a></p>\n</body></html>",
message = html_escape(message),
)),
)
.into_response()
// Plain text — the JS app surfaces this in an alert(), so HTML
// wrapping would just clutter the message.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}
fn render_containers(
containers: &[String],
running: &std::collections::HashMap<String, bool>,
transient: &std::collections::HashMap<String, crate::coordinator::TransientState>,
current_rev: Option<&str>,
hostname: &str,
) -> String {
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
// "update all" header button only when at least one container is stale.
if let Some(rev) = current_rev {
let any_stale = containers.iter().any(|c| {
let logical = c.strip_prefix(AGENT_PREFIX).unwrap_or(c);
crate::auto_update::agent_needs_update(logical, rev)
});
if any_stale {
out.push_str("<form method=\"POST\" action=\"/update-all\" class=\"inline\" data-async data-confirm=\"rebuild every stale container?\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ UPD4TE 4LL</button></form>\n");
}
}
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\" data-async>\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
// Render in-flight spawns first so the operator sees feedback immediately.
if !transient.is_empty() {
out.push_str("<ul>\n");
for (name, state) in transient {
// Skip names that already exist in `containers` (race: spawn finished
// between transient set and list refresh).
if containers.iter().any(|c| c == &format!("h-{name}")) {
continue;
}
let secs = state.since.elapsed().as_secs();
let label = match state.kind {
crate::coordinator::TransientKind::Spawning => "spawning…",
};
let _ = writeln!(
out,
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
);
}
out.push_str("</ul>\n");
}
if containers.is_empty() && transient.is_empty() {
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
return out;
}
out.push_str("<ul>\n");
for container in containers {
let is_running = running.get(container).copied().unwrap_or(false);
if container == MANAGER_NAME {
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
let restart_btn = if is_running {
format!(
" <form method=\"POST\" action=\"/restart/{MANAGER_NAME}\" class=\"inline\" data-async data-confirm=\"restart manager?\"><button class=\"btn btn-restart\" type=\"submit\">↺ R3ST4RT</button></form>\n",
)
} else {
String::new()
};
let _ = writeln!(
out,
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span>\n{restart_btn} <form method=\"POST\" action=\"/rebuild/{MANAGER_NAME}\" class=\"inline\" data-async data-confirm=\"rebuild manager? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n</li>",
);
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
let port = lifecycle::agent_web_port(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let login_badge = if claude_has_session(&claude_dir) {
String::new()
} else {
format!(
" <a class=\"role role-pending\" href=\"http://{hostname}:{port}/\">needs login →</a>",
)
};
let update_badge = update_badge_for(name, current_rev);
let running_buttons = if is_running {
format!(
" <form method=\"POST\" action=\"/restart/{name}\" class=\"inline\" data-async data-confirm=\"restart {name}?\"><button class=\"btn btn-restart\" type=\"submit\">↺ R3ST4RT</button></form>\n <form method=\"POST\" action=\"/kill/{name}\" class=\"inline\" data-async data-confirm=\"stop {name}?\"><button class=\"btn btn-stop\" type=\"submit\">■ ST0P</button></form>\n",
)
} else {
String::new()
};
let _ = writeln!(
out,
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n{running_buttons} <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" data-async data-confirm=\"destroy {name}? container is removed; state + creds kept.\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
);
}
}
out.push_str("</ul>\n");
out
}
async fn render_approvals(approvals: &[Approval]) -> String {
let mut out = String::from(
"<h2>◆ P3NDING APPR0VALS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
if approvals.is_empty() {
out.push_str("<p class=\"empty\">▓ queue empty ▓</p>\n");
return out;
}
out.push_str("<ul class=\"approvals\">\n");
for a in approvals {
match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
let diff = approval_diff(&a.agent, &a.commit_ref).await;
let diff_html = render_diff_lines(&diff);
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff_html}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
);
}
hive_sh4re::ApprovalKind::Spawn => {
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
id = a.id,
agent = a.agent,
);
}
}
}
out.push_str("</ul>\n");
out
}
/// Filter out approvals whose agent state dir was wiped out from under us
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
@ -417,20 +416,6 @@ fn render_diff_lines(diff: &str) -> String {
out
}
/// Returns either an empty string (agent is up-to-date / no rev known) or
/// a clickable "needs update" badge whose form POSTs to /rebuild/<name>.
fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
let Some(rev) = current_rev else {
return String::new();
};
if !crate::auto_update::agent_needs_update(name, rev) {
return String::new();
}
format!(
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
)
}
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the
/// agent's bound `~/.claude/` dir on disk contains any regular file. The
/// dashboard reads this each render so logins driven from the agent web UI
@ -499,38 +484,3 @@ fn html_escape(s: &str) -> String {
.replace('>', "&gt;")
}
const BANNER: &str = r#"<pre class="banner">
HYPERHIVE HIVE-C0RE WE ARE THE WIRED
</pre>"#;
/// Generic async submit + spinner for any `<form data-async>`. Replaces
/// the standard form-POST navigation: button shows a spinner during the
/// request, `data-confirm` runs first (skips the action if cancelled),
/// page reloads on success so the new state is reflected.
const ASYNC_FORMS_JS: &str = concat!(
"<script>\n",
include_str!("../assets/async_forms.js"),
"</script>",
);
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider"></div>
<p class="meta">live tail newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
<div id="msgflow" class="msgflow"><span class="meta">connecting</span></div>"#;
const MSG_FLOW_JS: &str = concat!(
"<script>\n",
include_str!("../assets/msg_flow.js"),
"</script>",
);
const FOOTER: &str = r#"<footer>
<div class="divider"></div>
<p> <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> hive-c0re on this host </p>
</footer>"#;
const STYLE: &str = concat!(
"<style>\n",
include_str!("../assets/dashboard.css"),
"</style>",
);