Skip to main content

par_term/status_bar/
system_monitor.rs

1//! System resource monitor for the status bar.
2//!
3//! Polls CPU, memory, and network usage on a background thread using `sysinfo`.
4//! Data is shared via `Arc<parking_lot::Mutex<...>>` for lock-free reads from
5//! the render thread.
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::thread::JoinHandle;
10use std::time::{Duration, Instant};
11
12use parking_lot::Mutex;
13
14/// Snapshot of system resource usage.
15#[derive(Debug, Clone, Default)]
16pub struct SystemMonitorData {
17    /// Global CPU usage percentage (0.0 - 100.0)
18    pub cpu_usage: f32,
19    /// Memory currently in use (bytes)
20    pub memory_used: u64,
21    /// Total physical memory (bytes)
22    pub memory_total: u64,
23    /// Network receive rate (bytes/sec)
24    pub network_rx_rate: u64,
25    /// Network transmit rate (bytes/sec)
26    pub network_tx_rate: u64,
27    /// When this data was last updated
28    pub last_update: Option<Instant>,
29}
30
31/// Background system resource monitor.
32///
33/// Spawns a polling thread that periodically refreshes CPU, memory, and
34/// network statistics via `sysinfo`.
35pub struct SystemMonitor {
36    data: Arc<Mutex<SystemMonitorData>>,
37    running: Arc<AtomicBool>,
38    thread: Mutex<Option<JoinHandle<()>>>,
39}
40
41impl SystemMonitor {
42    /// Create a new (stopped) system monitor.
43    pub fn new() -> Self {
44        Self {
45            data: Arc::new(Mutex::new(SystemMonitorData::default())),
46            running: Arc::new(AtomicBool::new(false)),
47            thread: Mutex::new(None),
48        }
49    }
50
51    /// Start the polling thread.
52    ///
53    /// If the monitor is already running, this is a no-op.
54    pub fn start(&self, poll_interval_secs: f32) {
55        if self
56            .running
57            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
58            .is_err()
59        {
60            return;
61        }
62
63        let data = Arc::clone(&self.data);
64        let running = Arc::clone(&self.running);
65        let interval = Duration::from_secs_f32(poll_interval_secs.max(0.5));
66
67        let handle = std::thread::Builder::new()
68            .name("status-bar-sysmon".to_string())
69            .spawn(move || {
70                use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
71
72                let mut sys = System::new_with_specifics(
73                    RefreshKind::nothing()
74                        .with_cpu(CpuRefreshKind::everything())
75                        .with_memory(MemoryRefreshKind::everything()),
76                );
77                let mut networks = sysinfo::Networks::new_with_refreshed_list();
78
79                // First CPU poll is always 0% — need two samples.
80                sys.refresh_cpu_all();
81                std::thread::sleep(Duration::from_millis(200));
82
83                let mut prev_rx: u64 = 0;
84                let mut prev_tx: u64 = 0;
85                let mut first_net = true;
86
87                while running.load(Ordering::SeqCst) {
88                    sys.refresh_cpu_all();
89                    sys.refresh_memory();
90                    networks.refresh(true);
91
92                    // Network totals
93                    let (mut total_rx, mut total_tx) = (0u64, 0u64);
94                    for (_name, net) in networks.iter() {
95                        total_rx = total_rx.saturating_add(net.total_received());
96                        total_tx = total_tx.saturating_add(net.total_transmitted());
97                    }
98
99                    let (rx_rate, tx_rate) = if first_net {
100                        first_net = false;
101                        (0, 0)
102                    } else {
103                        let secs = interval.as_secs_f64();
104                        let rx_delta = total_rx.saturating_sub(prev_rx);
105                        let tx_delta = total_tx.saturating_sub(prev_tx);
106                        (
107                            (rx_delta as f64 / secs) as u64,
108                            (tx_delta as f64 / secs) as u64,
109                        )
110                    };
111                    prev_rx = total_rx;
112                    prev_tx = total_tx;
113
114                    {
115                        let mut d = data.lock();
116                        d.cpu_usage = sys.global_cpu_usage();
117                        d.memory_used = sys.used_memory();
118                        d.memory_total = sys.total_memory();
119                        d.network_rx_rate = rx_rate;
120                        d.network_tx_rate = tx_rate;
121                        d.last_update = Some(Instant::now());
122                    }
123
124                    // Sleep in short increments so stop() returns quickly
125                    let deadline = Instant::now() + interval;
126                    while Instant::now() < deadline && running.load(Ordering::Relaxed) {
127                        std::thread::sleep(Duration::from_millis(50));
128                    }
129                }
130            })
131            .expect("failed to spawn sysmon thread");
132
133        *self.thread.lock() = Some(handle);
134    }
135
136    /// Signal the polling thread to stop without waiting for it to finish.
137    pub fn signal_stop(&self) {
138        self.running.store(false, Ordering::SeqCst);
139    }
140
141    /// Stop the polling thread and wait for it to finish.
142    pub fn stop(&self) {
143        self.signal_stop();
144        if let Some(handle) = self.thread.lock().take() {
145            let _ = handle.join();
146        }
147    }
148
149    /// Whether the polling thread is currently running.
150    pub fn is_running(&self) -> bool {
151        self.running.load(Ordering::SeqCst)
152    }
153
154    /// Get a clone of the current data snapshot.
155    pub fn data(&self) -> SystemMonitorData {
156        self.data.lock().clone()
157    }
158}
159
160impl Default for SystemMonitor {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166impl Drop for SystemMonitor {
167    fn drop(&mut self) {
168        self.stop();
169    }
170}
171
172// ============================================================================
173// Formatting helpers
174// ============================================================================
175
176/// Format bytes-per-second into a fixed-width human-readable string.
177///
178/// Output is always 10 characters wide (e.g. `"  1.0 KB/s"`) so the
179/// status bar doesn't jump around when values change.
180pub fn format_bytes_per_sec(bytes: u64) -> String {
181    const KB: u64 = 1024;
182    const MB: u64 = 1024 * 1024;
183    const GB: u64 = 1024 * 1024 * 1024;
184
185    if bytes >= GB {
186        format!("{:>5.1} GB/s", bytes as f64 / GB as f64)
187    } else if bytes >= MB {
188        format!("{:>5.1} MB/s", bytes as f64 / MB as f64)
189    } else if bytes >= KB {
190        format!("{:>5.1} KB/s", bytes as f64 / KB as f64)
191    } else {
192        // Extra space before "B" so width matches "KB", "MB", "GB"
193        format!("{:>5}  B/s", bytes)
194    }
195}
196
197/// Format memory usage (used / total) into a human-readable string.
198///
199/// Each side is fixed-width (7 chars, e.g. `"  4.0 GB"`) so the status
200/// bar doesn't jump when values change.
201pub fn format_memory(used: u64, total: u64) -> String {
202    fn human(bytes: u64) -> String {
203        const KB: u64 = 1024;
204        const MB: u64 = 1024 * 1024;
205        const GB: u64 = 1024 * 1024 * 1024;
206
207        if bytes >= GB {
208            format!("{:>5.1} GB", bytes as f64 / GB as f64)
209        } else if bytes >= MB {
210            format!("{:>5.1} MB", bytes as f64 / MB as f64)
211        } else if bytes >= KB {
212            format!("{:>5.1} KB", bytes as f64 / KB as f64)
213        } else {
214            format!("{:>5}  B", bytes)
215        }
216    }
217
218    format!("{} / {}", human(used), human(total))
219}
220
221// ============================================================================
222// Tests
223// ============================================================================
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_system_monitor_data_default() {
231        let d = SystemMonitorData::default();
232        assert_eq!(d.cpu_usage, 0.0);
233        assert_eq!(d.memory_used, 0);
234        assert_eq!(d.memory_total, 0);
235        assert_eq!(d.network_rx_rate, 0);
236        assert_eq!(d.network_tx_rate, 0);
237        assert!(d.last_update.is_none());
238    }
239
240    #[test]
241    fn test_format_bytes_per_sec() {
242        assert_eq!(format_bytes_per_sec(0), "    0  B/s");
243        assert_eq!(format_bytes_per_sec(512), "  512  B/s");
244        assert_eq!(format_bytes_per_sec(1024), "  1.0 KB/s");
245        assert_eq!(format_bytes_per_sec(1536), "  1.5 KB/s");
246        assert_eq!(format_bytes_per_sec(1_048_576), "  1.0 MB/s");
247        assert_eq!(format_bytes_per_sec(1_073_741_824), "  1.0 GB/s");
248        // All outputs have same width
249        assert_eq!(
250            format_bytes_per_sec(0).len(),
251            format_bytes_per_sec(1024).len()
252        );
253        assert_eq!(
254            format_bytes_per_sec(1024).len(),
255            format_bytes_per_sec(1_048_576).len()
256        );
257    }
258
259    #[test]
260    fn test_format_memory() {
261        assert_eq!(format_memory(0, 0), "    0  B /     0  B");
262        // 1 GB used / 8 GB total
263        assert_eq!(
264            format_memory(1_073_741_824, 8_589_934_592),
265            "  1.0 GB /   8.0 GB"
266        );
267        // 512 MB / 1 GB
268        assert_eq!(
269            format_memory(536_870_912, 1_073_741_824),
270            "512.0 MB /   1.0 GB"
271        );
272    }
273
274    #[test]
275    fn test_system_monitor_start_stop() {
276        let monitor = SystemMonitor::new();
277        assert!(!monitor.is_running());
278
279        monitor.start(1.0);
280        assert!(monitor.is_running());
281
282        // Give the thread a moment to do an initial poll
283        std::thread::sleep(Duration::from_millis(500));
284
285        let data = monitor.data();
286        // After starting, last_update should be set (thread had 200ms init + sleep)
287        assert!(data.last_update.is_some());
288
289        monitor.stop();
290        assert!(!monitor.is_running());
291    }
292}