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}