1use std::collections::{HashMap, VecDeque};
7use std::time::Duration;
8
9use crate::config::AlertsConfig;
10use crate::snapshot::{ProcessInfo, Snapshot};
11use crate::source::ProcessSignal;
12
13pub const HISTORY_LEN: usize = 4096;
17
18const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
21
22#[derive(Debug, Default, Clone)]
24pub struct History {
25 samples: VecDeque<f64>,
26}
27
28impl History {
29 pub fn push(&mut self, value: f64) {
31 if self.samples.len() == HISTORY_LEN {
32 self.samples.pop_front();
33 }
34 self.samples.push_back(value);
35 }
36
37 pub fn last(&self, n: usize) -> Vec<f64> {
39 let skip = self.samples.len().saturating_sub(n);
40 self.samples.iter().skip(skip).copied().collect()
41 }
42
43 pub fn max_of_last(&self, n: usize) -> f64 {
45 self.last(n).into_iter().fold(0.0, f64::max)
46 }
47
48 pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
55 let GraphSpan::Secs(secs) = span else {
56 return self.last(columns);
57 };
58 let wanted = (secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize;
59 let samples = self.last(wanted.max(1));
60 if columns == 0 || samples.is_empty() {
61 return Vec::new();
62 }
63 let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
65 let shown = shown.clamp(1, columns);
66 let per_column = samples.len() as f64 / shown as f64;
67 if per_column < 1.0 {
68 let last = samples.len() - 1;
70 return (0..shown)
71 .map(|i| {
72 let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
73 let (below, frac) = (at.floor() as usize, at.fract());
74 let above = (below + 1).min(last);
75 samples[below] * (1.0 - frac) + samples[above] * frac
76 })
77 .collect();
78 }
79 (0..shown)
80 .map(|i| {
81 let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
82 let start = ((i as f64 * per_column) as usize).min(end - 1);
83 let part = &samples[start..end];
84 part.iter().sum::<f64>() / part.len() as f64
85 })
86 .collect()
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum GraphSpan {
93 #[default]
96 Fit,
97 Secs(u64),
99}
100
101impl GraphSpan {
102 pub const CHOICES: [GraphSpan; 5] = [
104 GraphSpan::Fit,
105 GraphSpan::Secs(60),
106 GraphSpan::Secs(5 * 60),
107 GraphSpan::Secs(15 * 60),
108 GraphSpan::Secs(60 * 60),
109 ];
110
111 pub fn next(self) -> Self {
113 let index = Self::CHOICES
114 .iter()
115 .position(|c| *c == self)
116 .map_or(0, |i| i + 1);
117 Self::CHOICES[index % Self::CHOICES.len()]
118 }
119
120 pub fn label(self) -> String {
122 match self {
123 GraphSpan::Fit => "fit".to_owned(),
124 GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
125 GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
126 GraphSpan::Secs(secs) => format!("{secs}s"),
127 }
128 }
129
130 pub fn as_secs(self) -> u64 {
132 match self {
133 GraphSpan::Fit => 0,
134 GraphSpan::Secs(secs) => secs,
135 }
136 }
137
138 pub fn from_secs(secs: u64) -> Self {
140 if secs == 0 {
141 GraphSpan::Fit
142 } else {
143 GraphSpan::Secs(secs)
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum SortKey {
151 Pid,
152 Name,
153 Threads,
154 User,
155 Memory,
156 Cpu,
157 Gpu,
158 Vram,
159}
160
161impl SortKey {
162 pub const ALL: [SortKey; 8] = [
164 SortKey::Pid,
165 SortKey::Name,
166 SortKey::Threads,
167 SortKey::User,
168 SortKey::Memory,
169 SortKey::Cpu,
170 SortKey::Gpu,
171 SortKey::Vram,
172 ];
173
174 pub fn descending_by_default(self) -> bool {
176 !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
177 }
178}
179
180#[derive(Debug, Clone)]
182pub struct ProcessRow<'a> {
183 pub info: &'a ProcessInfo,
184 pub gpu_percent: Option<f32>,
186 pub vram_bytes: Option<u64>,
188}
189
190#[derive(Debug, Default, Clone)]
192pub struct MetricHistory {
193 pub cpu: History,
195 pub memory: History,
197 pub gpus: Vec<History>,
199 pub net_rx: History,
201 pub net_tx: History,
203 pub net_interface: Option<String>,
205}
206
207impl MetricHistory {
208 pub fn record(&mut self, snapshot: &Snapshot) {
210 self.cpu.push(snapshot.cpu.total_percent as f64);
211 let memory = &snapshot.memory;
212 if memory.total_bytes > 0 {
213 self.memory
214 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
215 }
216 self.gpus.resize_with(snapshot.gpus.len(), History::default);
217 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
218 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
219 }
220 if self.net_interface.is_none() {
221 self.net_interface = default_interface(snapshot);
222 }
223 let (rx, tx) = self
224 .net_interface
225 .as_deref()
226 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
227 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
228 .unwrap_or((0.0, 0.0));
229 self.net_rx.push(rx);
230 self.net_tx.push(tx);
231 }
232
233 pub fn select_interface(&mut self, name: &str) {
235 if self.net_interface.as_deref() != Some(name) {
236 self.net_interface = Some(name.to_owned());
237 self.net_rx = History::default();
239 self.net_tx = History::default();
240 }
241 }
242
243 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
246 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
247 if names.is_empty() {
248 return None;
249 }
250 let current = self
251 .net_interface
252 .as_deref()
253 .and_then(|name| names.iter().position(|n| *n == name));
254 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
255 self.select_interface(&next);
256 Some(next)
257 }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
262pub enum Severity {
263 Warning,
265 Critical,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct Alert {
272 pub severity: Severity,
273 pub text: String,
274}
275
276pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
278 alerts_with(snapshot, &AlertsConfig::default())
279}
280
281pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
285 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
287 let mut alerts = Vec::new();
288 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
289 for fs in &snapshot.raid.btrfs {
290 let name = fs
291 .mount_points
292 .first()
293 .map_or(fs.fsid.as_str(), String::as_str);
294 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
295 if missing > 0 {
296 add(
297 Severity::Critical,
298 format!("btrfs {name}: {missing} device(s) missing"),
299 );
300 }
301 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
302 if errors > 0 {
303 let devices: Vec<String> = fs
305 .device_stats
306 .iter()
307 .filter(|d| d.total_errors() > 0)
308 .map(|d| d.label())
309 .collect();
310 add(
311 Severity::Critical,
312 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
313 );
314 }
315 if let Some(scrub) = &fs.scrub {
316 if scrub.uncorrectable > 0 {
317 add(
318 Severity::Critical,
319 format!(
320 "btrfs {name}: scrub found {} uncorrectable errors",
321 scrub.uncorrectable
322 ),
323 );
324 } else if scrub.errors > 0 {
325 add(
326 Severity::Warning,
327 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
328 );
329 }
330 }
331 }
332 for md in &snapshot.raid.md {
333 if md.active && md.is_degraded() {
334 add(
335 Severity::Critical,
336 format!("{} ({}) degraded", md.name, md.level),
337 );
338 } else if !md.active {
339 add(Severity::Warning, format!("{} inactive", md.name));
340 }
341 if let Some(sync) = &md.sync {
342 add(
343 Severity::Warning,
344 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
345 );
346 }
347 }
348 for smart in &snapshot.smart {
349 if let Some(error) = &smart.error {
350 add(
351 Severity::Warning,
352 format!("{}: SMART unreadable ({error})", smart.device),
353 );
354 } else if smart.passed == Some(false) {
355 add(
356 Severity::Critical,
357 format!("{}: SMART failed", smart.device),
358 );
359 } else if smart.needs_attention() {
360 add(
361 Severity::Critical,
362 format!("{}: SMART warning", smart.device),
363 );
364 }
365 }
366 for fs in &snapshot.filesystems {
367 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
368 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
369 let name = fs
370 .mount_points
371 .first()
372 .map_or(fs.device.as_str(), String::as_str);
373 if reached(percent, limits.filesystem_critical_percent) {
374 add(Severity::Critical, format!("{name} {percent:.0}% full"));
375 } else if reached(percent, limits.filesystem_warning_percent) {
376 add(Severity::Warning, format!("{name} {percent:.0}% full"));
377 }
378 }
379 let hottest_cpu = snapshot
380 .cpu
381 .temperatures
382 .iter()
383 .map(|t| t.celsius)
384 .fold(f32::NAN, f32::max);
385 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
386 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
387 }
388 for gpu in &snapshot.gpus {
389 if let Some(t) = gpu
390 .temperature_c
391 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
392 {
393 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
394 }
395 }
396 for (device, celsius) in drive_temperatures(snapshot) {
397 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
398 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
399 }
400 }
401 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
403 alerts
404}
405
406fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
409 let mut drives: Vec<(String, f32)> = snapshot
410 .smart
411 .iter()
412 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
413 .collect();
414 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
415 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
417 if drives.iter().any(|(d, _)| d == device) {
418 continue;
419 }
420 let composite = chip
421 .temperatures
422 .iter()
423 .find(|t| t.label == "Composite")
424 .or(chip.temperatures.first());
425 if let Some(t) = composite {
426 drives.push((device.to_owned(), t.celsius));
427 }
428 }
429 drives
430}
431
432#[derive(Debug, Clone, Copy)]
434pub struct ProcessQuery<'q> {
435 pub sort_key: SortKey,
437 pub descending: bool,
439 pub filter: &'q str,
441 pub show_kernel_threads: bool,
443}
444
445pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
447 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
449 for gpu in &snapshot.gpus {
450 for p in &gpu.processes {
451 let entry = gpu_usage.entry(p.pid).or_default();
452 if let Some(sm) = p.sm_percent {
453 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
454 }
455 if let Some(mem) = p.memory_bytes {
456 entry.1 = Some(entry.1.unwrap_or(0) + mem);
457 }
458 }
459 }
460
461 let filter = query.filter.to_lowercase();
462 let mut rows: Vec<ProcessRow<'a>> = snapshot
463 .processes
464 .iter()
465 .filter(|p| query.show_kernel_threads || !p.kernel_thread)
466 .filter(|p| {
467 filter.is_empty()
468 || p.name.to_lowercase().contains(&filter)
469 || p.command.to_lowercase().contains(&filter)
470 || p.user.to_lowercase().contains(&filter)
471 || p.pid.to_string() == filter
472 })
473 .map(|info| {
474 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
475 ProcessRow {
476 info,
477 gpu_percent,
478 vram_bytes,
479 }
480 })
481 .collect();
482
483 let key = query.sort_key;
484 rows.sort_by(|a, b| {
485 let ordering = match key {
486 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
487 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
488 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
489 SortKey::User => a.info.user.cmp(&b.info.user),
490 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
491 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
492 SortKey::Gpu => a
493 .gpu_percent
494 .unwrap_or(-1.0)
495 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
496 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
497 }
498 .then(a.info.pid.cmp(&b.info.pid));
500 if query.descending {
501 ordering.reverse()
502 } else {
503 ordering
504 }
505 });
506 rows
507}
508
509pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
512
513pub fn lhm_help(error: &str) -> String {
515 format!(
516 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
517 Start it and turn on Options → Remote Web Server → Run.\n\
518 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
519 {error}"
520 )
521}
522
523pub fn signal_name(signal: ProcessSignal) -> &'static str {
525 match signal {
526 ProcessSignal::Terminate => "SIGTERM",
527 ProcessSignal::Kill => "SIGKILL",
528 }
529}
530
531pub fn is_virtual_interface(name: &str) -> bool {
534 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
535}
536
537pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
539 snapshot
540 .networks
541 .iter()
542 .filter(|n| !is_virtual_interface(&n.name))
543 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
544 .or_else(|| snapshot.networks.first())
545 .map(|n| n.name.clone())
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
552
553 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
554 ProcessInfo {
555 pid,
556 name: name.to_owned(),
557 cpu_percent: cpu,
558 kernel_thread: kernel,
559 ..Default::default()
560 }
561 }
562
563 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
564 ProcessQuery {
565 sort_key,
566 descending: sort_key.descending_by_default(),
567 filter: "",
568 show_kernel_threads: false,
569 }
570 }
571
572 #[test]
573 fn history_keeps_newest_samples() {
574 let mut history = History::default();
575 for i in 0..(HISTORY_LEN + 5) {
576 history.push(i as f64);
577 }
578 assert_eq!(
579 history.last(2),
580 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
581 );
582 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
583 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
584 }
585
586 #[test]
587 fn graph_spans_spread_samples_over_the_width() {
588 let second = Duration::from_secs(1);
589 let mut history = History::default();
590 for i in 0..120 {
591 history.push(i as f64);
592 }
593 assert_eq!(
595 history.graph(3, GraphSpan::Fit, second),
596 vec![117.0, 118.0, 119.0]
597 );
598 let minute = history.graph(30, GraphSpan::Secs(60), second);
600 assert_eq!(minute.len(), 30);
601 assert_eq!(minute[0], 60.5);
602 assert_eq!(minute[29], 118.5);
603 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
605 assert_eq!(stretched.len(), 120);
606 assert_eq!(&stretched[..2], &[90.0, 90.0]);
607 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
608 assert_eq!(stretched[119], 119.0);
609 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
610 let hour = history.graph(300, GraphSpan::Secs(3600), second);
612 assert_eq!(hour.len(), 10);
613 assert_eq!(hour[0], 5.5);
614 assert!(
615 History::default()
616 .graph(10, GraphSpan::Secs(60), second)
617 .is_empty()
618 );
619 }
620
621 #[test]
622 fn graph_span_choices_cycle() {
623 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
624 .take(6)
625 .map(GraphSpan::label)
626 .collect();
627 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "fit"]);
628 assert_eq!(
629 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
630 GraphSpan::Secs(300)
631 );
632 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
633 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
635 }
636
637 #[test]
638 fn rows_filter_sort_and_join_gpu_usage() {
639 let snapshot = Snapshot {
640 processes: vec![
641 process(10, "bash", 5.0, false),
642 process(2, "kworker/0:1", 50.0, true),
643 process(30, "python", 1.0, false),
644 ],
645 gpus: vec![GpuStats {
646 processes: vec![GpuProcess {
647 pid: 30,
648 memory_bytes: Some(1 << 30),
649 sm_percent: Some(70.0),
650 }],
651 ..Default::default()
652 }],
653 ..Default::default()
654 };
655 let pids = |q: &ProcessQuery| {
656 process_rows(&snapshot, q)
657 .iter()
658 .map(|r| r.info.pid)
659 .collect::<Vec<_>>()
660 };
661 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
662 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
663 assert_eq!(
664 pids(&ProcessQuery {
665 show_kernel_threads: true,
666 ..query(SortKey::Cpu)
667 }),
668 vec![2, 10, 30]
669 );
670 assert_eq!(
671 pids(&ProcessQuery {
672 filter: "PYT",
673 ..query(SortKey::Pid)
674 }),
675 vec![30]
676 );
677 let rows = process_rows(&snapshot, &query(SortKey::Pid));
678 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
679 assert_eq!(rows[1].gpu_percent, Some(70.0));
680 }
681
682 #[test]
683 fn metric_history_records_and_switches_interfaces() {
684 let snapshot = Snapshot {
685 networks: vec![
686 NetworkStats {
687 name: "enp9s0".to_owned(),
688 rx_total_bytes: 10,
689 rx_bytes_per_sec: 100.0,
690 ..Default::default()
691 },
692 NetworkStats {
693 name: "wlp7s0".to_owned(),
694 tx_bytes_per_sec: 5.0,
695 ..Default::default()
696 },
697 ],
698 gpus: vec![GpuStats {
699 utilization_percent: Some(40.0),
700 ..Default::default()
701 }],
702 ..Default::default()
703 };
704 let mut history = MetricHistory::default();
705 history.record(&snapshot);
706 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
707 assert_eq!(history.net_rx.last(1), vec![100.0]);
708 assert_eq!(history.gpus[0].last(1), vec![40.0]);
709 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
710 assert!(history.net_rx.last(10).is_empty());
712 history.record(&snapshot);
713 assert_eq!(history.net_tx.last(1), vec![5.0]);
714 }
715
716 #[test]
717 fn alerts_flag_storage_problems_first() {
718 use crate::snapshot::{
719 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
720 };
721 let snapshot = Snapshot {
722 filesystems: vec![FilesystemUsage {
723 mount_points: vec!["/data".to_owned()],
724 total_bytes: 100,
725 available_bytes: 8,
726 ..Default::default()
727 }],
728 raid: RaidStats {
729 btrfs: vec![BtrfsFilesystem {
730 mount_points: vec!["/".to_owned()],
731 device_stats: vec![BtrfsDeviceStats {
732 devid: 1,
733 corruption_errs: 2,
734 ..Default::default()
735 }],
736 ..Default::default()
737 }],
738 md: vec![MdArray {
739 name: "md0".to_owned(),
740 active: true,
741 level: "raid1".to_owned(),
742 expected_devices: Some(2),
743 working_devices: Some(1),
744 ..Default::default()
745 }],
746 },
747 smart: vec![SmartDevice {
748 device: "sda".to_owned(),
749 passed: Some(true),
750 ..Default::default()
751 }],
752 ..Default::default()
753 };
754 let alerts = alerts(&snapshot);
755 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
756 assert_eq!(
757 texts,
758 vec![
759 "btrfs /: 2 I/O errors (devid 1)",
760 "md0 (raid1) degraded",
761 "/data 92% full"
762 ]
763 );
764 assert_eq!(alerts[0].severity, Severity::Critical);
765 assert_eq!(alerts[2].severity, Severity::Warning);
766 assert!(super::alerts(&Snapshot::default()).is_empty());
767 }
768
769 #[test]
770 fn alert_thresholds_come_from_the_configuration() {
771 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
772 let temp = |label: &str, celsius| Temperature {
773 label: label.to_owned(),
774 celsius,
775 ..Default::default()
776 };
777 let mut snapshot = Snapshot {
778 filesystems: vec![FilesystemUsage {
779 mount_points: vec!["/data".to_owned()],
780 total_bytes: 100,
781 available_bytes: 8,
782 ..Default::default()
783 }],
784 gpus: vec![GpuStats {
785 index: 0,
786 temperature_c: Some(80.0),
787 ..Default::default()
788 }],
789 smart: vec![SmartDevice {
790 device: "nvme0".to_owned(),
791 temperature_c: Some(72.0),
792 ..Default::default()
793 }],
794 sensors: vec![
796 SensorChip {
797 key: "nvme:nvme0".to_owned(),
798 name: "nvme".to_owned(),
799 temperatures: vec![temp("Composite", 71.0)],
800 ..Default::default()
801 },
802 SensorChip {
803 key: "nvme:nvme1".to_owned(),
804 name: "nvme".to_owned(),
805 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
806 ..Default::default()
807 },
808 ],
809 ..Default::default()
810 };
811 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
812 let texts = |limits: &AlertsConfig| -> Vec<String> {
813 alerts_with(&snapshot, limits)
814 .into_iter()
815 .map(|a| a.text)
816 .collect()
817 };
818 assert_eq!(
819 texts(&AlertsConfig::default()),
820 [
821 "/data 92% full",
822 "CPU at 97°C",
823 "nvme0 at 72°C",
824 "nvme1 at 74°C"
825 ]
826 );
827 let custom = AlertsConfig {
828 filesystem_warning_percent: 0.0,
829 cpu_warning_celsius: 0.0,
830 gpu_warning_celsius: 75.0,
831 drive_warning_celsius: 73.0,
832 ..Default::default()
833 };
834 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
835 let critical = AlertsConfig {
836 filesystem_critical_percent: 90.0,
837 ..custom
838 };
839 assert_eq!(
840 alerts_with(&snapshot, &critical)[0],
841 Alert {
842 severity: Severity::Critical,
843 text: "/data 92% full".to_owned()
844 }
845 );
846 }
847
848 #[test]
849 fn interface_helpers() {
850 assert!(is_virtual_interface("veth5a74356"));
851 assert!(!is_virtual_interface("enp9s0"));
852 let snapshot = Snapshot {
853 networks: vec![
854 NetworkStats {
855 name: "docker0".to_owned(),
856 rx_total_bytes: 1 << 40,
857 ..Default::default()
858 },
859 NetworkStats {
860 name: "enp9s0".to_owned(),
861 rx_total_bytes: 1 << 30,
862 ..Default::default()
863 },
864 ],
865 ..Default::default()
866 };
867 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
868 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
869 }
870}