dashboard: async forms with spinner + rebuild button on every container
This commit is contained in:
parent
e2ed58c1a7
commit
8fbee4fbf2
1 changed files with 49 additions and 7 deletions
|
|
@ -82,7 +82,7 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
|
||||||
};
|
};
|
||||||
|
|
||||||
Html(format!(
|
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),
|
containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
@ -181,7 +181,7 @@ fn render_containers(
|
||||||
let mut out = String::from(
|
let mut out = String::from(
|
||||||
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
"<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.
|
// Render in-flight spawns first so the operator sees feedback immediately.
|
||||||
if !transient.is_empty() {
|
if !transient.is_empty() {
|
||||||
out.push_str("<ul>\n");
|
out.push_str("<ul>\n");
|
||||||
|
|
@ -212,7 +212,7 @@ fn render_containers(
|
||||||
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
|
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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) {
|
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
||||||
let port = lifecycle::agent_web_port(name);
|
let port = lifecycle::agent_web_port(name);
|
||||||
|
|
@ -227,7 +227,7 @@ fn render_containers(
|
||||||
let update_badge = update_badge_for(name, current_rev);
|
let update_badge = update_badge_for(name, current_rev);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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 diff_html = render_diff_lines(&diff);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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,
|
id = a.id,
|
||||||
agent = a.agent,
|
agent = a.agent,
|
||||||
);
|
);
|
||||||
|
|
@ -260,7 +260,7 @@ async fn render_approvals(approvals: &[Approval]) -> String {
|
||||||
hive_sh4re::ApprovalKind::Spawn => {
|
hive_sh4re::ApprovalKind::Spawn => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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,
|
id = a.id,
|
||||||
agent = a.agent,
|
agent = a.agent,
|
||||||
);
|
);
|
||||||
|
|
@ -331,7 +331,7 @@ fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
format!(
|
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 ░▒▓█▓▒░
|
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
||||||
</pre>"#;
|
</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>
|
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<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>
|
<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-approve { color: var(--green); border-color: var(--green); }
|
||||||
.btn-deny { color: var(--red); border-color: var(--red); }
|
.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-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-talk { color: var(--cyan); border-color: var(--cyan); }
|
||||||
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
||||||
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue