//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; //! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page //! state as JSON; the JS app renders. Live events stream on //! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a //! 303 Redirect (for browsers that submit the form normally) or just //! 200 OK — the JS app re-fetches `/api/state` afterwards. use std::convert::Infallible; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use tower_http::services::ServeDir; use crate::client; use crate::events::Bus; use crate::login::LoginState; use crate::login_session::{LoginSession, drop_if_finished}; use crate::mcp; use crate::turn::TurnFiles; /// Live login state for the web UI. The harness updates this in place as it /// transitions between `NeedsLogin` and `Online`; the UI reads on each /// render. pub type LoginStateCell = Arc>; /// Shared turn lock. The serve loop acquires this (as an async mutex) for the /// duration of every `drive_turn` call. The `/api/compact` handler tries /// `try_lock()` and rejects immediately if a turn is in flight, preventing /// concurrent access to the claude session. pub type TurnLock = Arc>; #[derive(Clone)] struct AppState { label: String, login: LoginStateCell, session: Arc>>>, bus: Bus, socket: PathBuf, /// Same `TurnFiles` the harness's turn loop uses. Shared so /// `/api/compact` re-uses the exact MCP config / system prompt / /// settings claude saw on the last regular turn — keeps the /// session shape identical across compact + normal turns. files: TurnFiles, /// Prevents `/api/compact` from racing with an in-flight normal turn. turn_lock: TurnLock, /// VNC port read from `/etc/hyperhive/gui.json` at startup. /// `None` when the file is absent (gui not enabled for this agent). gui_vnc_port: Option, } impl AppState { fn flavor(&self) -> Flavor { self.files.flavor } } /// Which wire protocol the per-agent UI's `/send` handler should speak. /// Sub-agent → `AgentRequest::OperatorMsg`; manager → /// `ManagerRequest::OperatorMsg`. Reuses the MCP-side enum so a /// single value drives both the send protocol and (in /// `post_compact`) the allowed-tools surface claude sees. pub type Flavor = mcp::Flavor; /// # Errors /// /// Returns an error if the TCP listener cannot bind to the given port. pub async fn serve( label: String, port: u16, login: LoginStateCell, bus: Bus, socket: PathBuf, files: TurnFiles, turn_lock: TurnLock, ) -> Result<()> { let gui_vnc_port = read_gui_json(); let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") .map(PathBuf::from) .context( "HIVE_STATIC_DIR env var not set — point it at the merged \ per-agent dist (see hyperhive.frontend.mergedDist in nix)", )?; if !static_dir.is_dir() { anyhow::bail!( "HIVE_STATIC_DIR ({}) is not a directory", static_dir.display() ); } tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved"); let state = AppState { label, login, session: Arc::new(Mutex::new(None)), bus, socket, files, turn_lock, gui_vnc_port, }; let app = Router::new() .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) .route("/events/history", get(events_history)) .route("/send", post(post_send)) .route("/login/start", post(post_login_start)) .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) .route("/api/cancel", post(post_cancel_turn)) .route("/api/compact", post(post_compact)) .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) .route("/api/logout", post(post_logout)) .route("/api/loose-ends", get(api_loose_ends)) .route("/api/stats", get(api_stats)) .route("/screen/ws", get(screen_ws)) .route("/icon", get(serve_icon)) // Anything else (`/`, `/stats`, `/screen`, `/static/*`) // falls through to the merged dist. ServeDir auto-appends // `.html` when the URL is a bare path that matches a file // (so `/stats` → `dist/stats.html`, `/screen` → `dist/ // screen.html`). Per-agent `extraFiles` additions are // already layered into this same directory (see // hyperhive.frontend.mergedDist in nix). .fallback_service(ServeDir::new(&static_dir)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; tracing::info!(%port, "web UI listening"); axum::serve(listener, app).await?; Ok(()) } // --------------------------------------------------------------------------- // Static assets + state snapshot // --------------------------------------------------------------------------- /// Bind a TCP listener with `SO_REUSEADDR` set, retrying on /// `AddrInUse` indefinitely with exponential backoff capped at 2s. /// nspawn restarts can race the previous harness's socket release; /// `SO_REUSEADDR` lets us reclaim a port still in `TIME_WAIT` from a /// clean previous exit, and the retry covers the case where the /// previous process is genuinely still alive (systemd restart-delay /// overlap). /// /// The retry has no attempt cap: capping was the proximate cause of /// issue #324 — two back-to-back restarts left the previous socket /// holding the port for longer than the ~20s the old 12-attempt /// budget allowed, and the harness silently lost its web UI for the /// rest of the process lifetime. Genuine port collisions are /// preflighted host-side (`lifecycle::{spawn,rebuild}`) and surfaced /// on the dashboard as a banner, so at this layer a persistent /// `AddrInUse` always reflects a recoverable stale socket — retrying /// forever is the safe choice. The first attempts log at WARN; once /// we cross attempt 12 the level drops to INFO so a long stale /// socket doesn't flood the journal. async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { let mut delay_ms = 250u64; let mut attempts = 0u32; loop { match try_bind(addr) { Ok(l) => { if attempts > 0 { tracing::info!( %addr, attempts, "{label}: bind succeeded after retry" ); } return Ok(l); } Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { let attempt = attempts + 1; if attempt <= 12 { tracing::warn!( %addr, attempt, "{label}: AddrInUse, retrying in {delay_ms}ms" ); } else { tracing::info!( %addr, attempt, "{label}: AddrInUse still holding, retrying in {delay_ms}ms" ); } tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; attempts += 1; delay_ms = (delay_ms * 2).min(2000); } Err(e) => { return Err(e).with_context(|| format!("bind {label} on {addr}")); } } } } fn try_bind(addr: SocketAddr) -> std::io::Result { let sock = match addr { SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?, SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?, }; sock.set_reuseaddr(true)?; sock.bind(addr)?; sock.listen(1024) } /// This agent's icon. Serves the operator-configured SVG from /// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix /// option) when present, otherwise the bundled default hyperhive logo. /// Always returns an image, so consumers (dashboard, favicon) can hit /// `/icon` unconditionally without probing whether one is configured. async fn serve_icon() -> impl IntoResponse { // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set // via the `hyperhive.icon` agent.nix option); the bundled default is // resolved at runtime from // `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file // can be read we serve an empty body — keeps the response a valid // SVG content-type without a panic on a misconfigured container. let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| { std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default() }); ([("content-type", "image/svg+xml")], body) } /// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field. /// Returns `None` if the file is absent or unparseable — GUI not enabled. fn read_gui_json() -> Option { let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?; let val: serde_json::Value = serde_json::from_str(&text).ok()?; val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok()) } /// WebSocket handler: upgrade then pump bytes between the WS client and /// the VNC server on `127.0.0.1:`. Returns 404 when gui is not /// enabled for this agent. async fn screen_ws( ws: axum::extract::ws::WebSocketUpgrade, State(state): State, ) -> Response { let Some(vnc_port) = state.gui_vnc_port else { return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response(); }; ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port)) } /// Pure byte pump: forwards raw bytes between the WebSocket client and /// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`). async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) { // Import futures traits locally so they don't conflict with // tokio_stream::StreamExt used at module scope. use axum::extract::ws::Message; use futures_util::{SinkExt, StreamExt as _}; let addr = format!("127.0.0.1:{vnc_port}"); let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else { tracing::warn!(%addr, "screen/ws: could not connect to VNC server"); return; }; let (mut tcp_rx, mut tcp_tx) = tcp.into_split(); let (mut ws_tx, mut ws_rx) = socket.split(); // WS → TCP let ws_to_tcp = tokio::spawn(async move { while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await { match msg { Message::Binary(data) => { if tcp_tx.write_all(&data).await.is_err() { break; } } Message::Close(_) => break, _ => {} // ping/pong/text: ignore } } }); // TCP → WS let tcp_to_ws = tokio::spawn(async move { let mut buf = vec![0u8; 8192]; loop { match tcp_rx.read(&mut buf).await { Ok(0) | Err(_) => break, Ok(n) => { if ws_tx .send(Message::Binary(buf[..n].to_vec().into())) .await .is_err() { break; } } } } }); // Wait for either direction to close, then let both tasks drop. tokio::select! { _ = ws_to_tcp => {} _ = tcp_to_ws => {} } } #[derive(Deserialize)] struct StatsQuery { window: Option, } async fn api_stats( State(state): State, axum::extract::Query(q): axum::extract::Query, ) -> axum::Json { let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); let mut snapshot = crate::stats::snapshot_default(window); // Pass the window span to the reminder-stats RPC so the broker // filters its counts to the same time range as the chart data. let window_secs = window.span_secs(); let window_secs_u = u64::try_from(window_secs).unwrap_or(0); snapshot.reminder_stats = fetch_reminder_stats(&state.socket, state.flavor(), window_secs_u).await; axum::Json(snapshot) } #[derive(Serialize)] struct StateSnapshot { /// Bus seq at the moment this snapshot was assembled. Clients dedupe /// their buffered SSE traffic against this value: events with /// `seq <= snapshot.seq` are already reflected (or pre-date the /// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on /// harness restart — clients treat reconnect as a fresh world. seq: u64, label: String, /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when /// the host has been configured for a multi-hive swarm; falls back /// to the short label when the hive domain env var is unset (#589). /// The frontend uses this for the page title / agent self-introduction; /// when it equals `label`, the page renders the short form unchanged. qualified_label: String, dashboard_port: u16, /// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`. status: &'static str, /// Present when `status == "needs_login_in_progress"`. session: Option, /// Last N messages addressed to this agent, newest-first. Pulled /// from the broker via the per-agent socket on each render. /// Empty on transport failure. inbox: Vec, /// Authoritative turn-loop state from the harness and the unix /// timestamp the state was entered. The JS computes the age /// client-side off this rather than tracking it from SSE events. turn_state: crate::events::TurnState, turn_state_since: i64, /// Currently-active claude model name. Reflected on the page so /// the operator can see what they just switched to (and what's /// in flight). Mutable at runtime via `POST /api/model`. model: String, /// Effective context-window token budget for the current model. /// Primary source: API-reported `modelUsage.*.contextWindow` from /// the last result event (authoritative per-inference active window). /// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000. /// Consumers (e.g. dashboard badge) use this to render ctx-usage %. context_window_tokens: u64, /// Last-inference token usage from the most recent completed /// turn — represents the current context-window size at turn-end. /// `null` until the first turn finishes. ctx_usage: Option, /// Cumulative token usage across the most recent turn's inferences /// (cost signal). `null` until the first turn finishes. cost_usage: Option, /// Navigation links for this agent page (issue #262). Stats is /// always present; screen when the VNC compositor is enabled; the /// forge profile + the agent-configs mirror repo when the agent /// has a forge account; followed by any agent-declared /// `hyperhive.dashboardLinks` extras (read from /// `{state_dir}/hyperhive-dashboard-links.json`). Each URL is /// already absolute — built server-side from the request `Host` /// header — so the frontend just renders. /// /// This same list is the **source of truth** for the per-agent /// page *and* the dashboard card's icon-only nav strip: hive-c0re /// proxies it via `GET /api/agent/{name}/links` (same-origin from /// the dashboard JS), avoiding CORS and centralising the link /// definitions in the agent backend. links: Vec, } /// One navigation link in the agent page header row. Same JSON /// shape feeds the dashboard's icon-only nav strip via the host's /// `GET /api/agent/{name}/links` passthrough proxy, so the agent /// backend is the single source of truth for what links an agent /// exposes (issue #262). #[derive(Serialize)] struct AgentLink { /// `kind = Container | Forge` → path; `kind = External` → full URL. /// The frontend prepends the right base before rendering. url: String, icon: String, label: String, kind: AgentLinkKind, } /// Resolution hint for `AgentLink.url`. The agent backend can't know /// which hostname the browser sees (especially when the dashboard /// proxies the call from a different origin), so it labels each link /// and lets the frontend prepend the right base. #[derive(Serialize, Clone, Copy)] #[serde(rename_all = "snake_case")] enum AgentLinkKind { /// `url` is a path on the agent's container web UI (`/stats`, /// `/screen`). Agent page: same-origin path. Dashboard: /// `http://:`. Container, /// `url` is a path on the local Forgejo (`/