diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 2970821a..da62d746 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -372,7 +372,7 @@ fn build_transient_views( containers: &[ContainerView], transient_snapshot: &std::collections::HashMap>, ) -> Vec { - transient_snapshot + let mut out: Vec = transient_snapshot .iter() .filter(|(name, _)| !containers.iter().any(|c| &c.name == *name)) // One row per running node, so an agent with several shows several @@ -389,7 +389,18 @@ fn build_transient_views( .max(0) .cast_unsigned(), }) - .collect() + .collect(); + // The caller rebuilds this map on every poll, so its iteration order + // differs between renders and the rows would shuffle under an operator + // watching an operation run. Sorted for the same reason + // `build_port_conflicts` sorts its clusters. + out.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.secs.cmp(&b.secs)) + }); + out } /// Render each pending approval into its dashboard view (short sha for @@ -717,7 +728,82 @@ pub(super) async fn dashboard_stream( #[cfg(test)] mod tests { - use super::{ContainerView, build_port_conflicts}; + use std::collections::HashMap; + + use chrono::{Duration, Utc}; + + use super::{ContainerView, build_port_conflicts, build_transient_views}; + use crate::job_queue::RunningTransient; + + fn rt(agent: &str, label: &str, age_secs: i64) -> RunningTransient { + RunningTransient { + agent: agent.to_owned(), + label: label.to_owned(), + takes_container_down: false, + since: Utc::now() - Duration::seconds(age_secs), + } + } + + fn snapshot(rows: Vec) -> HashMap> { + let mut m: HashMap> = HashMap::new(); + for r in rows { + m.entry(r.agent.clone()).or_default().push(r); + } + m + } + + #[test] + fn an_agent_whose_container_exists_is_not_transient() { + let snap = snapshot(vec![rt("alice", "spawn", 5)]); + let live = [cv("alice", 8101, true)]; + assert!(build_transient_views(&live, &snap).is_empty()); + // Control: with no container by that name it *is* reported, so the + // emptiness above is the filter and not an inert builder. + assert_eq!(build_transient_views(&[], &snap).len(), 1); + } + + #[test] + fn an_agent_with_several_running_nodes_gets_a_row_each() { + let snap = snapshot(vec![rt("alice", "spawn", 5), rt("alice", "update", 3)]); + assert_eq!(build_transient_views(&[], &snap).len(), 2); + } + + /// The snapshot is a fresh `HashMap` per poll, so its iteration order is + /// not stable across renders. Sorting is what keeps a row still. + #[test] + fn rows_are_ordered_by_name_then_kind() { + let snap = snapshot(vec![ + rt("zoe", "spawn", 1), + rt("adam", "update", 1), + rt("adam", "spawn", 1), + ]); + let got: Vec<(String, String)> = build_transient_views(&[], &snap) + .into_iter() + .map(|v| (v.name, v.kind)) + .collect(); + assert_eq!( + got, + [ + ("adam".to_owned(), "spawn".to_owned()), + ("adam".to_owned(), "update".to_owned()), + ("zoe".to_owned(), "spawn".to_owned()), + ] + ); + } + + /// A `since` in the future yields a negative duration, and casting that + /// unsigned would render an age near `u64::MAX` rather than a small one. + #[test] + fn a_future_since_clamps_to_zero_instead_of_wrapping() { + let snap = snapshot(vec![rt("alice", "spawn", -600)]); + let got = build_transient_views(&[], &snap); + assert_eq!(got[0].secs, 0, "clamped, not wrapped"); + } + + #[test] + fn an_empty_snapshot_produces_no_rows() { + assert!(build_transient_views(&[], &snapshot(vec![])).is_empty()); + } fn cv(name: &str, port: u16, running: bool) -> ContainerView { ContainerView {