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
|
|
@ -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