//! Physical topology derivation for the dashboard map. //! //! Pure config → map translation: nodes, cables, HA glyphs. The UI draws //! the fixed scene; the snapshot builder only attaches live HA states. use crate::config::Config; use serde::Serialize; /// One cable on the map: `a` → `b`, where `a` is a switch entity id, the /// pseudo-switch `"lan"` (the passive 10G), or a node name (DAC), and `b` /// is a node name. #[derive(Serialize, Clone, Debug, PartialEq)] pub struct TopoLink { /// "200g" (HA-managed fabric switch), "dac" (direct-attach copper), /// "10g" (passive 10G switch). pub kind: String, pub a: String, pub b: String, } /// An HA-managed device glyph: the blocking fabric switch ("ib") or an /// auxiliary entity like a fan ("aux"). #[derive(Serialize, Clone, Debug, PartialEq)] pub struct TopoSwitch { pub entity: String, pub kind: String, } /// HA state for a map glyph: `on`/`off`, `None` = unknown/unreachable. #[derive(Serialize, Clone, Debug, PartialEq)] pub struct TopoState { pub entity: String, pub on: Option, } /// The fixed physical map, derived from config facts (see [`derive_topo`]). #[derive(Serialize, Clone, Debug, Default, PartialEq)] pub struct TopoSnap { /// Every configured node, sorted by slot (digits parsed from the name). pub nodes: Vec, pub links: Vec, pub switches: Vec, /// HA states for [`TopoSnap::switches`] (filled by the snapshot builder). pub states: Vec, } /// Derive the fixed physical topology from the config alone: /// /// - every configured node occupies its slot (`dgx7.lan` → slot 7) — /// absent slots render as "planned"; /// - a node with an `ib_ip` hangs on its cluster's *blocking* switch /// entity that is not a node plug (the 200G fabric, HA-managed) — /// or, when the cluster has no blocking switch (`2nodes-dac`), the IB /// link is direct-attach copper between consecutive ranks; /// - every node hangs on the passive 10G switch (pseudo-entity `"lan"`, /// no HA state); /// - extra entities that are neither blocking-fabric switches nor node /// plugs appear as auxiliary glyphs (a fan). pub(crate) fn derive_topo(config: &Config) -> TopoSnap { let slot = |n: &str| { n.chars() .filter(|c| c.is_ascii_digit()) .collect::() .parse::() .unwrap_or(u32::MAX) }; let mut nodes: Vec = Vec::new(); let mut links: Vec = Vec::new(); let mut switches: Vec = Vec::new(); let push_switch = |switches: &mut Vec, entity: &str, kind: &str| { if !switches.iter().any(|s| s.entity == entity) { switches.push(TopoSwitch { entity: entity.to_string(), kind: kind.to_string(), }); } }; for c in &config.clusters { let node_entities: Vec = c.nodes.iter().filter_map(|n| n.entity.clone()).collect(); for n in &c.nodes { if !nodes.contains(&n.name) { nodes.push(n.name.clone()); } } let ib: Vec<&crate::config::Node> = c.nodes.iter().filter(|n| n.ib_ip.is_some()).collect(); let blocking = c .extra_entities .iter() .find(|e| e.blocks() && !node_entities.contains(&e.entity().to_string())); if let Some(sw) = blocking { push_switch(&mut switches, sw.entity(), "ib"); for n in ib { let link = TopoLink { kind: "200g".into(), a: sw.entity().to_string(), b: n.name.clone(), }; // the same physical cable appears in every cluster that // shares the fabric — one line on the map if !links.contains(&link) { links.push(link); } } } else { // An IB subnet with no switch fabric: direct-attach copper, // point-to-point between consecutive ranks. let mut ranked = ib; ranked.sort_by_key(|n| n.rank); for w in ranked.windows(2) { links.push(TopoLink { kind: "dac".into(), a: w[0].name.clone(), b: w[1].name.clone(), }); } } for e in &c.extra_entities { if !e.blocks() && !node_entities.contains(&e.entity().to_string()) { push_switch(&mut switches, e.entity(), "aux"); } } } nodes.sort_by_key(|n| (slot(n), n.clone())); for n in &nodes { links.push(TopoLink { kind: "10g".into(), a: "lan".into(), b: n.clone(), }); } TopoSnap { nodes, links, switches, states: Vec::new(), } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn node(name: &str, ib: Option<&str>, entity: Option<&str>, rank: u32) -> crate::config::Node { crate::config::Node { name: name.into(), ssh_ip: "10.0.0.1".into(), ib_ip: ib.map(String::from), rank, host_key: None, entity: entity.map(String::from), } } fn cluster( name: &str, nodes: Vec, extra: Vec, ) -> crate::config::Cluster { crate::config::Cluster { name: name.into(), master_ib_ip: None, nodes, extra_entities: extra, } } #[test] fn derive_topo_fabric_dac_lan() { let config = Config { ssh_user: "u".into(), ssh_key_path: "/k".into(), nccl: HashMap::new(), hf_cache_dir: None, init_port: 25000, before_serve: HashMap::new(), exec_aliases: HashMap::new(), home_assistant: None, mikrotik: None, global_mods_dir: None, drop_caches: true, mount_aliases: HashMap::new(), recipe_sources: Vec::new(), recipe_repos: Vec::new(), docker_image_sources: Vec::new(), cluster_sources: Vec::new(), registry: None, clusters: vec![ // fabric: blocking switch + ib nodes → 200g links cluster( "fabric", vec![ node("dgx2.lan", Some("10.0.200.2"), Some("switch.dgx2"), 1), node("dgx1.lan", Some("10.0.200.1"), Some("switch.dgx1"), 0), ], vec![ crate::config::ExtraEntity::Simple("switch.sw6".into()), crate::config::ExtraEntity::Detailed { entity: "switch.dgx1".into(), blocks: true, }, ], ), // dac: ib nodes, no blocking switch → direct chain by rank cluster( "dac", vec![ node("dgx1.lan", Some("10.0.100.1"), Some("switch.dgx1"), 0), node("dgx2.lan", Some("10.0.100.2"), Some("switch.dgx2"), 1), ], vec![], ), // lan-only nodes (no ib_ip), plus a fan extra cluster( "solo", vec![node("dgx10.lan", None, Some("switch.dgx10"), 0)], vec![crate::config::ExtraEntity::Detailed { entity: "switch.fan".into(), blocks: false, }], ), ], }; let t = derive_topo(&config); // slots: dgx1 < dgx2 < dgx10 (numeric, not lexicographic) assert_eq!(t.nodes, vec!["dgx1.lan", "dgx2.lan", "dgx10.lan"]); // 200g: every ib node of the fabric cluster → the blocking switch assert_eq!(t.links.iter().filter(|l| l.kind == "200g").count(), 2); assert!( t.links .iter() .any(|l| l.kind == "200g" && l.a == "switch.sw6" && l.b == "dgx1.lan") ); // dac chain between the ranks (dgx1 → dgx2) assert!( t.links .iter() .any(|l| l.kind == "dac" && l.a == "dgx1.lan" && l.b == "dgx2.lan") ); // every node hangs on the passive lan assert_eq!(t.links.iter().filter(|l| l.kind == "10g").count(), 3); // switch glyphs: the fabric switch (ib, node-plug entities excluded) // and the fan (aux) — deduped across clusters assert_eq!( t.switches, vec![ TopoSwitch { entity: "switch.sw6".into(), kind: "ib".into() }, TopoSwitch { entity: "switch.fan".into(), kind: "aux".into() } ] ); } }