1use std::collections::{HashMap, VecDeque};
7
8use crate::snapshot::{ProcessInfo, Snapshot};
9use crate::source::ProcessSignal;
10
11pub const HISTORY_LEN: usize = 1024;
14
15const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
18
19#[derive(Debug, Default, Clone)]
21pub struct History {
22 samples: VecDeque<f64>,
23}
24
25impl History {
26 pub fn push(&mut self, value: f64) {
28 if self.samples.len() == HISTORY_LEN {
29 self.samples.pop_front();
30 }
31 self.samples.push_back(value);
32 }
33
34 pub fn last(&self, n: usize) -> Vec<f64> {
36 let skip = self.samples.len().saturating_sub(n);
37 self.samples.iter().skip(skip).copied().collect()
38 }
39
40 pub fn max_of_last(&self, n: usize) -> f64 {
42 self.last(n).into_iter().fold(0.0, f64::max)
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SortKey {
49 Pid,
50 Name,
51 Threads,
52 User,
53 Memory,
54 Cpu,
55 Gpu,
56 Vram,
57}
58
59impl SortKey {
60 pub const ALL: [SortKey; 8] = [
62 SortKey::Pid,
63 SortKey::Name,
64 SortKey::Threads,
65 SortKey::User,
66 SortKey::Memory,
67 SortKey::Cpu,
68 SortKey::Gpu,
69 SortKey::Vram,
70 ];
71
72 pub fn descending_by_default(self) -> bool {
74 !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct ProcessRow<'a> {
81 pub info: &'a ProcessInfo,
82 pub gpu_percent: Option<f32>,
84 pub vram_bytes: Option<u64>,
86}
87
88#[derive(Debug, Default, Clone)]
90pub struct MetricHistory {
91 pub cpu: History,
93 pub memory: History,
95 pub gpus: Vec<History>,
97 pub net_rx: History,
99 pub net_tx: History,
101 pub net_interface: Option<String>,
103}
104
105impl MetricHistory {
106 pub fn record(&mut self, snapshot: &Snapshot) {
108 self.cpu.push(snapshot.cpu.total_percent as f64);
109 let memory = &snapshot.memory;
110 if memory.total_bytes > 0 {
111 self.memory
112 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
113 }
114 self.gpus.resize_with(snapshot.gpus.len(), History::default);
115 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
116 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
117 }
118 if self.net_interface.is_none() {
119 self.net_interface = default_interface(snapshot);
120 }
121 let (rx, tx) = self
122 .net_interface
123 .as_deref()
124 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
125 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
126 .unwrap_or((0.0, 0.0));
127 self.net_rx.push(rx);
128 self.net_tx.push(tx);
129 }
130
131 pub fn select_interface(&mut self, name: &str) {
133 if self.net_interface.as_deref() != Some(name) {
134 self.net_interface = Some(name.to_owned());
135 self.net_rx = History::default();
137 self.net_tx = History::default();
138 }
139 }
140
141 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
144 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
145 if names.is_empty() {
146 return None;
147 }
148 let current = self
149 .net_interface
150 .as_deref()
151 .and_then(|name| names.iter().position(|n| *n == name));
152 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
153 self.select_interface(&next);
154 Some(next)
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub enum Severity {
161 Warning,
163 Critical,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Alert {
170 pub severity: Severity,
171 pub text: String,
172}
173
174const FS_WARN_PERCENT: f64 = 90.0;
176const FS_CRITICAL_PERCENT: f64 = 97.0;
177const GPU_HOT_CELSIUS: f32 = 85.0;
179
180pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
183 let mut alerts = Vec::new();
184 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
185 for fs in &snapshot.raid.btrfs {
186 let name = fs
187 .mount_points
188 .first()
189 .map_or(fs.fsid.as_str(), String::as_str);
190 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
191 if missing > 0 {
192 add(
193 Severity::Critical,
194 format!("btrfs {name}: {missing} device(s) missing"),
195 );
196 }
197 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
198 if errors > 0 {
199 let devices: Vec<String> = fs
201 .device_stats
202 .iter()
203 .filter(|d| d.total_errors() > 0)
204 .map(|d| d.label())
205 .collect();
206 add(
207 Severity::Critical,
208 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
209 );
210 }
211 if let Some(scrub) = &fs.scrub {
212 if scrub.uncorrectable > 0 {
213 add(
214 Severity::Critical,
215 format!(
216 "btrfs {name}: scrub found {} uncorrectable errors",
217 scrub.uncorrectable
218 ),
219 );
220 } else if scrub.errors > 0 {
221 add(
222 Severity::Warning,
223 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
224 );
225 }
226 }
227 }
228 for md in &snapshot.raid.md {
229 if md.active && md.is_degraded() {
230 add(
231 Severity::Critical,
232 format!("{} ({}) degraded", md.name, md.level),
233 );
234 } else if !md.active {
235 add(Severity::Warning, format!("{} inactive", md.name));
236 }
237 if let Some(sync) = &md.sync {
238 add(
239 Severity::Warning,
240 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
241 );
242 }
243 }
244 for smart in &snapshot.smart {
245 if let Some(error) = &smart.error {
246 add(
247 Severity::Warning,
248 format!("{}: SMART unreadable ({error})", smart.device),
249 );
250 } else if smart.passed == Some(false) {
251 add(
252 Severity::Critical,
253 format!("{}: SMART failed", smart.device),
254 );
255 } else if smart.needs_attention() {
256 add(
257 Severity::Critical,
258 format!("{}: SMART warning", smart.device),
259 );
260 }
261 }
262 for fs in &snapshot.filesystems {
263 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
264 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
265 let name = fs
266 .mount_points
267 .first()
268 .map_or(fs.device.as_str(), String::as_str);
269 if percent >= FS_CRITICAL_PERCENT {
270 add(Severity::Critical, format!("{name} {percent:.0}% full"));
271 } else if percent >= FS_WARN_PERCENT {
272 add(Severity::Warning, format!("{name} {percent:.0}% full"));
273 }
274 }
275 for gpu in &snapshot.gpus {
276 if let Some(t) = gpu.temperature_c.filter(|t| *t >= GPU_HOT_CELSIUS) {
277 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
278 }
279 }
280 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
282 alerts
283}
284
285#[derive(Debug, Clone, Copy)]
287pub struct ProcessQuery<'q> {
288 pub sort_key: SortKey,
290 pub descending: bool,
292 pub filter: &'q str,
294 pub show_kernel_threads: bool,
296}
297
298pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
300 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
302 for gpu in &snapshot.gpus {
303 for p in &gpu.processes {
304 let entry = gpu_usage.entry(p.pid).or_default();
305 if let Some(sm) = p.sm_percent {
306 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
307 }
308 if let Some(mem) = p.memory_bytes {
309 entry.1 = Some(entry.1.unwrap_or(0) + mem);
310 }
311 }
312 }
313
314 let filter = query.filter.to_lowercase();
315 let mut rows: Vec<ProcessRow<'a>> = snapshot
316 .processes
317 .iter()
318 .filter(|p| query.show_kernel_threads || !p.kernel_thread)
319 .filter(|p| {
320 filter.is_empty()
321 || p.name.to_lowercase().contains(&filter)
322 || p.command.to_lowercase().contains(&filter)
323 || p.user.to_lowercase().contains(&filter)
324 || p.pid.to_string() == filter
325 })
326 .map(|info| {
327 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
328 ProcessRow {
329 info,
330 gpu_percent,
331 vram_bytes,
332 }
333 })
334 .collect();
335
336 let key = query.sort_key;
337 rows.sort_by(|a, b| {
338 let ordering = match key {
339 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
340 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
341 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
342 SortKey::User => a.info.user.cmp(&b.info.user),
343 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
344 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
345 SortKey::Gpu => a
346 .gpu_percent
347 .unwrap_or(-1.0)
348 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
349 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
350 }
351 .then(a.info.pid.cmp(&b.info.pid));
353 if query.descending {
354 ordering.reverse()
355 } else {
356 ordering
357 }
358 });
359 rows
360}
361
362pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
365
366pub fn lhm_help(error: &str) -> String {
368 format!(
369 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
370 Start it and turn on Options → Remote Web Server → Run.\n\
371 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
372 {error}"
373 )
374}
375
376pub fn signal_name(signal: ProcessSignal) -> &'static str {
378 match signal {
379 ProcessSignal::Terminate => "SIGTERM",
380 ProcessSignal::Kill => "SIGKILL",
381 }
382}
383
384pub fn is_virtual_interface(name: &str) -> bool {
387 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
388}
389
390pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
392 snapshot
393 .networks
394 .iter()
395 .filter(|n| !is_virtual_interface(&n.name))
396 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
397 .or_else(|| snapshot.networks.first())
398 .map(|n| n.name.clone())
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
405
406 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
407 ProcessInfo {
408 pid,
409 name: name.to_owned(),
410 cpu_percent: cpu,
411 kernel_thread: kernel,
412 ..Default::default()
413 }
414 }
415
416 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
417 ProcessQuery {
418 sort_key,
419 descending: sort_key.descending_by_default(),
420 filter: "",
421 show_kernel_threads: false,
422 }
423 }
424
425 #[test]
426 fn history_keeps_newest_samples() {
427 let mut history = History::default();
428 for i in 0..(HISTORY_LEN + 5) {
429 history.push(i as f64);
430 }
431 assert_eq!(
432 history.last(2),
433 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
434 );
435 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
436 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
437 }
438
439 #[test]
440 fn rows_filter_sort_and_join_gpu_usage() {
441 let snapshot = Snapshot {
442 processes: vec![
443 process(10, "bash", 5.0, false),
444 process(2, "kworker/0:1", 50.0, true),
445 process(30, "python", 1.0, false),
446 ],
447 gpus: vec![GpuStats {
448 processes: vec![GpuProcess {
449 pid: 30,
450 memory_bytes: Some(1 << 30),
451 sm_percent: Some(70.0),
452 }],
453 ..Default::default()
454 }],
455 ..Default::default()
456 };
457 let pids = |q: &ProcessQuery| {
458 process_rows(&snapshot, q)
459 .iter()
460 .map(|r| r.info.pid)
461 .collect::<Vec<_>>()
462 };
463 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
464 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
465 assert_eq!(
466 pids(&ProcessQuery {
467 show_kernel_threads: true,
468 ..query(SortKey::Cpu)
469 }),
470 vec![2, 10, 30]
471 );
472 assert_eq!(
473 pids(&ProcessQuery {
474 filter: "PYT",
475 ..query(SortKey::Pid)
476 }),
477 vec![30]
478 );
479 let rows = process_rows(&snapshot, &query(SortKey::Pid));
480 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
481 assert_eq!(rows[1].gpu_percent, Some(70.0));
482 }
483
484 #[test]
485 fn metric_history_records_and_switches_interfaces() {
486 let snapshot = Snapshot {
487 networks: vec![
488 NetworkStats {
489 name: "enp9s0".to_owned(),
490 rx_total_bytes: 10,
491 rx_bytes_per_sec: 100.0,
492 ..Default::default()
493 },
494 NetworkStats {
495 name: "wlp7s0".to_owned(),
496 tx_bytes_per_sec: 5.0,
497 ..Default::default()
498 },
499 ],
500 gpus: vec![GpuStats {
501 utilization_percent: Some(40.0),
502 ..Default::default()
503 }],
504 ..Default::default()
505 };
506 let mut history = MetricHistory::default();
507 history.record(&snapshot);
508 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
509 assert_eq!(history.net_rx.last(1), vec![100.0]);
510 assert_eq!(history.gpus[0].last(1), vec![40.0]);
511 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
512 assert!(history.net_rx.last(10).is_empty());
514 history.record(&snapshot);
515 assert_eq!(history.net_tx.last(1), vec![5.0]);
516 }
517
518 #[test]
519 fn alerts_flag_storage_problems_first() {
520 use crate::snapshot::{
521 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
522 };
523 let snapshot = Snapshot {
524 filesystems: vec![FilesystemUsage {
525 mount_points: vec!["/data".to_owned()],
526 total_bytes: 100,
527 available_bytes: 8,
528 ..Default::default()
529 }],
530 raid: RaidStats {
531 btrfs: vec![BtrfsFilesystem {
532 mount_points: vec!["/".to_owned()],
533 device_stats: vec![BtrfsDeviceStats {
534 devid: 1,
535 corruption_errs: 2,
536 ..Default::default()
537 }],
538 ..Default::default()
539 }],
540 md: vec![MdArray {
541 name: "md0".to_owned(),
542 active: true,
543 level: "raid1".to_owned(),
544 expected_devices: Some(2),
545 working_devices: Some(1),
546 ..Default::default()
547 }],
548 },
549 smart: vec![SmartDevice {
550 device: "sda".to_owned(),
551 passed: Some(true),
552 ..Default::default()
553 }],
554 ..Default::default()
555 };
556 let alerts = alerts(&snapshot);
557 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
558 assert_eq!(
559 texts,
560 vec![
561 "btrfs /: 2 I/O errors (devid 1)",
562 "md0 (raid1) degraded",
563 "/data 92% full"
564 ]
565 );
566 assert_eq!(alerts[0].severity, Severity::Critical);
567 assert_eq!(alerts[2].severity, Severity::Warning);
568 assert!(super::alerts(&Snapshot::default()).is_empty());
569 }
570
571 #[test]
572 fn interface_helpers() {
573 assert!(is_virtual_interface("veth5a74356"));
574 assert!(!is_virtual_interface("enp9s0"));
575 let snapshot = Snapshot {
576 networks: vec![
577 NetworkStats {
578 name: "docker0".to_owned(),
579 rx_total_bytes: 1 << 40,
580 ..Default::default()
581 },
582 NetworkStats {
583 name: "enp9s0".to_owned(),
584 rx_total_bytes: 1 << 30,
585 ..Default::default()
586 },
587 ],
588 ..Default::default()
589 };
590 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
591 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
592 }
593}