From 8cf5d72798a3399d18858bf1d2bae62ffcf299cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:43:20 +0200 Subject: [PATCH] Phase 6b: vibec0re-styled dashboard on hive-c0re + agent web UI restyled --- hive-ag3nt/src/web_ui.rs | 51 ++++++++-- hive-c0re/Cargo.toml | 1 + hive-c0re/src/dashboard.rs | 197 +++++++++++++++++++++++++++++++++++++ hive-c0re/src/main.rs | 11 +++ nix/modules/hive-c0re.nix | 19 +++- 5 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 hive-c0re/src/dashboard.rs diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 141b9c6..132304a 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -27,14 +27,49 @@ pub async fn serve(label: String, port: u16) -> Result<()> { async fn index(State(state): State) -> Html { Html(format!( - "\n\ - \n\ - {label}\n\ - \n\ -

{label}

\n\ -

hyperhive harness placeholder. Phase 6a: this page exists.

\n\ - \n\ - \n", + "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

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

▓█▓▒░ harness alive ▓█▓▒░

\n

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

\n\n\n", label = state.label, )) } + +const STYLE: &str = r#" + +"#; diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 8eafdfa..553a7d3 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] anyhow.workspace = true +axum.workspace = true clap.workspace = true hive-sh4re.workspace = true rusqlite.workspace = true diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs new file mode 100644 index 0000000..d305572 --- /dev/null +++ b/hive-c0re/src/dashboard.rs @@ -0,0 +1,197 @@ +//! Hyperhive dashboard. Lists managed containers (with deep-links to each +//! container's web UI), pending approvals, and the manager. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use axum::{Router, extract::State, http::HeaderMap, response::Html, routing::get}; +use hive_sh4re::Approval; + +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)) + .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 approvals = state.coord.approvals.pending().unwrap_or_default(); + + Html(format!( + "\n\n\n\nhyperhive // h1ve-c0re\n{style}\n\n\n{banner}\n{containers}\n{approvals}\n{footer}\n\n\n", + style = STYLE, + banner = BANNER, + containers = render_containers(&containers, &hostname), + approvals = render_approvals(&approvals), + footer = FOOTER, + )) +} + +fn render_containers(containers: &[String], hostname: &str) -> String { + let mut out = String::from( + "

◆ C0NTAINERS ◆

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

▓ no managed containers ▓

\n"); + return out; + } + out.push_str("
    \n"); + for container in containers { + if container == MANAGER_NAME { + out.push_str(&format!( + "
  • ▓█▓▒░ {container} m1nd :{MANAGER_PORT}
  • \n", + )); + } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { + let port = lifecycle::agent_web_port(name); + out.push_str(&format!( + "
  • ▒░▒░░ {name} ag3nt {container} :{port}
  • \n", + )); + } + } + out.push_str("
\n"); + out +} + +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 { + let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; + out.push_str(&format!( + "
  • #{id} {agent} {sha_short} approve via hive-c0re approve {id}
  • \n", + id = a.id, + agent = a.agent, + )); + } + out.push_str("
\n"); + out +} + +const BANNER: &str = r#""#; + +const FOOTER: &str = r#"
+
══════════════════════════════════════════════════════════════
+

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

+
"#; + +const STYLE: &str = r#" + +"#; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index fd8329e..8497489 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -10,6 +10,7 @@ mod approvals; mod broker; mod client; mod coordinator; +mod dashboard; mod lifecycle; mod manager_server; mod server; @@ -38,6 +39,9 @@ enum Cmd { /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, + /// Dashboard HTTP port. + #[arg(long, default_value_t = 7000)] + dashboard_port: u16, }, /// Spawn a new agent container (`hive-agent-`). Spawn { name: String }, @@ -69,9 +73,16 @@ async fn main() -> Result<()> { Cmd::Serve { hyperhive_flake, db, + dashboard_port, } => { let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); manager_server::start(coord.clone())?; + let dash_coord = coord.clone(); + tokio::spawn(async move { + if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await { + tracing::error!(error = ?e, "dashboard failed"); + } + }); server::serve(&cli.socket, coord).await } Cmd::Spawn { name } => { diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index c3c4cfd..5438b25 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -26,15 +26,24 @@ in build the container. ''; }; + dashboardPort = lib.mkOption { + type = lib.types.port; + default = 7000; + description = "TCP port the hive-c0re dashboard listens on."; + }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package ]; - # Per-container web UIs share the host's network namespace and need their - # ports reachable. Manager: 8000. Sub-agents: 8100..8999 (deterministic - # hash; see `lifecycle::agent_web_port`). - networking.firewall.allowedTCPPorts = [ 8000 ]; + # Dashboard + per-container web UIs share the host's network namespace and + # need their ports reachable. Dashboard: `cfg.dashboardPort` (default 7000). + # Manager: 8000. Sub-agents: 8100..8999 (deterministic hash; see + # `lifecycle::agent_web_port`). + networking.firewall.allowedTCPPorts = [ + cfg.dashboardPort + 8000 + ]; networking.firewall.allowedTCPPortRanges = [ { from = 8100; @@ -47,7 +56,7 @@ in wantedBy = [ "multi-user.target" ]; path = [ "/run/current-system/sw" ]; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive";