dashboard: SPA shell — static index.html + app.js, /api/state JSON

This commit is contained in:
müde 2026-05-15 17:10:57 +02:00
parent 8428c693e0
commit 6fc9862c3c
5 changed files with 464 additions and 280 deletions

268
hive-c0re/assets/app.js Normal file
View file

@ -0,0 +1,268 @@
// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
// up async-form submission (URL-encoded POST + spinner + state refresh),
// and tails the broker over `/messages/stream` SSE.
(() => {
// ─── helpers ────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
const el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
e.append(c.nodeType ? c : document.createTextNode(c));
}
return e;
};
const form = (action, btnClass, btnLabel, confirmMsg, extra = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value }));
}
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
return f;
};
// ─── async forms ────────────────────────────────────────────────────────
document.addEventListener('submit', async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
e.preventDefault();
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const original = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
try {
const resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
refreshState();
} catch (err) {
alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
// ─── state rendering ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
root.innerHTML = '';
if (s.any_stale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
));
}
const spawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '',
});
spawn.append(
el('input', {
name: 'name',
placeholder: 'new agent name (≤9 chars)',
maxlength: '9', required: '', autocomplete: 'off',
}),
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
);
root.append(spawn);
root.append(el('p', { class: 'meta' },
'spawn requests queue as approvals. operator approves below to actually create the container.',
));
if (s.transients.length) {
const ul = el('ul');
for (const t of s.transients) {
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, t.name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`),
));
}
root.append(ul);
}
if (!s.containers.length && !s.transients.length) {
root.append(el('p', { class: 'empty' }, '▓ no managed containers ▓'));
return;
}
const ul = el('ul');
for (const c of s.containers) {
const url = `http://${s.hostname}:${c.port}/`;
const li = el('li');
li.append(
el('span', { class: 'glyph' }, c.is_manager ? '▓█▓▒░' : '▒░▒░░'),
' ',
el('a', { href: url }, c.name),
' ',
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
c.is_manager ? 'm1nd' : 'ag3nt'),
);
if (c.needs_login) {
li.append(' ', el('a',
{ class: 'role role-pending', href: url }, 'needs login →'));
}
if (c.needs_update) {
li.append(' ', form(
'/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
));
}
li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`));
if (c.running) {
li.append(
' ',
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'),
);
if (!c.is_manager) {
li.append(
' ',
form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'),
);
}
}
li.append(
' ',
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.'),
);
if (!c.is_manager) {
li.append(
' ',
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.'),
);
}
ul.append(li);
}
root.append(ul);
}
function renderApprovals(s) {
const root = $('approvals-section');
root.innerHTML = '';
if (!s.approvals.length) {
root.append(el('p', { class: 'empty' }, '▓ queue empty ▓'));
return;
}
const ul = el('ul', { class: 'approvals' });
for (const a of s.approvals) {
const li = el('li');
const row = el('div', { class: 'row' });
if (a.kind === 'apply_commit') {
row.append(
el('span', { class: 'glyph' }, '→'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, 'apply'), ' ',
el('code', {}, a.sha_short || ''),
);
} else {
row.append(
el('span', { class: 'glyph' }, '⊕'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
el('span', { class: 'meta' },
'new sub-agent — container will be created on approve'),
);
}
row.append(
' ',
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
' ',
form('/deny/' + a.id, 'btn-deny', 'DENY'),
);
li.append(row);
if (a.diff_html) {
const details = el('details');
details.append(el('summary', {}, 'diff vs applied'));
// diff_html is pre-rendered server-side (per-line class spans inside
// a <pre>); inject as innerHTML.
const pre = el('pre', { class: 'diff', html: a.diff_html });
details.append(pre);
li.append(details);
}
ul.append(li);
}
root.append(ul);
}
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
async function refreshState() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderContainers(s);
renderApprovals(s);
// Auto-refresh while a spawn is in flight; otherwise back off.
const next = s.transients.length ? 2000 : 0;
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (next) pollTimer = setTimeout(refreshState, next);
} catch (err) {
console.error('refreshState failed', err);
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
// ─── message flow SSE ───────────────────────────────────────────────────
(() => {
const flow = $('msgflow');
if (!flow) return;
flow.innerHTML = '';
const es = new EventSource('/messages/stream');
const MAX_ROWS = 200;
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
es.onmessage = (e) => {
let m;
try { m = JSON.parse(e.data); } catch { return; }
const row = document.createElement('div');
row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓';
row.innerHTML =
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
'<span class="msg-arrow">' + kind + '</span>' +
'<span class="msg-from">' + esc(m.from) + '</span>' +
'<span class="msg-sep">→</span>' +
'<span class="msg-to">' + esc(m.to) + '</span>' +
'<span class="msg-body">' + esc(m.body) + '</span>';
flow.insertBefore(row, flow.firstChild);
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
};
es.onerror = () => {
flow.insertBefore(Object.assign(document.createElement('div'), {
className: 'msgrow meta', textContent: '[connection lost — retrying]',
}), flow.firstChild);
};
})();
})();

View file

@ -1,41 +0,0 @@
// Generic async submit + spinner for any `<form data-async>`.
// Replaces the standard form-POST navigation: button shows a spinner during
// the request, `data-confirm` runs first (skips the action if cancelled),
// page reloads on success so the new state is reflected.
(() => {
document.querySelectorAll('form[data-async]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (form.dataset.confirm && !confirm(form.dataset.confirm)) return;
const btn = form.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const original = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner">◐</span>';
}
try {
// axum's `Form` extractor wants application/x-www-form-urlencoded;
// FormData would send multipart/form-data and bounce with 415.
const resp = await fetch(form.action, {
method: form.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(form)),
redirect: 'manual',
});
const ok = resp.ok
|| resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
window.location.reload();
} catch (err) {
alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
});
})();

View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive // h1ve-c0re</title>
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
<pre class="banner">
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
</pre>
<h2>◆ C0NTAINERS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
<footer>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -1,30 +0,0 @@
(() => {
const flow = document.getElementById('msgflow');
if (!flow) return;
flow.innerHTML = '';
const es = new EventSource('/messages/stream');
const MAX_ROWS = 200;
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
const esc = (s) => s.replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
es.onmessage = (e) => {
let m;
try { m = JSON.parse(e.data); } catch { return; }
const row = document.createElement('div');
row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓';
row.innerHTML =
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
'<span class="msg-arrow">' + kind + '</span>' +
'<span class="msg-from">' + esc(m.from) + '</span>' +
'<span class="msg-sep">→</span>' +
'<span class="msg-to">' + esc(m.to) + '</span>' +
'<span class="msg-body">' + esc(m.body) + '</span>';
flow.insertBefore(row, flow.firstChild);
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
};
es.onerror = () => {
flow.insertBefore(Object.assign(document.createElement('div'), {
className: 'msgrow meta', textContent: '[connection lost — retrying]'
}), flow.firstChild);
};
})();

View file

@ -21,7 +21,7 @@ use axum::{
routing::{get, post},
};
use hive_sh4re::Approval;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
@ -38,7 +38,10 @@ struct AppState {
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
let app = Router::new()
.route("/", get(index))
.route("/", get(serve_index))
.route("/static/dashboard.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/api/state", get(api_state))
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
.route("/destroy/{name}", post(post_destroy))
@ -58,46 +61,170 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
Ok(())
}
async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String> {
// ---------------------------------------------------------------------------
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
// returns the current snapshot as JSON. The JS app fetches state on load,
// re-fetches after every async-form submit, and listens on
// `/messages/stream` for broker traffic.
// ---------------------------------------------------------------------------
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../assets/index.html"))
}
async fn serve_css() -> impl IntoResponse {
([("content-type", "text/css")], include_str!("../assets/dashboard.css"))
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
#[derive(Serialize)]
struct StateSnapshot {
hostname: String,
manager_port: u16,
any_stale: bool,
containers: Vec<ContainerView>,
transients: Vec<TransientView>,
approvals: Vec<ApprovalView>,
}
#[derive(Serialize)]
#[allow(clippy::struct_excessive_bools)]
struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
name: String,
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
container: String,
is_manager: bool,
port: u16,
running: bool,
needs_update: bool,
needs_login: bool,
}
#[derive(Serialize)]
struct TransientView {
name: String,
kind: &'static str,
secs: u64,
}
#[derive(Serialize)]
struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the `commit_ref`, for `ApplyCommit` only.
sha_short: Option<String>,
/// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only.
diff_html: Option<String>,
}
async fn api_state(
headers: HeaderMap,
State(state): State<AppState>,
) -> axum::Json<StateSnapshot> {
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 raw_containers = lifecycle::list().await.unwrap_or_default();
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
let mut running: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
for c in &containers {
let logical = c
.strip_prefix(lifecycle::AGENT_PREFIX)
.unwrap_or(c.as_str())
.to_owned();
running.insert(c.clone(), lifecycle::is_running(&logical).await);
}
let transient_snapshot = state.coord.transient_snapshot();
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 {
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
};
let mut containers = Vec::new();
let mut any_stale = false;
for c in &raw_containers {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
(n.to_owned(), false)
} else {
continue;
};
let needs_update = current_rev
.as_deref()
.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
if needs_update {
any_stale = true;
}
let needs_login = if is_manager {
false
} else {
!claude_has_session(&Coordinator::agent_claude_dir(&logical))
};
containers.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
container: c.clone(),
name: logical,
is_manager,
needs_update,
needs_login,
});
}
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers =
render_containers(&containers, &running, &transient, current_rev.as_deref(), &hostname),
))
let transients = transient_snapshot
.into_iter()
.filter(|(name, _)| {
!raw_containers
.iter()
.any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == name)
})
.map(|(name, st)| TransientView {
name,
kind: match st.kind {
crate::coordinator::TransientKind::Spawning => "spawning",
},
secs: st.since.elapsed().as_secs(),
})
.collect();
let mut approval_views = Vec::with_capacity(approvals.len());
for a in approvals {
let view = match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, &a.commit_ref).await;
ApprovalView {
id: a.id,
agent: a.agent,
kind: "apply_commit",
sha_short: Some(sha),
diff_html: Some(render_diff_lines(&diff)),
}
}
hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent,
kind: "spawn",
sha_short: None,
diff_html: None,
},
};
approval_views.push(view);
}
axum::Json(StateSnapshot {
hostname,
manager_port: MANAGER_PORT,
any_stale,
containers,
transients,
approvals: approval_views,
})
}
async fn messages_stream(
@ -236,140 +363,12 @@ async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<St
}
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()
// Plain text — the JS app surfaces this in an alert(), so HTML
// wrapping would just clutter the message.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}
fn render_containers(
containers: &[String],
running: &std::collections::HashMap<String, bool>,
transient: &std::collections::HashMap<String, crate::coordinator::TransientState>,
current_rev: Option<&str>,
hostname: &str,
) -> String {
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
// "update all" header button only when at least one container is stale.
if let Some(rev) = current_rev {
let any_stale = containers.iter().any(|c| {
let logical = c.strip_prefix(AGENT_PREFIX).unwrap_or(c);
crate::auto_update::agent_needs_update(logical, rev)
});
if any_stale {
out.push_str("<form method=\"POST\" action=\"/update-all\" class=\"inline\" data-async data-confirm=\"rebuild every stale container?\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ UPD4TE 4LL</button></form>\n");
}
}
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\" data-async>\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
// Render in-flight spawns first so the operator sees feedback immediately.
if !transient.is_empty() {
out.push_str("<ul>\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,
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
);
}
out.push_str("</ul>\n");
}
if containers.is_empty() && transient.is_empty() {
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
return out;
}
out.push_str("<ul>\n");
for container in containers {
let is_running = running.get(container).copied().unwrap_or(false);
if container == MANAGER_NAME {
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
let restart_btn = if is_running {
format!(
" <form method=\"POST\" action=\"/restart/{MANAGER_NAME}\" class=\"inline\" data-async data-confirm=\"restart manager?\"><button class=\"btn btn-restart\" type=\"submit\">↺ R3ST4RT</button></form>\n",
)
} else {
String::new()
};
let _ = writeln!(
out,
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span>\n{restart_btn} <form method=\"POST\" action=\"/rebuild/{MANAGER_NAME}\" class=\"inline\" data-async data-confirm=\"rebuild manager? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n</li>",
);
} 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!(
" <a class=\"role role-pending\" href=\"http://{hostname}:{port}/\">needs login →</a>",
)
};
let update_badge = update_badge_for(name, current_rev);
let running_buttons = if is_running {
format!(
" <form method=\"POST\" action=\"/restart/{name}\" class=\"inline\" data-async data-confirm=\"restart {name}?\"><button class=\"btn btn-restart\" type=\"submit\">↺ R3ST4RT</button></form>\n <form method=\"POST\" action=\"/kill/{name}\" class=\"inline\" data-async data-confirm=\"stop {name}?\"><button class=\"btn btn-stop\" type=\"submit\">■ ST0P</button></form>\n",
)
} else {
String::new()
};
let _ = writeln!(
out,
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n{running_buttons} <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"btn btn-rebuild\" type=\"submit\">↻ R3BU1LD</button></form>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" data-async data-confirm=\"destroy {name}? container is removed; state + creds kept.\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</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 {
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 diff_html = render_diff_lines(&diff);
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff_html}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
);
}
hive_sh4re::ApprovalKind::Spawn => {
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\" data-async><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\" data-async><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
id = a.id,
agent = a.agent,
);
}
}
}
out.push_str("</ul>\n");
out
}
/// 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
@ -417,20 +416,6 @@ fn render_diff_lines(diff: &str) -> String {
out
}
/// Returns either an empty string (agent is up-to-date / no rev known) or
/// a clickable "needs update" badge whose form POSTs to /rebuild/<name>.
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!(
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" data-async data-confirm=\"rebuild {name}? hot-reloads the container.\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
)
}
/// 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
@ -499,38 +484,3 @@ fn html_escape(s: &str) -> String {
.replace('>', "&gt;")
}
const BANNER: &str = r#"<pre class="banner">
HYPERHIVE HIVE-C0RE WE ARE THE WIRED
</pre>"#;
/// Generic async submit + spinner for any `<form data-async>`. Replaces
/// the standard form-POST navigation: button shows a spinner during the
/// request, `data-confirm` runs first (skips the action if cancelled),
/// page reloads on success so the new state is reflected.
const ASYNC_FORMS_JS: &str = concat!(
"<script>\n",
include_str!("../assets/async_forms.js"),
"</script>",
);
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider"></div>
<p class="meta">live tail newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
<div id="msgflow" class="msgflow"><span class="meta">connecting</span></div>"#;
const MSG_FLOW_JS: &str = concat!(
"<script>\n",
include_str!("../assets/msg_flow.js"),
"</script>",
);
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 = concat!(
"<style>\n",
include_str!("../assets/dashboard.css"),
"</style>",
);