dashboard: async forms with spinner + rebuild button on every container

This commit is contained in:
müde 2026-05-15 16:21:25 +02:00
parent e2ed58c1a7
commit 8fbee4fbf2

View file

@ -82,7 +82,7 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
};
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{MSG_FLOW_JS}\n</body>\n</html>\n",
"<!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, &transient, current_rev.as_deref(), &hostname),
))
}
@ -181,7 +181,7 @@ fn render_containers(
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\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");
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");
@ -212,7 +212,7 @@ fn render_containers(
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
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></li>",
"<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 <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);
@ -227,7 +227,7 @@ fn render_containers(
let update_badge = update_badge_for(name, current_rev);
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 <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
"<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 <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>",
);
}
}
@ -252,7 +252,7 @@ async fn render_approvals(approvals: &[Approval]) -> String {
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\"><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_html}</pre></details>\n</li>",
"<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,
);
@ -260,7 +260,7 @@ async fn render_approvals(approvals: &[Approval]) -> String {
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\"><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</li>",
"<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,
);
@ -331,7 +331,7 @@ fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
return String::new();
}
format!(
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" onsubmit=\"return 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>",
" <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>",
)
}
@ -407,6 +407,47 @@ 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 = r#"<script>
(() => {
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 {
const resp = await fetch(form.action, {
method: form.method || 'POST',
body: 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; }
}
});
});
})();
</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>
@ -551,6 +592,7 @@ const STYLE: &str = r#"
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }