events.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. //! SSE event bus + state snapshot for the dashboard.
  2. //!
  3. //! One multiplexed `broadcast` channel; `/events` forwards every event and
  4. //! the UI routes by `type`. Emits `state` (supervisor + job finishes),
  5. //! `job` (registry transitions), and `log` (attached window lines).
  6. use crate::config::Config;
  7. use crate::web::probe::ContainerSnap;
  8. use serde::Serialize;
  9. use std::collections::HashMap;
  10. use std::time::{SystemTime, UNIX_EPOCH};
  11. /// One SSE event (serialized as `{"type":"state"|"job", ...}` on the wire).
  12. #[derive(Serialize, Clone, Debug)]
  13. #[serde(tag = "type", rename_all = "lowercase")]
  14. pub enum Event {
  15. State {
  16. ts: u64,
  17. snapshot: Snapshot,
  18. },
  19. Job {
  20. id: String,
  21. /// "serve" | "stop" | "power"
  22. kind: &'static str,
  23. event: String,
  24. cluster: String,
  25. recipe: Option<String>,
  26. message: String,
  27. },
  28. Log {
  29. /// Attach id (matches `POST /api/logs` response).
  30. attach: String,
  31. cluster: String,
  32. node: String,
  33. recipe: String,
  34. rank: u32,
  35. line: String,
  36. },
  37. }
  38. /// Per-node view for the LED grid.
  39. #[derive(Serialize, Clone, Debug)]
  40. pub struct NodeSnap {
  41. pub name: String,
  42. pub ssh_ip: String,
  43. pub reachable: bool,
  44. pub occupied: bool,
  45. pub containers: Vec<ContainerSnap>,
  46. /// Node rank — log windows attach the rank's `docker logs` stream.
  47. pub rank: u32,
  48. /// The node's HA power plug (`node.entity`) reports "on"; `None` = no
  49. /// entity or HA unreachable (don't darken the device on a probe lag).
  50. pub ha: Option<bool>,
  51. }
  52. /// Per-cluster view.
  53. #[derive(Serialize, Clone, Debug)]
  54. pub struct ClusterSnap {
  55. pub name: String,
  56. pub nodes: Vec<NodeSnap>,
  57. /// All nodes reachable AND blocking HA switches on (cluster verdict).
  58. pub up: bool,
  59. /// A blocking `extra_entity` reports anything but "on".
  60. pub switch_off: bool,
  61. /// Non-blocking entities that are off (display only, e.g. a fan).
  62. pub soft_off: Vec<String>,
  63. /// Free nodes; 0 when the cluster is down.
  64. pub free: u32,
  65. }
  66. /// One log window attach (for state snapshots — the UI restores open
  67. /// log windows from `/api/state` after a reload).
  68. #[derive(Serialize, Clone, Debug)]
  69. pub struct AttachSnap {
  70. pub id: String,
  71. pub cluster: String,
  72. pub node: String,
  73. pub recipe: String,
  74. pub rank: u32,
  75. /// Stream ended on its own (container stopped, SSH drop).
  76. pub done: bool,
  77. }
  78. /// The full dashboard snapshot (also the `GET /api/state` body).
  79. #[derive(Serialize, Clone, Debug)]
  80. pub struct Snapshot {
  81. pub ts: u64,
  82. pub clusters: Vec<ClusterSnap>,
  83. /// The fixed physical map (nodes, cables, HA glyphs).
  84. pub topo: crate::web::topo::TopoSnap,
  85. pub jobs: Vec<crate::web::jobs::Job>,
  86. pub attaches: Vec<AttachSnap>,
  87. }
  88. fn now_ts() -> u64 {
  89. SystemTime::now()
  90. .duration_since(UNIX_EPOCH)
  91. .map(|d| d.as_secs())
  92. .unwrap_or(0)
  93. }
  94. /// Pure snapshot builder — unit-testable without SSH.
  95. ///
  96. /// Cluster-level semantics via [`crate::cluster_state::verdict`]: one
  97. /// offline node or a switch off takes the whole cluster down, and a down
  98. /// cluster reports no containers (even on nodes that are alive).
  99. pub fn build_snapshot(
  100. config: &Config,
  101. fleet: &crate::web::probe::Fleet,
  102. jobs: &[crate::web::jobs::Job],
  103. attaches: &[AttachSnap],
  104. ha: &HashMap<String, Option<bool>>,
  105. ) -> Snapshot {
  106. let nodes = &fleet.nodes;
  107. let switch_off = &fleet.switch_off;
  108. let soft_off = &fleet.soft_off;
  109. let mut clusters = Vec::with_capacity(config.clusters.len());
  110. for c in &config.clusters {
  111. let mut node_snaps = Vec::with_capacity(c.nodes.len());
  112. let mut reachables: Vec<(bool, bool)> = Vec::with_capacity(c.nodes.len());
  113. for n in &c.nodes {
  114. let s = nodes.get(&n.name);
  115. let reachable = s.is_some_and(|x| x.reachable);
  116. let occupied = s.is_some_and(|x| x.occupied);
  117. reachables.push((reachable, occupied));
  118. }
  119. let so = switch_off.get(&c.name).copied().unwrap_or(false);
  120. let v = crate::cluster_state::verdict(&reachables, so);
  121. for n in &c.nodes {
  122. let s = nodes.get(&n.name);
  123. let reachable = s.is_some_and(|x| x.reachable);
  124. let occupied = s.is_some_and(|x| x.occupied);
  125. // A down cluster reports no containers — the cluster is off as
  126. // a whole, whatever individual nodes still run.
  127. let containers = if v.up {
  128. s.map(|x| x.containers.clone()).unwrap_or_default()
  129. } else {
  130. Vec::new()
  131. };
  132. node_snaps.push(NodeSnap {
  133. name: n.name.clone(),
  134. ssh_ip: n.ssh_ip.clone(),
  135. reachable,
  136. occupied,
  137. containers,
  138. rank: n.rank,
  139. ha: n
  140. .entity
  141. .as_deref()
  142. .and_then(|e| ha.get(e).copied().flatten()),
  143. });
  144. }
  145. clusters.push(ClusterSnap {
  146. name: c.name.clone(),
  147. nodes: node_snaps,
  148. up: v.up,
  149. switch_off: so,
  150. soft_off: soft_off.get(&c.name).cloned().unwrap_or_default(),
  151. free: v.free,
  152. });
  153. }
  154. let mut topo = crate::web::topo::derive_topo(config);
  155. topo.states = topo
  156. .switches
  157. .iter()
  158. .map(|s| crate::web::topo::TopoState {
  159. entity: s.entity.clone(),
  160. on: ha.get(&s.entity).copied().flatten(),
  161. })
  162. .collect();
  163. Snapshot {
  164. ts: now_ts(),
  165. clusters,
  166. topo,
  167. jobs: jobs.to_vec(),
  168. attaches: attaches.to_vec(),
  169. }
  170. }
  171. #[cfg(test)]
  172. mod tests {
  173. use super::*;
  174. use std::collections::HashMap;
  175. fn config() -> Config {
  176. Config {
  177. ssh_user: "u".into(),
  178. ssh_key_path: "/k".into(),
  179. clusters: vec![
  180. crate::config::Cluster {
  181. name: "a".into(),
  182. master_ib_ip: None,
  183. nodes: vec![
  184. crate::config::Node {
  185. name: "n0".into(),
  186. ssh_ip: "10.0.0.1".into(),
  187. ib_ip: None,
  188. rank: 0,
  189. host_key: None,
  190. entity: None,
  191. },
  192. crate::config::Node {
  193. name: "n1".into(),
  194. ssh_ip: "10.0.0.2".into(),
  195. ib_ip: None,
  196. rank: 1,
  197. host_key: None,
  198. entity: None,
  199. },
  200. ],
  201. extra_entities: vec![],
  202. },
  203. crate::config::Cluster {
  204. name: "b".into(),
  205. master_ib_ip: None,
  206. nodes: vec![crate::config::Node {
  207. name: "n2".into(),
  208. ssh_ip: "10.0.0.3".into(),
  209. ib_ip: None,
  210. rank: 0,
  211. host_key: None,
  212. entity: None,
  213. }],
  214. extra_entities: vec![],
  215. },
  216. ],
  217. nccl: std::collections::HashMap::new(),
  218. hf_cache_dir: None,
  219. init_port: 25000,
  220. before_serve: std::collections::HashMap::new(),
  221. exec_aliases: std::collections::HashMap::new(),
  222. home_assistant: None,
  223. global_mods_dir: None,
  224. drop_caches: true,
  225. mount_aliases: std::collections::HashMap::new(),
  226. recipe_sources: Vec::new(),
  227. docker_image_sources: Vec::new(),
  228. cluster_sources: Vec::new(),
  229. }
  230. }
  231. #[test]
  232. fn snapshot_reflects_probe_state() {
  233. let config = config();
  234. let mut nodes = HashMap::new();
  235. nodes.insert(
  236. "n0".into(),
  237. crate::web::probe::WebNodeState {
  238. reachable: true,
  239. occupied: true,
  240. containers: vec![ContainerSnap {
  241. name: "sm121_r_0".into(),
  242. status: "Up 3 hours".into(),
  243. cluster: "a".into(),
  244. recipe: "r".into(),
  245. }],
  246. },
  247. );
  248. nodes.insert(
  249. "n1".into(),
  250. crate::web::probe::WebNodeState {
  251. reachable: true,
  252. occupied: false,
  253. containers: vec![],
  254. },
  255. );
  256. // n2 absent → unreachable.
  257. let fleet = crate::web::probe::Fleet {
  258. nodes,
  259. switch_off: std::collections::HashMap::new(),
  260. soft_off: std::collections::HashMap::new(),
  261. ha: std::collections::HashMap::new(),
  262. };
  263. let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
  264. assert_eq!(snap.clusters.len(), 2);
  265. let a = &snap.clusters[0];
  266. assert!(a.up);
  267. assert!(a.nodes[0].reachable && a.nodes[0].occupied);
  268. assert_eq!(a.nodes[0].containers[0].recipe, "r");
  269. assert!(a.nodes[1].reachable && !a.nodes[1].occupied);
  270. assert_eq!(a.free, 1);
  271. // cluster b has one offline node → down as a whole
  272. let b = &snap.clusters[1];
  273. assert!(!b.up);
  274. assert!(!b.nodes[0].reachable);
  275. assert_eq!(b.free, 0);
  276. assert!(snap.jobs.is_empty());
  277. }
  278. #[test]
  279. fn down_cluster_hides_containers_on_live_nodes() {
  280. // Both nodes of cluster a are live; n1 runs a container but n0 is
  281. // offline → cluster a is down as a whole and the live node's
  282. // container must not be reported.
  283. let config = config();
  284. let mut nodes = HashMap::new();
  285. nodes.insert("n0".into(), crate::web::probe::WebNodeState::default());
  286. nodes.insert(
  287. "n1".into(),
  288. crate::web::probe::WebNodeState {
  289. reachable: true,
  290. occupied: true,
  291. containers: vec![ContainerSnap {
  292. name: "c2".into(),
  293. status: "Up".into(),
  294. cluster: "a".into(),
  295. recipe: "r".into(),
  296. }],
  297. },
  298. );
  299. let fleet = crate::web::probe::Fleet {
  300. nodes,
  301. switch_off: std::collections::HashMap::new(),
  302. soft_off: std::collections::HashMap::new(),
  303. ha: std::collections::HashMap::new(),
  304. };
  305. let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
  306. let a = &snap.clusters[0];
  307. assert!(!a.up, "one offline node takes the cluster down");
  308. assert_eq!(a.free, 0);
  309. assert!(
  310. a.nodes[1].containers.is_empty(),
  311. "containers hidden on a down cluster"
  312. );
  313. assert!(a.nodes[1].reachable, "reachability is still shown");
  314. }
  315. #[test]
  316. fn switch_off_takes_cluster_down() {
  317. let config = config();
  318. let mut nodes = HashMap::new();
  319. for n in ["n0", "n1", "n2"] {
  320. nodes.insert(
  321. n.into(),
  322. crate::web::probe::WebNodeState {
  323. reachable: true,
  324. occupied: false,
  325. containers: vec![],
  326. },
  327. );
  328. }
  329. let mut so = HashMap::new();
  330. so.insert("a".into(), true);
  331. let fleet = crate::web::probe::Fleet {
  332. nodes,
  333. switch_off: so,
  334. soft_off: std::collections::HashMap::new(),
  335. ha: std::collections::HashMap::new(),
  336. };
  337. let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
  338. assert!(!snap.clusters[0].up);
  339. assert!(snap.clusters[0].switch_off);
  340. assert_eq!(snap.clusters[0].free, 0);
  341. assert!(snap.clusters[1].up);
  342. assert_eq!(snap.clusters[1].free, 1);
  343. }
  344. #[test]
  345. fn soft_off_does_not_take_cluster_down() {
  346. // A non-blocking entity (fan) is off, all nodes live → cluster
  347. // stays up; the off entity is surfaced for display only.
  348. let config = config();
  349. let mut nodes = HashMap::new();
  350. for n in ["n0", "n1", "n2"] {
  351. nodes.insert(
  352. n.into(),
  353. crate::web::probe::WebNodeState {
  354. reachable: true,
  355. occupied: false,
  356. containers: vec![],
  357. },
  358. );
  359. }
  360. let mut soft = HashMap::new();
  361. soft.insert("a".into(), vec!["switch.fan".to_string()]);
  362. let fleet = crate::web::probe::Fleet {
  363. nodes,
  364. switch_off: HashMap::new(),
  365. soft_off: soft,
  366. ha: std::collections::HashMap::new(),
  367. };
  368. let snap = build_snapshot(&config, &fleet, &[], &[], &HashMap::new());
  369. assert!(
  370. snap.clusters[0].up,
  371. "non-blocking off must not down the cluster"
  372. );
  373. assert_eq!(snap.clusters[0].free, 2);
  374. assert_eq!(snap.clusters[0].soft_off, vec!["switch.fan".to_string()]);
  375. assert!(snap.clusters[1].soft_off.is_empty());
  376. }
  377. #[test]
  378. fn job_event_serializes_with_type_tag() {
  379. let ev = Event::Job {
  380. id: "j1".into(),
  381. kind: "serve",
  382. event: "started".into(),
  383. cluster: "c".into(),
  384. recipe: Some("r".into()),
  385. message: "ok".into(),
  386. };
  387. let json = serde_json::to_value(&ev).unwrap();
  388. assert_eq!(json["type"], "job");
  389. assert_eq!(json["kind"], "serve");
  390. assert_eq!(json["id"], "j1");
  391. assert_eq!(json["event"], "started");
  392. }
  393. }