phase 6: container events + drop the 5s /api/state poll
new DashboardEvent::ContainerStateChanged + ContainerRemoved close the last refetch loop on the dashboard. Coordinator's rescan_containers_and_emit diffs a fresh container_view::build_all against a cached last_containers map and fires per-row events. called from actions::approve (post-spawn), actions::destroy, the lifecycle_action wrapper, auto_update::rebuild_agent, and the existing 10s crash_watch poll. ContainerView extracted to its own module so coordinator and dashboard can both build it. dashboard endpoints flip to 200; container-lifecycle forms carry data-no-refresh. client drops the periodic poll entirely — initial cold load + SSE for everything afterwards. pending overlay reads from the existing transientsState since the new event payload doesn't carry it. PURG3 + meta-update keep the post-submit refetch since tombstones + meta_inputs aren't event-derived yet; tracked in TODO.md.
This commit is contained in:
parent
f153639cb4
commit
e7ce35c503
11 changed files with 396 additions and 195 deletions
121
hive-c0re/src/container_view.rs
Normal file
121
hive-c0re/src/container_view.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! `ContainerView` + the snapshot builder that turns
|
||||
//! `nixos-container list` (plus per-agent state on disk) into the row
|
||||
//! shape the dashboard renders. Extracted from `dashboard.rs` so the
|
||||
//! coordinator's rescan-and-emit helper can build the same view and
|
||||
//! diff against the last snapshot to fire
|
||||
//! `ContainerStateChanged` / `ContainerRemoved` events.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
|
||||
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct ContainerView {
|
||||
/// Logical agent name (no `h-` prefix). Used in action URLs.
|
||||
pub name: String,
|
||||
/// Container name as nixos-container sees it (`h-foo`, `hm1nd`).
|
||||
pub container: String,
|
||||
pub is_manager: bool,
|
||||
pub port: u16,
|
||||
pub running: bool,
|
||||
pub needs_update: bool,
|
||||
pub needs_login: bool,
|
||||
/// First 12 chars of the sha the meta flake currently has locked
|
||||
/// for this agent's input.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployed_sha: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the full container list. Wraps `lifecycle::list()` and
|
||||
/// resolves every per-agent attribute the dashboard surfaces.
|
||||
pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||
let raw = lifecycle::list().await.unwrap_or_default();
|
||||
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
|
||||
let locked = read_meta_locked_revs();
|
||||
let mut out = Vec::new();
|
||||
for c in &raw {
|
||||
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));
|
||||
let needs_login =
|
||||
!is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
|
||||
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,
|
||||
container: c.clone(),
|
||||
name: logical,
|
||||
is_manager,
|
||||
needs_update,
|
||||
needs_login,
|
||||
deployed_sha,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true
|
||||
/// if the agent's bound `~/.claude/` dir on disk contains any regular
|
||||
/// file. Reads each `build_all()` so a login driven from the agent's
|
||||
/// own web UI reflects on the next snapshot.
|
||||
pub fn claude_has_session(dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
}
|
||||
|
||||
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
|
||||
/// render the `deployed:<sha12>` chip per container row.
|
||||
fn read_meta_locked_revs() -> HashMap<String, String> {
|
||||
let mut out = 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;
|
||||
};
|
||||
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
|
||||
return out;
|
||||
};
|
||||
let Some(root_inputs) = nodes
|
||||
.get(root_name)
|
||||
.and_then(|n| n.get("inputs"))
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return out;
|
||||
};
|
||||
for alias in root_inputs.keys() {
|
||||
let target_name = match root_inputs.get(alias) {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(rev) = nodes
|
||||
.get(&target_name)
|
||||
.and_then(|n| n.get("locked"))
|
||||
.and_then(|v| v.get("rev"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
out.insert(alias.clone(), rev.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue