//! Hyperhive dashboard. Lists managed containers (with deep-links to each //! container's web UI), pending approvals (with unified diff vs the applied //! repo, plus approve/deny buttons), and the manager. use std::convert::Infallible; use std::fmt::Write as _; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; use axum::extract::Form; use axum::{ Router, extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::{ Html, IntoResponse, Redirect, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use hive_sh4re::{Approval, MANAGER_AGENT, Message}; use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; use crate::actions; use crate::coordinator::Coordinator; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; const MANAGER_PORT: u16 = 8000; #[derive(Clone)] struct AppState { coord: Arc, } pub async fn serve(port: u16, coord: Arc) -> Result<()> { let app = Router::new() .route("/", get(index)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) .route("/rebuild/{name}", post(post_rebuild)) .route("/request-spawn", post(post_request_spawn)) .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) .await .with_context(|| format!("bind dashboard on port {port}"))?; tracing::info!(%port, "dashboard listening"); axum::serve(listener, app).await?; Ok(()) } async fn index(headers: HeaderMap, State(state): State) -> Html { 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 current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); 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 { "".to_owned() }; Html(format!( "\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname), talk = render_talk(&containers), )) } #[derive(Deserialize)] struct SendForm { to: String, body: String, } async fn post_send(State(state): State, Form(form): Form) -> Response { let to = form.to.trim().to_owned(); let body = form.body.trim().to_owned(); if to.is_empty() || body.is_empty() { return error_response("send: `to` and `body` required"); } let msg = Message { from: "operator".into(), to, body, }; match state.coord.broker.send(&msg) { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("send failed: {e:#}")), } } async fn messages_stream( State(state): State, ) -> Sse>> { let rx = state.coord.broker.subscribe(); let stream = BroadcastStream::new(rx).filter_map(|res| { // Drop lagged events. Browsers reconnect; nothing to do here. let event = res.ok()?; let json = serde_json::to_string(&event).ok()?; Some(Ok(Event::default().data(json))) }); Sse::new(stream).keep_alive(KeepAlive::default()) } async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { match actions::approve(state.coord.clone(), id).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } } async fn post_deny(State(state): State, AxumPath(id): AxumPath) -> Response { match actions::deny(&state.coord, id) { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("deny {id} failed: {e:#}")), } } #[derive(Deserialize)] struct RequestSpawnForm { name: String, } async fn post_request_spawn( State(state): State, Form(form): Form, ) -> Response { let name = form.name.trim().to_owned(); if name.is_empty() { return error_response("spawn: `name` required"); } match state .coord .approvals .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "") { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); Redirect::to("/").into_response() } Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), } } async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake) else { return error_response( "rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`", ); }; let result = crate::auto_update::rebuild_agent(&state.coord, &name, ¤t_rev).await; match result { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")), } } async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { match actions::destroy(&state.coord, &name).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), } } fn error_response(message: &str) -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, Html(format!( "\n\n{STYLE}\n{BANNER}\n

◆ ERR0R ◆

\n
{message}
\n

← back

\n", message = html_escape(message), )), ) .into_response() } fn render_containers( containers: &[String], transient: &std::collections::HashMap, current_rev: Option<&str>, hostname: &str, ) -> String { let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); out.push_str("
\n \n \n
\n

spawn requests queue as approvals. operator approves below to actually create the container.

\n"); // Render in-flight spawns first so the operator sees feedback immediately. if !transient.is_empty() { out.push_str("
    \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, "
  • {name} {label} nixos-container create + start ({secs}s)
  • ", ); } out.push_str("
\n"); } if containers.is_empty() && transient.is_empty() { out.push_str("

▓ no managed containers ▓

\n"); return out; } out.push_str("
    \n"); for container in containers { if container == MANAGER_NAME { let update_badge = update_badge_for(MANAGER_NAME, current_rev); let _ = writeln!( out, "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}
  • ", ); } 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!( " needs login →", ) }; let update_badge = update_badge_for(name, current_rev); let _ = writeln!( out, "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
    \n
  • ", ); } } out.push_str("
\n"); out } async fn render_approvals(approvals: &[Approval]) -> String { let mut out = String::from( "

◆ P3NDING APPR0VALS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); if approvals.is_empty() { out.push_str("

▓ queue empty ▓

\n"); return out; } out.push_str("
    \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 _ = writeln!( out, "
  • \n
    #{id} {agent} apply {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff}
    \n
  • ", id = a.id, agent = a.agent, diff = html_escape(&diff), ); } hive_sh4re::ApprovalKind::Spawn => { let _ = writeln!( out, "
  • \n
    #{id} {agent} spawn new sub-agent — container will be created on approve\n
    \n
    \n
    \n
  • ", id = a.id, agent = a.agent, ); } } } out.push_str("
\n"); out } fn render_talk(containers: &[String]) -> String { let mut options = String::new(); let _ = writeln!( options, "", ); for container in containers { if container == MANAGER_NAME { continue; } if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let _ = writeln!(options, ""); } } format!( "

◆ T4LK ◆

\n
══════════════════════════════════════════════════════════════
\n
\n \n \n \n
\n

sends as from: operator. Replies stream into the message panel below.

\n" ) } /// 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 /// `pending` on next render. fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { approvals .into_iter() .filter(|a| { // Spawn approvals are for not-yet-existent agents; the proposed // dir is supposed to be missing. if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) { return true; } if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { let _ = coord.approvals.mark_failed(a.id, "agent state dir missing"); tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); false } }) .collect() } /// Returns either an empty string (agent is up-to-date / no rev known) or /// a clickable "needs update" badge whose form POSTs to /rebuild/. 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!( "
", ) } /// 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 /// (Phase 8 step 4) reflect within one auto-refresh cycle. fn claude_has_session(dir: &Path) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; entries .flatten() .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } async fn approval_diff(agent: &str, commit_ref: &str) -> String { let proposed = Coordinator::agent_proposed_dir(agent); if !proposed.exists() { return format!( "(proposed dir {} does not exist — agent destroyed?)", proposed.display() ); } if !proposed.join(".git").exists() { return format!("(no git repo at {})", proposed.display()); } let applied = Coordinator::agent_applied_dir(agent).join("agent.nix"); let applied_text = std::fs::read_to_string(&applied).unwrap_or_default(); match git_show(&proposed, commit_ref).await { Ok(s) => unified_diff(&applied_text, &s), Err(e) => format!("(error: {e:#})"), } } async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result { let out = lifecycle::git_command() .current_dir(proposed_dir) .args(["show", &format!("{commit_ref}:agent.nix")]) .output() .await .with_context(|| { format!( "spawn `git show` in {} (HYPERHIVE_GIT={})", proposed_dir.display(), std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "".into()), ) })?; if !out.status.success() { anyhow::bail!( "git show {commit_ref}:agent.nix failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } fn unified_diff(applied: &str, proposed: &str) -> String { let diff = similar::TextDiff::from_lines(applied, proposed); diff.unified_diff() .context_radius(3) .header("applied", "proposed") .to_string() } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } const BANNER: &str = r#""#; const MSG_FLOW: &str = r#"

◆ MESS4GE FL0W ◆

══════════════════════════════════════════════════════════════

live tail — newest at the top. tap on every send / recv through the broker.

connecting…
"#; const MSG_FLOW_JS: &str = r#""#; const FOOTER: &str = r#"
══════════════════════════════════════════════════════════════

▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲

"#; const STYLE: &str = r#" "#;