dashboard: split api_state into per-section builders

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.
This commit is contained in:
müde 2026-05-15 20:13:08 +02:00
parent 7b4adea325
commit c27111ac32

View file

@ -161,7 +161,6 @@ struct ApprovalView {
diff_html: Option<String>, diff_html: Option<String>,
} }
#[allow(clippy::too_many_lines)]
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> { async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
let host = headers let host = headers
.get("host") .get("host")
@ -172,14 +171,48 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
let raw_containers = lifecycle::list().await.unwrap_or_default(); let raw_containers = lifecycle::list().await.unwrap_or_default();
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake); let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
let transient_snapshot = state.coord.transient_snapshot(); let transient_snapshot = state.coord.transient_snapshot();
let approvals = gc_orphans( let pending_approvals = gc_orphans(
&state.coord, &state.coord,
state.coord.approvals.pending().unwrap_or_default(), 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<String, crate::coordinator::TransientState>,
) -> (Vec<ContainerView>, bool) {
let mut out = Vec::new();
let mut any_stale = false; let mut any_stale = false;
for c in &raw_containers { for c in raw_containers {
let (logical, is_manager) = if c == MANAGER_NAME { let (logical, is_manager) = if c == MANAGER_NAME {
(MANAGER_NAME.to_owned(), true) (MANAGER_NAME.to_owned(), true)
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) { } else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
@ -187,21 +220,16 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
} else { } else {
continue; continue;
}; };
let needs_update = current_rev let needs_update =
.as_deref() current_rev.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
.is_some_and(|rev| crate::auto_update::agent_needs_update(&logical, rev));
if needs_update { if needs_update {
any_stale = true; any_stale = true;
} }
let needs_login = if is_manager { let needs_login = !is_manager && !claude_has_session(&Coordinator::agent_claude_dir(&logical));
false
} else {
!claude_has_session(&Coordinator::agent_claude_dir(&logical))
};
let pending = transient_snapshot let pending = transient_snapshot
.get(&logical) .get(&logical)
.map(|st| transient_label(st.kind)); .map(|st| transient_label(st.kind));
containers.push(ContainerView { out.push(ContainerView {
port: lifecycle::agent_web_port(&logical), port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await, running: lifecycle::is_running(&logical).await,
container: c.clone(), container: c.clone(),
@ -212,24 +240,37 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
pending, pending,
}); });
} }
(out, any_stale)
}
let transients = transient_snapshot /// Transient state for agents whose container does NOT yet exist
.into_iter() /// (`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<String, crate::coordinator::TransientState>,
) -> Vec<TransientView> {
transient_snapshot
.iter()
.filter(|(name, _)| { .filter(|(name, _)| {
!raw_containers !raw_containers
.iter() .iter()
.any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == name) .any(|c| c == &format!("{AGENT_PREFIX}{name}") || c == *name)
}) })
.map(|(name, st)| TransientView { .map(|(name, st)| TransientView {
name, name: name.clone(),
kind: transient_label(st.kind), kind: transient_label(st.kind),
secs: st.since.elapsed().as_secs(), 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<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals { for a in approvals {
let view = match a.kind { out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => { hive_sh4re::ApprovalKind::ApplyCommit => {
let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned(); let sha = a.commit_ref[..a.commit_ref.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, &a.commit_ref).await; let diff = approval_diff(&a.agent, &a.commit_ref).await;
@ -248,27 +289,28 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
sha_short: None, sha_short: None,
diff_html: None, diff_html: None,
}, },
}; });
approval_views.push(view);
} }
out
}
let operator_inbox = state /// State-dir names that don't appear in the live container list (and
.coord /// aren't the manager). Each one surfaces in the dashboard as a row
.broker /// with R3V1V3 + PURG3 actions.
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50) fn build_tombstone_views(
.unwrap_or_default(); coord: &Coordinator,
let questions = state.coord.questions.pending().unwrap_or_default(); containers: &[ContainerView],
transient_snapshot: &std::collections::HashMap<String, crate::coordinator::TransientState>,
// Tombstones: state-dir names that don't appear in the live container ) -> Vec<TombstoneView> {
// list (and aren't the manager). Operator can re-spawn or PURG3. let _ = coord; // kept_state_names is a free fn but takes &self by future plan
let live: std::collections::HashSet<String> = containers let live: std::collections::HashSet<&str> = containers
.iter() .iter()
.map(|c| c.name.clone()) .map(|c| c.name.as_str())
.chain(state.coord.transient_snapshot().into_keys()) .chain(transient_snapshot.keys().map(String::as_str))
.collect(); .collect();
let tombstones: Vec<TombstoneView> = Coordinator::kept_state_names() Coordinator::kept_state_names()
.into_iter() .into_iter()
.filter(|name| name != MANAGER_NAME && !live.contains(name)) .filter(|name| name != MANAGER_NAME && !live.contains(name.as_str()))
.map(|name| { .map(|name| {
let root = Coordinator::agent_state_root(&name); let root = Coordinator::agent_state_root(&name);
let state_bytes = dir_size_bytes(&root); let state_bytes = dir_size_bytes(&root);
@ -286,19 +328,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
has_creds, has_creds,
} }
}) })
.collect(); .collect()
axum::Json(StateSnapshot {
hostname,
manager_port: MANAGER_PORT,
any_stale,
containers,
transients,
approvals: approval_views,
operator_inbox,
questions,
tombstones,
})
} }
/// Sum the byte size of every regular file under `root`. Cheap to compute /// Sum the byte size of every regular file under `root`. Cheap to compute