Skip to main content

rigtop_core/
view.rs

1//! Front-end helpers shared by the TUI and the GUI: graph history, process
2//! table sorting/filtering joined with GPU usage, and small naming helpers.
3//!
4//! Nothing here depends on a UI toolkit.
5
6use std::collections::{HashMap, VecDeque};
7use std::time::Duration;
8
9use crate::config::AlertsConfig;
10use crate::snapshot::{ProcessInfo, Snapshot};
11use crate::source::ProcessSignal;
12
13/// Number of samples kept for graphs: over an hour at the default interval
14/// of 1 s, and wider than any realistic terminal in braille dots (2 samples
15/// per cell).
16pub const HISTORY_LEN: usize = 4096;
17
18/// Interface name prefixes skipped when choosing the default network
19/// interface (container bridges and virtual links).
20const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
21
22/// Fixed-size history of samples, newest last.
23#[derive(Debug, Default, Clone)]
24pub struct History {
25    samples: VecDeque<f64>,
26}
27
28impl History {
29    /// Appends a sample, dropping the oldest when full.
30    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    /// The newest `n` samples (or fewer), oldest first.
38    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    /// Largest of the newest `n` samples.
44    pub fn max_of_last(&self, n: usize) -> f64 {
45        self.last(n).into_iter().fold(0.0, f64::max)
46    }
47
48    /// Values for a graph `columns` wide, oldest first. [`GraphSpan::Fit`]
49    /// gives the newest sample per column; a time span gives the samples of
50    /// that span (one every `interval`) spread over the columns, averaged
51    /// where several share a column and interpolated where there are fewer
52    /// samples than columns. While the history is shorter than the span,
53    /// fewer values come back, so the graph fills up from the right.
54    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        // The share of the columns that the available samples cover.
64        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            // Stretched: a straight line between neighbouring samples.
69            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/// How much time the graphs cover.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum GraphSpan {
93    /// One sample per graph column, so the time covered grows with the
94    /// width and the interval.
95    #[default]
96    Fit,
97    /// The last this many seconds, spread over the width.
98    Secs(u64),
99}
100
101impl GraphSpan {
102    /// The choices [`GraphSpan::next`] steps through.
103    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    /// The choice after this one, back to the first after the last.
112    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    /// Short name: "fit", "1m", "1h".
121    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    /// Seconds covered, 0 for [`GraphSpan::Fit`] (for storing the choice).
131    pub fn as_secs(self) -> u64 {
132        match self {
133            GraphSpan::Fit => 0,
134            GraphSpan::Secs(secs) => secs,
135        }
136    }
137
138    /// The inverse of [`GraphSpan::as_secs`].
139    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/// Columns the process table can be sorted by.
149#[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    /// Sort keys in column order, used to step left/right.
163    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    /// Whether "descending" is the natural order (largest consumers first).
175    pub fn descending_by_default(self) -> bool {
176        !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
177    }
178}
179
180/// One row of the process table: the process plus its GPU usage.
181#[derive(Debug, Clone)]
182pub struct ProcessRow<'a> {
183    pub info: &'a ProcessInfo,
184    /// Highest per-GPU SM utilization of the process.
185    pub gpu_percent: Option<f32>,
186    /// VRAM used by the process across all GPUs.
187    pub vram_bytes: Option<u64>,
188}
189
190/// Graph histories of one host, updated from each snapshot.
191#[derive(Debug, Default, Clone)]
192pub struct MetricHistory {
193    /// Total CPU usage (%).
194    pub cpu: History,
195    /// Used memory (% of total).
196    pub memory: History,
197    /// Utilization (%) per GPU index.
198    pub gpus: Vec<History>,
199    /// Receive rate of `net_interface` (bytes/s).
200    pub net_rx: History,
201    /// Transmit rate of `net_interface` (bytes/s).
202    pub net_tx: History,
203    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
204    pub net_interface: Option<String>,
205}
206
207impl MetricHistory {
208    /// Appends the values of a new snapshot.
209    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    /// Switches the recorded network interface, clearing its history.
234    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            // The old samples belong to another interface.
238            self.net_rx = History::default();
239            self.net_tx = History::default();
240        }
241    }
242
243    /// Switches to the interface after the current one in `snapshot` and
244    /// returns its name.
245    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/// How urgent an [`Alert`] is.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
262pub enum Severity {
263    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
264    Warning,
265    /// Data at risk (errors, a missing device, a failing drive).
266    Critical,
267}
268
269/// A condition on a host that needs attention, for overviews.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct Alert {
272    pub severity: Severity,
273    pub text: String,
274}
275
276/// [`alerts_with`] the default thresholds.
277pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
278    alerts_with(snapshot, &AlertsConfig::default())
279}
280
281/// Conditions in `snapshot` that need attention, most severe first:
282/// storage errors and failing drives, degraded RAID, full filesystems, hot
283/// CPUs, GPUs and drives, judged by `limits` (0 turns a threshold off).
284pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
285    // A threshold of 0 (or less) is turned off.
286    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            // Which drives: the one to replace.
304            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    // Stable sort keeps the order above within each severity.
402    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
403    alerts
404}
405
406/// (device, °C) of each drive: SMART results, then NVMe drives without
407/// them from their hwmon sensor ("Composite", the drive's own summary).
408fn 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        // The chip key is "nvme:<device>".
416        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/// How the process table is filtered and sorted.
433#[derive(Debug, Clone, Copy)]
434pub struct ProcessQuery<'q> {
435    /// Column to sort by.
436    pub sort_key: SortKey,
437    /// Largest first when true.
438    pub descending: bool,
439    /// Case-insensitive substring of name, command or user, or an exact pid.
440    pub filter: &'q str,
441    /// Include kernel threads (`kworker` and friends).
442    pub show_kernel_threads: bool,
443}
444
445/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
446pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
447    // pid -> (max SM %, total VRAM) across GPUs.
448    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        // Stable tie-breaker so rows do not jump between frames.
499        .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
509/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
510/// read (Windows; see [`Snapshot::lhm_error`]).
511pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
512
513/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
514pub 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
523/// Name of a signal as shown to the user.
524pub fn signal_name(signal: ProcessSignal) -> &'static str {
525    match signal {
526        ProcessSignal::Terminate => "SIGTERM",
527        ProcessSignal::Kill => "SIGKILL",
528    }
529}
530
531/// Returns true for container bridges, veth pairs and loopback, which are
532/// skipped when choosing and listing interfaces.
533pub fn is_virtual_interface(name: &str) -> bool {
534    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
535}
536
537/// Picks the physical interface with the most traffic so far.
538pub 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        // Fit: one sample per column, the newest ones.
594        assert_eq!(
595            history.graph(3, GraphSpan::Fit, second),
596            vec![117.0, 118.0, 119.0]
597        );
598        // A minute on 30 columns: two samples averaged per column.
599        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        // Fewer samples than columns: interpolated between them.
604        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        // An hour with two minutes recorded fills the right 1/30.
611        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        // A stored span that is not a choice starts over.
634        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        // Switching clears the samples of the previous interface.
711        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            // nvme0 also has SMART (reported once); nvme1 only its sensor.
795            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}