dashboard: SPA shell — static index.html + app.js, /api/state JSON
This commit is contained in:
parent
8428c693e0
commit
6fc9862c3c
5 changed files with 464 additions and 280 deletions
268
hive-c0re/assets/app.js
Normal file
268
hive-c0re/assets/app.js
Normal 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) =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[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);
|
||||
};
|
||||
})();
|
||||
})();
|
||||
|
|
@ -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; }
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
37
hive-c0re/assets/index.html
Normal file
37
hive-c0re/assets/index.html
Normal 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>
|
||||
|
|
@ -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) => ({'&':'&','<':'<','>':'>'}[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);
|
||||
};
|
||||
})();
|
||||
|
|
@ -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('>', ">")
|
||||
}
|
||||
|
||||
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>",
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue