diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index ed691d56..8d9fb0a7 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -342,11 +342,13 @@ pub(super) async fn api_state( }) } -/// Group live containers by their assigned web UI port; clusters with -/// more than one member are port-hash collisions the operator needs -/// to resolve by renaming. Manager (fixed at 8000) and sub-agents -/// (8100..8999) can't collide with each other — collisions are -/// strictly between sub-agents. +/// Group containers by their assigned web UI port; clusters with more +/// than one member are port-hash collisions the operator resolves by +/// renaming an agent. +/// +/// Every container's port comes from [`crate::lifecycle::agent_web_port`], +/// which hashes the name into one range for all of them — so the manager +/// is a candidate for a collision exactly like any sub-agent. fn build_port_conflicts(containers: &[ContainerView]) -> Vec { let mut by_port: std::collections::BTreeMap> = std::collections::BTreeMap::new(); @@ -712,3 +714,100 @@ pub(super) async fn dashboard_stream( }); Sse::new(stream).keep_alive(KeepAlive::default()) } + +#[cfg(test)] +mod tests { + use super::{ContainerView, build_port_conflicts}; + + fn cv(name: &str, port: u16, running: bool) -> ContainerView { + ContainerView { + name: name.to_owned(), + container: format!("h-{name}"), + port, + running, + failed: false, + needs_update: false, + needs_login: false, + deployed_sha: None, + parent: None, + active_model: None, + paused: false, + cpu_quota: "200%".to_owned(), + memory_max: "4G".to_owned(), + } + } + + #[test] + fn distinct_ports_produce_no_conflicts() { + let cs = [cv("alice", 8101, true), cv("bob", 8102, true)]; + assert!(build_port_conflicts(&cs).is_empty()); + } + + #[test] + fn a_lone_container_does_not_conflict_with_itself() { + assert!(build_port_conflicts(&[cv("alice", 8101, true)]).is_empty()); + } + + #[test] + fn two_agents_on_one_port_are_reported_together() { + let cs = [cv("alice", 8101, true), cv("bob", 8101, true)]; + let got = build_port_conflicts(&cs); + assert_eq!(got.len(), 1); + assert_eq!(got[0].port, 8101); + assert_eq!(got[0].agents, ["alice", "bob"]); + } + + /// The manager takes its port from the same hash as everyone else, so + /// it collides like any other name. A doc comment here used to claim it + /// sat at a fixed 8000 and could not — no such special case exists. + #[test] + fn the_manager_can_collide_with_a_sub_agent() { + let cs = [cv("manager", 8101, true), cv("alice", 8101, true)]; + let got = build_port_conflicts(&cs); + assert_eq!(got.len(), 1); + assert_eq!(got[0].agents, ["alice", "manager"]); + } + + /// A stopped agent keeps its hashed port, so the clash is real the + /// moment it starts. Reporting it while it is down is the point. + #[test] + fn a_stopped_container_still_claims_its_port() { + let cs = [cv("alice", 8101, true), cv("bob", 8101, false)]; + assert_eq!(build_port_conflicts(&cs).len(), 1); + } + + #[test] + fn names_are_sorted_within_a_conflict() { + let cs = [cv("zoe", 8101, true), cv("adam", 8101, true)]; + assert_eq!(build_port_conflicts(&cs)[0].agents, ["adam", "zoe"]); + } + + #[test] + fn more_than_two_agents_land_in_one_cluster() { + let cs = [ + cv("c", 8101, true), + cv("a", 8101, true), + cv("b", 8101, true), + ]; + let got = build_port_conflicts(&cs); + assert_eq!(got.len(), 1, "one cluster, not one row per pair"); + assert_eq!(got[0].agents, ["a", "b", "c"]); + } + + #[test] + fn separate_clusters_are_ordered_by_port() { + let cs = [ + cv("d", 8300, true), + cv("c", 8300, true), + cv("b", 8200, true), + cv("a", 8200, true), + ]; + let ports: Vec = build_port_conflicts(&cs).iter().map(|p| p.port).collect(); + assert_eq!(ports, [8200, 8300]); + } + + #[test] + fn no_containers_is_not_a_conflict() { + assert!(build_port_conflicts(&[]).is_empty()); + } +}