Skip to main content

zwire_host/
sysmon.rs

1//! Live system-stats streamer.
2//!
3//! `sysinfo_start` spawns a [`Monitor`]: a background thread that pushes a
4//! `{"sys":{…}}` frame every `interval_ms` until the connection closes or the
5//! caller sends `sysinfo_stop` (which drops the `Monitor`). Running on its own
6//! thread means the connection stays free for other RPCs — a HUD can stream
7//! stats *and* run a shell over the same pipe.
8use crate::proto::{send_msg, Out};
9use serde_json::{json, Value};
10use std::path::Path;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::thread::JoinHandle;
14use std::time::{Duration, Instant};
15
16/// A running stats stream. Dropping it stops the thread and joins it.
17pub struct Monitor {
18    stop: Arc<AtomicBool>,
19    handle: Option<JoinHandle<()>>,
20}
21
22impl Monitor {
23    /// Start streaming `{"sys":…}` frames to `out` every `interval_ms`
24    /// (floored at 250 ms). `id`, when non-empty, is echoed on every frame so a
25    /// multiplexed client can tell streams apart.
26    pub fn start(out: &Out, interval_ms: u64, id: String) -> Monitor {
27        let stop = Arc::new(AtomicBool::new(false));
28        let s2 = stop.clone();
29        let o = out.clone();
30        let interval = Duration::from_millis(interval_ms.max(250));
31        let handle = std::thread::spawn(move || stream(&o, &s2, interval, &id));
32        Monitor {
33            stop,
34            handle: Some(handle),
35        }
36    }
37}
38
39impl Drop for Monitor {
40    fn drop(&mut self) {
41        self.stop.store(true, Ordering::Relaxed);
42        if let Some(h) = self.handle.take() {
43            let _ = h.join();
44        }
45    }
46}
47
48/// Collect one stats snapshot as a JSON object. Shared by the streamer and the
49/// one-shot `sysinfo_once` command.
50pub fn snapshot(dt: f64, nets: &sysinfo::Networks, sys: &sysinfo::System) -> Value {
51    use sysinfo::{Components, Disks, System};
52    let mut d = serde_json::Map::new();
53    d.insert("cpu".into(), json!(sys.global_cpu_usage().round() as i64));
54    let (mu, mt) = (sys.used_memory(), sys.total_memory());
55    d.insert(
56        "mem".into(),
57        json!({"u": mu, "t": mt, "p": (mu * 100).checked_div(mt).unwrap_or(0)}),
58    );
59    let (su, st) = (sys.used_swap(), sys.total_swap());
60    if st > 0 {
61        d.insert("swap".into(), json!({"u": su, "t": st, "p": su * 100 / st}));
62    }
63    let la = System::load_average();
64    d.insert(
65        "load".into(),
66        json!([round2(la.one), round2(la.five), round2(la.fifteen)]),
67    );
68    d.insert("uptime".into(), json!(System::uptime()));
69
70    let disks = Disks::new_with_refreshed_list();
71    if let Some(root) = disks.iter().find(|k| k.mount_point() == Path::new("/")) {
72        let (t, a) = (root.total_space(), root.available_space());
73        if t > 0 {
74            d.insert(
75                "disk".into(),
76                json!({"u": t - a, "t": t, "p": (t - a) * 100 / t}),
77            );
78        }
79    }
80    let (mut up, mut down) = (0u64, 0u64);
81    for (_, n) in nets {
82        up += n.transmitted();
83        down += n.received();
84    }
85    d.insert(
86        "net".into(),
87        json!({"up": (up as f64 / dt) as u64, "down": (down as f64 / dt) as u64}),
88    );
89
90    let comps = Components::new_with_refreshed_list();
91    let mut tmax = f32::MIN;
92    for c in &comps {
93        if let Some(t) = c.temperature() {
94            if t > tmax {
95                tmax = t;
96            }
97        }
98    }
99    if tmax > f32::MIN {
100        d.insert("temp".into(), json!(tmax.round() as i64));
101    }
102    if let Some(h) = System::host_name() {
103        d.insert("host".into(), json!(h.split('.').next().unwrap_or(&h)));
104    }
105    if let Some(ip) = local_ip() {
106        d.insert("lip".into(), json!(ip));
107    }
108    if let Some(b) = battery() {
109        d.insert("batt".into(), b);
110    }
111    Value::Object(d)
112}
113
114fn stream(out: &Out, stop: &AtomicBool, interval: Duration, id: &str) {
115    use sysinfo::{Networks, System};
116    let mut sys = System::new();
117    let mut nets = Networks::new_with_refreshed_list();
118    let mut pip = public_ip();
119    let mut last = Instant::now();
120    let mut ticks: u64 = 0;
121    while !stop.load(Ordering::Relaxed) {
122        sys.refresh_cpu_usage();
123        sys.refresh_memory();
124        nets.refresh(true);
125        let dt = last.elapsed().as_secs_f64().max(0.1);
126        last = Instant::now();
127
128        let mut snap = snapshot(dt, &nets, &sys);
129        if let Some(ref p) = pip {
130            if let Some(obj) = snap.as_object_mut() {
131                obj.insert("pip".into(), json!(p));
132            }
133        }
134        let mut frame = json!({ "sys": snap });
135        if !id.is_empty() {
136            frame["id"] = json!(id);
137        }
138        if send_msg(out, &frame).is_err() {
139            return; // port closed
140        }
141        ticks += 1;
142        if ticks.is_multiple_of(60) {
143            if let Some(p) = public_ip() {
144                pip = Some(p);
145            }
146        }
147        // Sleep in short slices so `sysinfo_stop` takes effect promptly.
148        let mut slept = Duration::ZERO;
149        while slept < interval && !stop.load(Ordering::Relaxed) {
150            let slice = Duration::from_millis(100).min(interval - slept);
151            std::thread::sleep(slice);
152            slept += slice;
153        }
154    }
155}
156
157fn round2(x: f64) -> f64 {
158    (x * 100.0).round() / 100.0
159}
160
161use crate::osops::local_ip;
162
163fn public_ip() -> Option<String> {
164    use std::io::{Read, Write};
165    let mut s = std::net::TcpStream::connect("api.ipify.org:80").ok()?;
166    s.set_read_timeout(Some(Duration::from_secs(3))).ok();
167    s.write_all(b"GET / HTTP/1.0\r\nHost: api.ipify.org\r\nConnection: close\r\n\r\n")
168        .ok()?;
169    let mut buf = String::new();
170    s.read_to_string(&mut buf).ok();
171    buf.rsplit("\r\n\r\n")
172        .next()
173        .map(|b| b.trim().to_string())
174        .filter(|b| !b.is_empty())
175}
176
177#[cfg(target_os = "macos")]
178fn battery() -> Option<Value> {
179    let out = std::process::Command::new("pmset")
180        .args(["-g", "batt"])
181        .output()
182        .ok()?;
183    let s = String::from_utf8_lossy(&out.stdout);
184    let idx = s.find('%')?;
185    let start = s[..idx]
186        .rfind(|c: char| !c.is_ascii_digit())
187        .map(|i| i + 1)
188        .unwrap_or(0);
189    let pct: i64 = s[start..idx].parse().ok()?;
190    let c = s.contains("AC Power") || s.contains("charging") || s.contains("charged");
191    Some(json!({"p": pct, "c": c}))
192}
193#[cfg(not(target_os = "macos"))]
194fn battery() -> Option<Value> {
195    None
196}