Skip to main content

netget/
system_stats.rs

1//! System statistics monitoring
2
3use std::sync::Arc;
4use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
5use tokio::sync::RwLock;
6
7/// GPU stats come from `gfxinfo`, and **not on macOS** — see `get_stats`.
8#[cfg(any(
9    all(unix, feature = "gpu", not(target_os = "macos")),
10    all(not(unix), feature = "gpu")
11))]
12use gfxinfo::active_gpu;
13
14/// System statistics (CPU, memory, GPU)
15#[derive(Debug, Clone)]
16pub struct SystemStats {
17    /// CPU usage percentage (0-100)
18    pub cpu_usage: f32,
19    /// Memory usage in bytes
20    pub memory_used: u64,
21    /// Total memory in bytes
22    pub memory_total: u64,
23    /// GPU usage percentage (0-100, None if not available)
24    pub gpu_usage: Option<f32>,
25    /// GPU memory used in bytes (None if not available)
26    pub gpu_memory_used: Option<u64>,
27    /// GPU memory total in bytes (None if not available)
28    pub gpu_memory_total: Option<u64>,
29}
30
31impl Default for SystemStats {
32    fn default() -> Self {
33        Self {
34            cpu_usage: 0.0,
35            memory_used: 0,
36            memory_total: 0,
37            gpu_usage: None,
38            gpu_memory_used: None,
39            gpu_memory_total: None,
40        }
41    }
42}
43
44impl SystemStats {
45    /// Get memory usage percentage
46    pub fn memory_percent(&self) -> f32 {
47        if self.memory_total == 0 {
48            0.0
49        } else {
50            (self.memory_used as f64 / self.memory_total as f64 * 100.0) as f32
51        }
52    }
53
54    /// Format memory usage as human-readable string
55    pub fn memory_used_str(&self) -> String {
56        format_bytes(self.memory_used)
57    }
58
59    /// Format total memory as human-readable string
60    pub fn memory_total_str(&self) -> String {
61        format_bytes(self.memory_total)
62    }
63
64    /// Format GPU memory usage as human-readable string
65    pub fn gpu_memory_used_str(&self) -> String {
66        self.gpu_memory_used
67            .map(format_bytes)
68            .unwrap_or_else(|| "N/A".to_string())
69    }
70
71    /// Format GPU memory total as human-readable string
72    pub fn gpu_memory_total_str(&self) -> String {
73        self.gpu_memory_total
74            .map(format_bytes)
75            .unwrap_or_else(|| "N/A".to_string())
76    }
77}
78
79/// Format bytes as human-readable string (KB, MB, GB, etc.)
80///
81/// `pub` so `tests/system_stats_test.rs` can exercise it directly — CLAUDE.md
82/// forbids unit-test modules in `src/`, so an internal helper has to be
83/// reachable to be tested.
84pub fn format_bytes(bytes: u64) -> String {
85    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
86    let mut value = bytes as f64;
87    let mut unit_index = 0;
88
89    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
90        value /= 1024.0;
91        unit_index += 1;
92    }
93
94    if unit_index == 0 {
95        format!("{} {}", value as u64, UNITS[unit_index])
96    } else {
97        format!("{:.1} {}", value, UNITS[unit_index])
98    }
99}
100
101/// System statistics monitor that updates at most once per second
102pub struct SystemStatsMonitor {
103    system: Arc<RwLock<System>>,
104    last_update: Arc<RwLock<crate::utils::clock::Instant>>,
105}
106
107impl SystemStatsMonitor {
108    /// Create a new system stats monitor
109    pub fn new() -> Self {
110        let system = System::new_with_specifics(
111            RefreshKind::new()
112                .with_cpu(CpuRefreshKind::everything())
113                .with_memory(MemoryRefreshKind::everything()),
114        );
115
116        Self {
117            system: Arc::new(RwLock::new(system)),
118            last_update: Arc::new(RwLock::new(crate::utils::clock::Instant::now())),
119        }
120    }
121
122    /// Get current system stats (updates at most once per second)
123    pub async fn get_stats(&self) -> SystemStats {
124        let now = crate::utils::clock::Instant::now();
125        let mut last_update = self.last_update.write().await;
126
127        // Only update if more than 1 second has passed
128        if now.duration_since(*last_update).as_secs() >= 1 {
129            let mut system = self.system.write().await;
130            system.refresh_cpu_all();
131            system.refresh_memory();
132            *last_update = now;
133        }
134
135        // Read stats
136        let system = self.system.read().await;
137
138        // Get GPU stats if available (NVIDIA, AMD, Intel — not macOS, see below)
139        #[cfg(any(
140            all(unix, feature = "gpu", not(target_os = "macos")),
141            all(not(unix), feature = "gpu")
142        ))]
143        let (gpu_usage, gpu_memory_used, gpu_memory_total) = {
144            // active_gpu() returns Result<Box<dyn Gpu>, _>
145            match active_gpu() {
146                Ok(gpu) => {
147                    let info = gpu.info();
148
149                    // gfxinfo returns 0 for unsupported/unavailable stats
150                    let gpu_usage = {
151                        let load = info.load_pct();
152                        if load > 0 {
153                            Some(load as f32)
154                        } else {
155                            None
156                        }
157                    };
158                    let gpu_memory_used = {
159                        let used = info.used_vram();
160                        if used > 0 {
161                            Some(used)
162                        } else {
163                            None
164                        }
165                    };
166                    let gpu_memory_total = {
167                        let total = info.total_vram();
168                        if total > 0 {
169                            Some(total)
170                        } else {
171                            None
172                        }
173                    };
174
175                    (gpu_usage, gpu_memory_used, gpu_memory_total)
176                }
177                Err(_) => {
178                    // GPU detection failed (no GPU or drivers not available)
179                    (None, None, None)
180                }
181            }
182        };
183
184        // No GPU stats: either the feature is off, or this is macOS.
185        //
186        // **`gfxinfo` 0.1 crashes the process on macOS, and it is not a panic that can be
187        // caught.** `MacGpuInfo::load_pct()` calls `gfxinfo::macos::performance_stat`, which
188        // over-releases the `CFDictionary` it builds; the extra `CFRelease` raises
189        // `EXC_BREAKPOINT`/`SIGTRAP` inside CoreFoundation, bypassing Rust's unwinding entirely.
190        // The crash is in a `Drop`, so there is no fallible call to wrap and
191        // `catch_unwind` cannot see it.
192        //
193        // It only ever showed up in the rolling TUI because `run_rolling_tui` (since removed) was the only caller
194        // of `get_stats` — the dashboard never asks for GPU stats — and it ticks once a second,
195        // so a `--features gpu` build died a second or two after painting. That is what the
196        // `crash_restore` handler was catching: the terminal was restored
197        // and the process still died.
198        //
199        // Reporting `N/A` on macOS is what the operator saw anyway on the machines where the
200        // stat was unsupported, so nothing is lost that was working. Linux and Windows still get
201        // real numbers. Revisit if `gfxinfo` fixes the double release.
202        #[cfg(any(
203            all(unix, any(not(feature = "gpu"), target_os = "macos")),
204            all(not(unix), not(feature = "gpu"))
205        ))]
206        let (gpu_usage, gpu_memory_used, gpu_memory_total) = (None, None, None);
207
208        SystemStats {
209            cpu_usage: system.global_cpu_usage(),
210            memory_used: system.used_memory(),
211            memory_total: system.total_memory(),
212            gpu_usage,
213            gpu_memory_used,
214            gpu_memory_total,
215        }
216    }
217}
218
219impl Default for SystemStatsMonitor {
220    fn default() -> Self {
221        Self::new()
222    }
223}