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. `disks` is passed in (rather than refreshed
50/// here) so the streamer can keep it alive across ticks — `Disk::usage()`
51/// reports bytes *since the last refresh*, so a persistent, per-tick-refreshed
52/// `Disks` is what turns those deltas into a real per-second I/O rate.
53pub fn snapshot(
54    dt: f64,
55    nets: &sysinfo::Networks,
56    disks: &sysinfo::Disks,
57    sys: &sysinfo::System,
58) -> Value {
59    use sysinfo::{Components, System};
60    let mut d = serde_json::Map::new();
61    d.insert("cpu".into(), json!(sys.global_cpu_usage().round() as i64));
62    let (mu, mt) = (sys.used_memory(), sys.total_memory());
63    d.insert(
64        "mem".into(),
65        json!({"u": mu, "t": mt, "p": (mu * 100).checked_div(mt).unwrap_or(0)}),
66    );
67    // Always emit swap (like `mem`), even when total is 0 — on macOS the dynamic
68    // pager reports 0 total until a swapfile is allocated, which is a real "0 used
69    // of 0" state, not "unavailable". Dropping the key made the statusbar show the
70    // `–` placeholder (reserved for a missing provider). `checked_div` guards the
71    // divide-by-zero that the old `st > 0` gate was really protecting against.
72    let (su, st) = (sys.used_swap(), sys.total_swap());
73    d.insert(
74        "swap".into(),
75        json!({"u": su, "t": st, "p": (su * 100).checked_div(st).unwrap_or(0)}),
76    );
77    let la = System::load_average();
78    d.insert(
79        "load".into(),
80        json!([round2(la.one), round2(la.five), round2(la.fifteen)]),
81    );
82    d.insert("uptime".into(), json!(System::uptime()));
83
84    if let Some(root) = disks.iter().find(|k| k.mount_point() == Path::new("/")) {
85        let (t, a) = (root.total_space(), root.available_space());
86        if t > 0 {
87            d.insert(
88                "disk".into(),
89                json!({"u": t - a, "t": t, "p": (t - a) * 100 / t}),
90            );
91        }
92    }
93    // Disk I/O rate: sum read/written bytes since the last refresh across every
94    // disk, converted to bytes-per-second. `{r, w}` matches the Python host's
95    // `io` field so a HUD statusbar renders the same IO segment on either host.
96    let (mut ior, mut iow) = (0u64, 0u64);
97    for k in disks.iter() {
98        let u = k.usage();
99        ior += u.read_bytes;
100        iow += u.written_bytes;
101    }
102    d.insert(
103        "io".into(),
104        json!({"r": (ior as f64 / dt) as u64, "w": (iow as f64 / dt) as u64}),
105    );
106
107    let (mut up, mut down) = (0u64, 0u64);
108    for (_, n) in nets {
109        up += n.transmitted();
110        down += n.received();
111    }
112    d.insert(
113        "net".into(),
114        json!({"up": (up as f64 / dt) as u64, "down": (down as f64 / dt) as u64}),
115    );
116
117    let comps = Components::new_with_refreshed_list();
118    let mut tmax = f32::MIN;
119    for c in &comps {
120        if let Some(t) = c.temperature() {
121            if t > tmax {
122                tmax = t;
123            }
124        }
125    }
126    if tmax > f32::MIN {
127        d.insert("temp".into(), json!(tmax.round() as i64));
128    }
129    if let Some(h) = System::host_name() {
130        d.insert("host".into(), json!(h.split('.').next().unwrap_or(&h)));
131    }
132    if let Some(ip) = local_ip() {
133        d.insert("lip".into(), json!(ip));
134    }
135    if let Some(b) = battery() {
136        d.insert("batt".into(), b);
137    }
138    Value::Object(d)
139}
140
141fn stream(out: &Out, stop: &AtomicBool, interval: Duration, id: &str) {
142    use sysinfo::{Disks, Networks, System};
143    let mut sys = System::new();
144    let mut nets = Networks::new_with_refreshed_list();
145    let mut disks = Disks::new_with_refreshed_list();
146    let mut pip = public_ip();
147    let mut last = Instant::now();
148    let mut ticks: u64 = 0;
149    while !stop.load(Ordering::Relaxed) {
150        sys.refresh_cpu_usage();
151        sys.refresh_memory();
152        nets.refresh(true);
153        disks.refresh(true);
154        let dt = last.elapsed().as_secs_f64().max(0.1);
155        last = Instant::now();
156
157        let mut snap = snapshot(dt, &nets, &disks, &sys);
158        if let Some(ref p) = pip {
159            if let Some(obj) = snap.as_object_mut() {
160                obj.insert("pip".into(), json!(p));
161            }
162        }
163        let mut frame = json!({ "sys": snap });
164        if !id.is_empty() {
165            frame["id"] = json!(id);
166        }
167        // Log the pushed statusbar frame so the HUD HOST tab shows the (high
168        // frequency) sysinfo stream that drives the tmux statusbar — these frames
169        // go out via send_msg, not respond(), so they'd otherwise be invisible.
170        crate::hostlog::record("rx", &json!({ "cmd": "sysinfo" }), &frame);
171        if send_msg(out, &frame).is_err() {
172            return; // port closed
173        }
174        ticks += 1;
175        if ticks.is_multiple_of(60) {
176            if let Some(p) = public_ip() {
177                pip = Some(p);
178            }
179        }
180        // Sleep in short slices so `sysinfo_stop` takes effect promptly.
181        let mut slept = Duration::ZERO;
182        while slept < interval && !stop.load(Ordering::Relaxed) {
183            let slice = Duration::from_millis(100).min(interval - slept);
184            std::thread::sleep(slice);
185            slept += slice;
186        }
187    }
188}
189
190fn round2(x: f64) -> f64 {
191    (x * 100.0).round() / 100.0
192}
193
194use crate::osops::local_ip;
195
196fn public_ip() -> Option<String> {
197    use std::io::{Read, Write};
198    let mut s = std::net::TcpStream::connect("api.ipify.org:80").ok()?;
199    s.set_read_timeout(Some(Duration::from_secs(3))).ok();
200    s.write_all(b"GET / HTTP/1.0\r\nHost: api.ipify.org\r\nConnection: close\r\n\r\n")
201        .ok()?;
202    let mut buf = String::new();
203    s.read_to_string(&mut buf).ok();
204    buf.rsplit("\r\n\r\n")
205        .next()
206        .map(|b| b.trim().to_string())
207        .filter(|b| !b.is_empty())
208}
209
210/// `{"p": percent, "c": on-AC-or-charging}` for the primary battery, or `None`
211/// when the machine has no battery (desktop/VM). Each OS reads its native power
212/// source: `pmset` on macOS, sysfs on Linux, `GetSystemPowerStatus` on Windows.
213#[cfg(target_os = "macos")]
214fn battery() -> Option<Value> {
215    let out = std::process::Command::new("pmset")
216        .args(["-g", "batt"])
217        .output()
218        .ok()?;
219    let s = String::from_utf8_lossy(&out.stdout);
220    let idx = s.find('%')?;
221    let start = s[..idx]
222        .rfind(|c: char| !c.is_ascii_digit())
223        .map(|i| i + 1)
224        .unwrap_or(0);
225    let pct: i64 = s[start..idx].parse().ok()?;
226    let c = s.contains("AC Power") || s.contains("charging") || s.contains("charged");
227    Some(json!({"p": pct, "c": c}))
228}
229
230/// Read the first `type == Battery` under `/sys/class/power_supply` for its
231/// `capacity` and `status`; treat a charging/full battery, or any online
232/// `Mains` adapter, as "on AC" so `c` matches the macOS semantics.
233#[cfg(target_os = "linux")]
234fn battery() -> Option<Value> {
235    use std::fs;
236    let dir = fs::read_dir("/sys/class/power_supply").ok()?;
237    let mut pct: Option<i64> = None;
238    let mut on_ac = false;
239    for entry in dir.flatten() {
240        let p = entry.path();
241        let kind = fs::read_to_string(p.join("type")).unwrap_or_default();
242        match kind.trim() {
243            "Battery" => {
244                if pct.is_none() {
245                    pct = fs::read_to_string(p.join("capacity"))
246                        .ok()
247                        .and_then(|c| c.trim().parse().ok());
248                }
249                let status = fs::read_to_string(p.join("status")).unwrap_or_default();
250                if matches!(status.trim(), "Charging" | "Full") {
251                    on_ac = true;
252                }
253            }
254            "Mains" if fs::read_to_string(p.join("online")).is_ok_and(|s| s.trim() == "1") => {
255                on_ac = true;
256            }
257            _ => {}
258        }
259    }
260    Some(json!({"p": pct?, "c": on_ac}))
261}
262
263/// `GetSystemPowerStatus` (kernel32) fills a `SYSTEM_POWER_STATUS`; `BatteryFlag`
264/// bit 7 (128) means "no system battery", and 255 percent means "unknown".
265#[cfg(target_os = "windows")]
266fn battery() -> Option<Value> {
267    #[repr(C)]
268    struct SystemPowerStatus {
269        ac_line_status: u8,
270        battery_flag: u8,
271        battery_life_percent: u8,
272        system_status_flag: u8,
273        battery_life_time: u32,
274        battery_full_life_time: u32,
275    }
276    #[link(name = "kernel32")]
277    extern "system" {
278        fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32;
279    }
280    let mut s = SystemPowerStatus {
281        ac_line_status: 0,
282        battery_flag: 0,
283        battery_life_percent: 0,
284        system_status_flag: 0,
285        battery_life_time: 0,
286        battery_full_life_time: 0,
287    };
288    // SAFETY: `GetSystemPowerStatus` only writes the struct we hand it.
289    if unsafe { GetSystemPowerStatus(&mut s) } == 0 {
290        return None;
291    }
292    if s.battery_flag == 128 || s.battery_life_percent == 255 {
293        return None; // no battery, or percent unknown
294    }
295    Some(json!({"p": s.battery_life_percent as i64, "c": s.ac_line_status == 1}))
296}
297
298#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
299fn battery() -> Option<Value> {
300    None
301}