dashboard: deployed sha chip per container

ContainerView grows deployed_sha (first 12 chars of the rev
that /var/lib/hyperhive/meta/flake.lock currently has locked
for agent-<name>). renderContainers appends a 'deployed:<sha12>'
chip next to the container name + port — title attribute
explains it's the meta-lock sha. degrades gracefully when the
meta repo isn't seeded yet (missing / unparsable lock = empty
map = no chip). new read_meta_locked_revs helper does the JSON
parsing without unwraps.
This commit is contained in:
müde 2026-05-16 00:36:52 +02:00
parent 691057d2d3
commit 2f6ecc4dc0
2 changed files with 43 additions and 0 deletions

View file

@ -279,6 +279,11 @@
));
}
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
if (c.deployed_sha) {
head.append(el('span',
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
`deployed:${c.deployed_sha}`));
}
li.append(head);
// ── line 2: action buttons ───────────────────────────────────

View file

@ -187,6 +187,12 @@ struct ContainerView {
/// disable other buttons.
#[serde(skip_serializing_if = "Option::is_none")]
pending: Option<&'static str>,
/// First 12 chars of the sha the meta flake currently has locked
/// for this agent's input. Reflects what's actually deployed; can
/// differ from `applied/<n>/main` only between
/// `meta::prepare_deploy` and `finalize_deploy` (≤ build duration).
#[serde(skip_serializing_if = "Option::is_none")]
deployed_sha: Option<String>,
}
#[derive(Serialize)]
@ -281,6 +287,7 @@ async fn build_container_views(
) -> (Vec<ContainerView>, bool) {
let mut out = Vec::new();
let mut any_stale = false;
let locked = read_meta_locked_revs();
for c in raw_containers {
let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true)
@ -299,6 +306,9 @@ async fn build_container_views(
let pending = transient_snapshot
.get(&logical)
.map(|st| transient_label(st.kind));
let deployed_sha = locked
.get(&format!("agent-{logical}"))
.map(|s| s[..s.len().min(12)].to_owned());
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
@ -308,11 +318,39 @@ async fn build_container_views(
needs_update,
needs_login,
pending,
deployed_sha,
});
}
(out, any_stale)
}
/// Parse `/var/lib/hyperhive/meta/flake.lock` into a map of node name
/// (`agent-<n>`, `hyperhive`) → locked sha. Missing / unparsable lock
/// yields an empty map so the dashboard degrades gracefully when the
/// meta repo hasn't been seeded yet.
fn read_meta_locked_revs() -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else {
return out;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
for (name, node) in nodes {
if let Some(rev) = node
.get("locked")
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
out.insert(name.clone(), rev.to_owned());
}
}
out
}
/// Transient state for agents whose container does NOT yet exist
/// (`Spawning`). Lifecycle ops on existing containers surface as
/// `ContainerView.pending` inline; this list only catches pre-creation.