From c27111ac32a061332cab079a7ee887d6ae78a44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:13:08 +0200 Subject: [PATCH] dashboard: split api_state into per-section builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drops the #[allow(clippy::too_many_lines)] on api_state by extracting four pure helpers: - build_container_views — live containers + any_stale flag - build_transient_views — agents in pre-creation Spawning state only - build_approval_views — pending approvals with diff html - build_tombstone_views — destroyed-but-kept state dirs api_state itself is now ~30 lines of orchestration. zero behavior change. each helper is independently readable + testable. --- hive-c0re/src/dashboard.rs | 128 +++++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 49 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 44b8715..cc7cef6 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -161,7 +161,6 @@ struct ApprovalView { diff_html: Option, } -#[allow(clippy::too_many_lines)] async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { let host = headers .get("host") @@ -172,14 +171,48 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J let raw_containers = lifecycle::list().await.unwrap_or_default(); let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); let transient_snapshot = state.coord.transient_snapshot(); - let approvals = gc_orphans( + let pending_approvals = gc_orphans( &state.coord, state.coord.approvals.pending().unwrap_or_default(), ); - let mut containers = Vec::new(); + let (containers, any_stale) = + build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await; + let transients = build_transient_views(&raw_containers, &transient_snapshot); + let approvals = build_approval_views(pending_approvals).await; + let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot); + + let operator_inbox = state + .coord + .broker + .recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50) + .unwrap_or_default(); + let questions = state.coord.questions.pending().unwrap_or_default(); + + axum::Json(StateSnapshot { + hostname, + manager_port: MANAGER_PORT, + any_stale, + containers, + transients, + approvals, + operator_inbox, + questions, + tombstones, + }) +} + +/// Build `ContainerView`s for every live nixos-container. Returns the +/// list and whether any container is stale (drives the "↻ UPD4TE 4LL" +/// banner). +async fn build_container_views( + raw_containers: &[String], + current_rev: Option<&str>, + transient_snapshot: &std::collections::HashMap, +) -> (Vec, bool) { + let mut out = Vec::new(); let mut any_stale = false; - for c in &raw_containers { + 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) { @@ -187,21 +220,16 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J } else { continue; }; - let needs_update = current_rev - .as_deref() - .is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev)); + let needs_update = + current_rev.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)) - }; + let needs_login = !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical)); let pending = transient_snapshot .get(&logical) .map(|st| transient_label(st.kind)); - containers.push(ContainerView { + out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, container: c.clone(), @@ -212,24 +240,37 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J pending, }); } + (out, any_stale) +} - let transients = transient_snapshot - .into_iter() +/// 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. +fn build_transient_views( + raw_containers: &[String], + transient_snapshot: &std::collections::HashMap, +) -> Vec { + transient_snapshot + .iter() .filter(|(name, _)| { !raw_containers .iter() - .any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == name) + .any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == *name) }) .map(|(name, st)| TransientView { - name, + name: name.clone(), kind: transient_label(st.kind), secs: st.since.elapsed().as_secs(), }) - .collect(); + .collect() +} - let mut approval_views = Vec::with_capacity(approvals.len()); +/// Render each pending approval into its dashboard view (short sha + +/// unified diff for `ApplyCommit`, just the name for `Spawn`). +async fn build_approval_views(approvals: Vec) -> Vec { + let mut out = Vec::with_capacity(approvals.len()); for a in approvals { - let view = match a.kind { + out.push(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; @@ -248,27 +289,28 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J sha_short: None, diff_html: None, }, - }; - approval_views.push(view); + }); } + out +} - let operator_inbox = state - .coord - .broker - .recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50) - .unwrap_or_default(); - let questions = state.coord.questions.pending().unwrap_or_default(); - - // Tombstones: state-dir names that don't appear in the live container - // list (and aren't the manager). Operator can re-spawn or PURG3. - let live: std::collections::HashSet = containers +/// State-dir names that don't appear in the live container list (and +/// aren't the manager). Each one surfaces in the dashboard as a row +/// with R3V1V3 + PURG3 actions. +fn build_tombstone_views( + coord: &Coordinator, + containers: &[ContainerView], + transient_snapshot: &std::collections::HashMap, +) -> Vec { + let _ = coord; // kept_state_names is a free fn but takes &self by future plan + let live: std::collections::HashSet<&str> = containers .iter() - .map(|c| c.name.clone()) - .chain(state.coord.transient_snapshot().into_keys()) + .map(|c| c.name.as_str()) + .chain(transient_snapshot.keys().map(String::as_str)) .collect(); - let tombstones: Vec = Coordinator::kept_state_names() + Coordinator::kept_state_names() .into_iter() - .filter(|name| name != MANAGER_NAME && !live.contains(name)) + .filter(|name| name != MANAGER_NAME && !live.contains(name.as_str())) .map(|name| { let root = Coordinator::agent_state_root(&name); let state_bytes = dir_size_bytes(&root); @@ -286,19 +328,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J has_creds, } }) - .collect(); - - axum::Json(StateSnapshot { - hostname, - manager_port: MANAGER_PORT, - any_stale, - containers, - transients, - approvals: approval_views, - operator_inbox, - questions, - tombstones, - }) + .collect() } /// Sum the byte size of every regular file under `root`. Cheap to compute