hyperhive/hive-c0re/src/dashboard.rs
2026-05-15 00:06:51 +02:00

320 lines
11 KiB
Rust

//! 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::fmt::Write as _;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::{
Router,
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use hive_sh4re::Approval;
use tokio::process::Command;
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<Coordinator>,
}
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
let app = Router::new()
.route("/", get(index))
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
.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();
let approvals_html = render_approvals(&approvals).await;
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_html}\n{FOOTER}\n</body>\n</html>\n",
containers = render_containers(&containers, &hostname),
))
}
async fn post_approve(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match actions::approve(&state.coord, id).await {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
}
}
async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match actions::deny(&state.coord, id) {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
}
}
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()
}
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
}
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 {
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,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
diff = html_escape(&diff),
);
}
out.push_str("</ul>\n");
out
}
async fn approval_diff(agent: &str, commit_ref: &str) -> String {
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix");
let proposed = Coordinator::agent_proposed_dir(agent);
let applied_text = std::fs::read_to_string(&applied).unwrap_or_default();
let proposed_text = match git_show(&proposed, commit_ref).await {
Ok(s) => s,
Err(e) => return format!("(error reading proposed agent.nix: {e:#})"),
};
unified_diff(&applied_text, &proposed_text)
}
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> {
let out = Command::new("git")
.current_dir(proposed_dir)
.args(["show", &format!("{commit_ref}:agent.nix")])
.output()
.await
.context("git show")?;
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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;
--bg-elev: #18002a;
--fg: #e0d4ff;
--muted: #6c5c8c;
--purple: #cc66ff;
--purple-dim: #4a1a6a;
--cyan: #00ffff;
--pink: #ff3399;
--amber: #ffaa00;
--green: #00ff88;
--red: #ff4466;
--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: var(--bg-elev);
padding: 0.1em 0.4em;
border: 1px solid var(--border);
border-radius: 2px;
font-size: 0.9em;
}
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
.approvals form.inline { display: inline; margin-left: 0.4em; }
.btn {
font-family: inherit;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.1em;
background: transparent;
border: 1px solid;
padding: 0.25em 0.8em;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
}
.btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; }
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
details { margin-top: 0.5em; }
summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.1em;
}
summary:hover { color: var(--purple); }
.diff {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.8em;
margin-top: 0.4em;
overflow-x: auto;
font-size: 0.85em;
line-height: 1.4;
color: var(--fg);
white-space: pre;
}
footer {
margin-top: 4em;
text-align: center;
color: var(--muted);
font-size: 0.9em;
}
footer a { color: var(--purple); }
</style>
"#;