Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75df5f6c29 | ||
|
|
2b4213d32a | ||
|
|
2e2989ef8c | ||
|
|
8cf5d72798 | ||
|
|
6dbf4eedd7 | ||
|
|
d0f954bbc1 |
14 changed files with 650 additions and 7 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -148,8 +148,15 @@ in.
|
|||
container builds from `applied`. Approve = read `agent.nix` at the
|
||||
approved commit from `proposed`, copy into `applied`, commit + rebuild.
|
||||
Manager cannot move `main` on its own.
|
||||
- 🔜 Phase 6 — per-agent web UI + dashboard MVP
|
||||
- 🔜 Phase 7 — dashboard commit-view + polish
|
||||
- ✅ Phase 6 — per-container web UIs + hive-c0re dashboard:
|
||||
- Each `hive-ag3nt` / `hive-m1nd` serves an `axum` HTTP page on `HIVE_PORT`
|
||||
(deterministic hash for sub-agents in 8100–8999; fixed 8000 for the manager).
|
||||
Vibec0re-styled placeholder for now (status / inbox / xterm coming later).
|
||||
- `hive-c0re` serves a dashboard on `cfg.dashboardPort` (default 7000)
|
||||
listing containers (deep-linked to their per-container UI) + pending
|
||||
approvals. Same aesthetic.
|
||||
- Firewall opens 7000, 8000, 8100–8999 when the module is enabled.
|
||||
- 🔜 Phase 7 — dashboard approval-actions + live message-flow + xterm.js terminal
|
||||
|
||||
## Approval flow (Phase 5)
|
||||
|
||||
|
|
|
|||
270
Cargo.lock
generated
270
Cargo.lock
generated
|
|
@ -67,6 +67,64 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
|
|
@ -175,6 +233,48 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
@ -204,6 +304,7 @@ name = "hive-ag3nt"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"hive-sh4re",
|
||||
"serde",
|
||||
|
|
@ -218,6 +319,7 @@ name = "hive-c0re"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"hive-sh4re",
|
||||
"rusqlite",
|
||||
|
|
@ -235,6 +337,86 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -285,12 +467,24 @@ dependencies = [
|
|||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
|
|
@ -323,6 +517,12 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -384,6 +584,12 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -427,6 +633,29 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -452,6 +681,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
|
|
@ -485,6 +720,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
|
|
@ -521,12 +762,41 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ must_use_candidate = "allow"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
axum = "0.8"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
hive-sh4re = { path = "hive-sh4re" }
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, client};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||
use tokio::process::Command;
|
||||
|
||||
|
|
@ -44,7 +44,19 @@ async fn main() -> Result<()> {
|
|||
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => serve(&cli.socket, Duration::from_millis(poll_ms)).await,
|
||||
Cmd::Serve { poll_ms } => {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_WEB_PORT);
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = web_ui::serve(label, port).await {
|
||||
tracing::error!(error = ?e, "web ui failed");
|
||||
}
|
||||
});
|
||||
serve(&cli.socket, Duration::from_millis(poll_ms)).await
|
||||
}
|
||||
Cmd::Send { to, body } => {
|
||||
let resp: AgentResponse =
|
||||
client::request(&cli.socket, &AgentRequest::Send { to, body }).await?;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, client};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui};
|
||||
use hive_sh4re::{ManagerRequest, ManagerResponse};
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -52,7 +52,19 @@ async fn main() -> Result<()> {
|
|||
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => serve(&cli.socket, Duration::from_millis(poll_ms)).await,
|
||||
Cmd::Serve { poll_ms } => {
|
||||
let port = std::env::var("HIVE_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_WEB_PORT);
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = web_ui::serve(label, port).await {
|
||||
tracing::error!(error = ?e, "web ui failed");
|
||||
}
|
||||
});
|
||||
serve(&cli.socket, Duration::from_millis(poll_ms)).await
|
||||
}
|
||||
Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await,
|
||||
Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await,
|
||||
Cmd::Spawn { name } => one_shot(&cli.socket, ManagerRequest::Spawn { name }).await,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
//! `hive-m1nd` (manager) binaries.
|
||||
|
||||
pub mod client;
|
||||
pub mod web_ui;
|
||||
|
||||
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
|
||||
pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
|
||||
|
||||
/// Default web UI port — used when `HIVE_PORT` env is unset.
|
||||
pub const DEFAULT_WEB_PORT: u16 = 8042;
|
||||
|
|
|
|||
75
hive-ag3nt/src/web_ui.rs
Normal file
75
hive-ag3nt/src/web_ui.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port.
|
||||
//! Containers share the host's network namespace (privateNetwork = false), so
|
||||
//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by
|
||||
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{Router, extract::State, response::Html, routing::get};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
label: String,
|
||||
}
|
||||
|
||||
pub async fn serve(label: String, port: u16) -> Result<()> {
|
||||
let state = AppState { label };
|
||||
let app = Router::new().route("/", get(index)).with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("bind web UI on port {port}"))?;
|
||||
tracing::info!(%port, "web UI listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Html<String> {
|
||||
Html(format!(
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p>▓█▓▒░ harness alive ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>\n</body>\n</html>\n",
|
||||
label = state.label,
|
||||
))
|
||||
}
|
||||
|
||||
const STYLE: &str = r#"
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0014;
|
||||
--fg: #e0d4ff;
|
||||
--muted: #6c5c8c;
|
||||
--purple: #cc66ff;
|
||||
--purple-dim: #4a1a6a;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
max-width: 70em;
|
||||
margin: 1.5em auto;
|
||||
padding: 0 1.5em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.banner {
|
||||
color: var(--purple);
|
||||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
h2 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
||||
}
|
||||
.divider {
|
||||
color: var(--purple-dim);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 0.85em; }
|
||||
</style>
|
||||
"#;
|
||||
|
|
@ -8,6 +8,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
|
|
|||
201
hive-c0re/src/dashboard.rs
Normal file
201
hive-c0re/src/dashboard.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! Hyperhive dashboard. Lists managed containers (with deep-links to each
|
||||
//! container's web UI), pending approvals, and the manager.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
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<Coordinator>,
|
||||
}
|
||||
|
||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<AppState>) -> Html<String> {
|
||||
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!(
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{style}\n</head>\n<body>\n{banner}\n{containers}\n{approvals}\n{footer}\n</body>\n</html>\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(
|
||||
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
||||
);
|
||||
if containers.is_empty() {
|
||||
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
|
||||
return out;
|
||||
}
|
||||
out.push_str("<ul>\n");
|
||||
for container in containers {
|
||||
if container == MANAGER_NAME {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span> <span class=\"meta\">:{MANAGER_PORT}</span></li>",
|
||||
);
|
||||
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
||||
let port = lifecycle::agent_web_port(name);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span> <span class=\"meta\">{container} :{port}</span></li>",
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push_str("</ul>\n");
|
||||
out
|
||||
}
|
||||
|
||||
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>\n");
|
||||
for a in approvals {
|
||||
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<li><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code> <span class=\"meta\">approve via <code>hive-c0re approve {id}</code></span></li>",
|
||||
id = a.id,
|
||||
agent = a.agent,
|
||||
);
|
||||
}
|
||||
out.push_str("</ul>\n");
|
||||
out
|
||||
}
|
||||
|
||||
const BANNER: &str = r#"<pre class="banner">
|
||||
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
||||
</pre>"#;
|
||||
|
||||
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 = r#"
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0014;
|
||||
--fg: #e0d4ff;
|
||||
--muted: #6c5c8c;
|
||||
--purple: #cc66ff;
|
||||
--purple-dim: #4a1a6a;
|
||||
--cyan: #00ffff;
|
||||
--pink: #ff3399;
|
||||
--amber: #ffaa00;
|
||||
--border: #2a0a4a;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
max-width: 70em;
|
||||
margin: 1.5em auto;
|
||||
padding: 0 1.5em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.banner {
|
||||
color: var(--purple);
|
||||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1, h2 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
margin-top: 2em;
|
||||
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
||||
}
|
||||
.divider {
|
||||
color: var(--purple-dim);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
ul { list-style: none; padding-left: 0; }
|
||||
li { padding: 0.5em 0; }
|
||||
.glyph { color: var(--purple); margin-right: 0.5em; }
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 255, 0.5);
|
||||
font-weight: bold;
|
||||
}
|
||||
a:hover {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 12px rgba(0, 255, 255, 0.9);
|
||||
}
|
||||
.role {
|
||||
display: inline-block;
|
||||
margin-left: 0.4em;
|
||||
padding: 0.05em 0.5em;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(255, 51, 153, 0.1); }
|
||||
.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(255, 170, 0, 0.1); }
|
||||
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
|
||||
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
|
||||
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
code {
|
||||
color: var(--amber);
|
||||
background: #18002a;
|
||||
padding: 0.1em 0.4em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
footer {
|
||||
margin-top: 4em;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
footer a { color: var(--purple); }
|
||||
</style>
|
||||
"#;
|
||||
|
|
@ -19,6 +19,25 @@ pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
|
|||
const GIT_NAME: &str = "hive-c0re";
|
||||
const GIT_EMAIL: &str = "hive-c0re@hyperhive";
|
||||
|
||||
/// Sub-agent web UI port range. Deterministic from the agent's name (FNV-1a
|
||||
/// hash mod range size), so the dashboard can compute the same port without
|
||||
/// asking hive-c0re.
|
||||
const WEB_PORT_BASE: u16 = 8100;
|
||||
const WEB_PORT_RANGE: u16 = 900;
|
||||
|
||||
/// Returns the per-agent web UI port. Same hash on both sides — manager,
|
||||
/// dashboard, and agent harness all agree.
|
||||
#[must_use]
|
||||
pub fn agent_web_port(name: &str) -> u16 {
|
||||
let mut hash: u32 = 2_166_136_261;
|
||||
for b in name.bytes() {
|
||||
hash ^= u32::from(b);
|
||||
hash = hash.wrapping_mul(16_777_619);
|
||||
}
|
||||
// Modulo of a u32 by a u16's value is guaranteed < u16::MAX, so try_from never fails.
|
||||
WEB_PORT_BASE + u16::try_from(hash % u32::from(WEB_PORT_RANGE)).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn container_name(name: &str) -> String {
|
||||
format!("{AGENT_PREFIX}{name}")
|
||||
}
|
||||
|
|
@ -125,6 +144,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str
|
|||
std::fs::create_dir_all(applied_dir)
|
||||
.with_context(|| format!("create {}", applied_dir.display()))?;
|
||||
|
||||
let port = agent_web_port(name);
|
||||
let flake_body = format!(
|
||||
r#"{{
|
||||
description = "hyperhive sub-agent {name}";
|
||||
|
|
@ -143,6 +163,10 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str
|
|||
[init]
|
||||
defaultBranch = main
|
||||
'';
|
||||
systemd.services.hive-ag3nt.environment = {{
|
||||
HIVE_PORT = "{port}";
|
||||
HIVE_LABEL = "{name}";
|
||||
}};
|
||||
}}
|
||||
];
|
||||
}};
|
||||
|
|
|
|||
|
|
@ -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-<name>`).
|
||||
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 } => {
|
||||
|
|
|
|||
|
|
@ -26,17 +26,37 @@ 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 ];
|
||||
|
||||
# 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;
|
||||
to = 8999;
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.hive-c0re = {
|
||||
description = "hyperhive coordinator daemon";
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
description = "hive-m1nd manager harness";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
environment = {
|
||||
HIVE_PORT = "8000";
|
||||
HIVE_LABEL = "hm1nd";
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
|
||||
Restart = "on-failure";
|
||||
|
|
|
|||
Loading…
Reference in a new issue