Skip to main content

kanade_shared/wire/
heartbeat.rs

1use serde::{Deserialize, Serialize};
2
3/// Liveness ping every agent sends on a 30 s cadence (see
4/// `inventory_interval` / `heartbeat_interval` in agent_config).
5///
6/// `hostname` and `os_family` are enriched baseline facts so the
7/// SPA agents page has *something* to show as soon as the agent
8/// boots — even when the full WMI-driven `HwInventory` hasn't been
9/// (or can't be) collected. Both stay `Option<String>` so older
10/// agents that don't send them still deserialize cleanly.
11#[derive(Serialize, Deserialize, Debug, Clone)]
12pub struct Heartbeat {
13    pub pc_id: String,
14    pub at: chrono::DateTime<chrono::Utc>,
15    pub agent_version: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub hostname: Option<String>,
18    /// Coarse OS bucket from `std::env::consts::OS` — `"windows"`,
19    /// `"linux"`, `"macos"`. Rich OS metadata still flows through
20    /// the inventory path; this is just the "agent is alive on a
21    /// <family>" signal.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub os_family: Option<String>,
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use chrono::TimeZone;
30
31    #[test]
32    fn heartbeat_round_trips_through_json() {
33        let hb = Heartbeat {
34            pc_id: "minipc".into(),
35            at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
36            agent_version: "0.12.0".into(),
37            hostname: Some("MINIPC".into()),
38            os_family: Some("windows".into()),
39        };
40        let json = serde_json::to_string(&hb).unwrap();
41        let back: Heartbeat = serde_json::from_str(&json).unwrap();
42        assert_eq!(back.pc_id, hb.pc_id);
43        assert_eq!(back.at, hb.at);
44        assert_eq!(back.agent_version, hb.agent_version);
45        assert_eq!(back.hostname, hb.hostname);
46        assert_eq!(back.os_family, hb.os_family);
47    }
48
49    #[test]
50    fn heartbeat_without_enrichment_still_decodes() {
51        // Older agents sending only the v0.11 shape must still parse.
52        let json = r#"{"pc_id":"x","at":"2026-05-16T00:00:00Z","agent_version":"0.11.5"}"#;
53        let hb: Heartbeat = serde_json::from_str(json).unwrap();
54        assert_eq!(hb.pc_id, "x");
55        assert_eq!(hb.hostname, None);
56        assert_eq!(hb.os_family, None);
57    }
58}