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, HashSet, VecDeque};
7use std::time::{Duration, SystemTime};
8
9use chrono::{DateTime, Local};
10
11use crate::config::AlertsConfig;
12use crate::format;
13use crate::snapshot::{ProcessInfo, Snapshot};
14use crate::source::ProcessSignal;
15
16/// Number of samples kept for graphs: over an hour at the default interval
17/// of 1 s, and wider than any realistic terminal in braille dots (2 samples
18/// per cell).
19pub const HISTORY_LEN: usize = 4096;
20
21/// Interface name prefixes skipped when choosing the default network
22/// interface (container bridges and virtual links).
23const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
24
25/// Samples averaged into one point of the coarse history (a minute at the
26/// default interval of 1 s).
27pub const COARSE_FACTOR: usize = 60;
28/// Points kept in the coarse history (a day at the default interval).
29pub const COARSE_LEN: usize = 1440;
30
31/// Fixed-size history of samples, newest last, plus a coarse history of
32/// averages for long time spans.
33#[derive(Debug, Default, Clone)]
34pub struct History {
35    samples: VecDeque<f64>,
36    coarse: VecDeque<f64>,
37    /// Sum and count of the samples not yet averaged into `coarse`.
38    pending: (f64, usize),
39}
40
41impl History {
42    /// Appends a sample, dropping the oldest when full.
43    pub fn push(&mut self, value: f64) {
44        if self.samples.len() == HISTORY_LEN {
45            self.samples.pop_front();
46        }
47        self.samples.push_back(value);
48        self.pending.0 += value;
49        self.pending.1 += 1;
50        if self.pending.1 == COARSE_FACTOR {
51            if self.coarse.len() == COARSE_LEN {
52                self.coarse.pop_front();
53            }
54            self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
55            self.pending = (0.0, 0);
56        }
57    }
58
59    /// The newest `n` samples (or fewer), oldest first.
60    pub fn last(&self, n: usize) -> Vec<f64> {
61        let skip = self.samples.len().saturating_sub(n);
62        self.samples.iter().skip(skip).copied().collect()
63    }
64
65    /// Largest of the newest `n` samples.
66    pub fn max_of_last(&self, n: usize) -> f64 {
67        self.last(n).into_iter().fold(0.0, f64::max)
68    }
69
70    /// Values for a graph `columns` wide, oldest first. [`GraphSpan::Fit`]
71    /// gives the newest sample per column; a time span gives the samples of
72    /// that span (one every `interval`) spread over the columns, averaged
73    /// where several share a column and interpolated where there are fewer
74    /// samples than columns. While the history is shorter than the span,
75    /// fewer values come back, so the graph fills up from the right.
76    pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
77        let GraphSpan::Secs(secs) = span else {
78            return self.last(columns);
79        };
80        let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
81        if wanted <= HISTORY_LEN {
82            spread(&self.last(wanted), wanted, columns)
83        } else {
84            // Longer than the samples kept: from the averages.
85            let wanted = wanted.div_ceil(COARSE_FACTOR);
86            let skip = self.coarse.len().saturating_sub(wanted);
87            let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
88            spread(&points, wanted, columns)
89        }
90    }
91}
92
93/// `samples` (the newest of `wanted`) spread over `columns`: averaged where
94/// several share a column, interpolated where there are fewer samples than
95/// columns, and over a proportional share of the columns at the right while
96/// fewer than `wanted` exist.
97fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
98    if columns == 0 || samples.is_empty() {
99        return Vec::new();
100    }
101    // The share of the columns that the available samples cover.
102    let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
103    let shown = shown.clamp(1, columns);
104    let per_column = samples.len() as f64 / shown as f64;
105    if per_column < 1.0 {
106        // Stretched: a straight line between neighbouring samples.
107        let last = samples.len() - 1;
108        return (0..shown)
109            .map(|i| {
110                let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
111                let (below, frac) = (at.floor() as usize, at.fract());
112                let above = (below + 1).min(last);
113                samples[below] * (1.0 - frac) + samples[above] * frac
114            })
115            .collect();
116    }
117    (0..shown)
118        .map(|i| {
119            let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
120            let start = ((i as f64 * per_column) as usize).min(end - 1);
121            let part = &samples[start..end];
122            part.iter().sum::<f64>() / part.len() as f64
123        })
124        .collect()
125}
126
127/// How much time the graphs cover.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum GraphSpan {
130    /// One sample per graph column, so the time covered grows with the
131    /// width and the interval.
132    #[default]
133    Fit,
134    /// The last this many seconds, spread over the width.
135    Secs(u64),
136}
137
138impl GraphSpan {
139    /// The choices [`GraphSpan::next`] steps through.
140    pub const CHOICES: [GraphSpan; 7] = [
141        GraphSpan::Fit,
142        GraphSpan::Secs(60),
143        GraphSpan::Secs(5 * 60),
144        GraphSpan::Secs(15 * 60),
145        GraphSpan::Secs(60 * 60),
146        GraphSpan::Secs(6 * 60 * 60),
147        GraphSpan::Secs(24 * 60 * 60),
148    ];
149
150    /// The choice after this one, back to the first after the last.
151    pub fn next(self) -> Self {
152        let index = Self::CHOICES
153            .iter()
154            .position(|c| *c == self)
155            .map_or(0, |i| i + 1);
156        Self::CHOICES[index % Self::CHOICES.len()]
157    }
158
159    /// Short name: "fit", "1m", "1h".
160    pub fn label(self) -> String {
161        match self {
162            GraphSpan::Fit => "fit".to_owned(),
163            GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
164            GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
165            GraphSpan::Secs(secs) => format!("{secs}s"),
166        }
167    }
168
169    /// Seconds covered, 0 for [`GraphSpan::Fit`] (for storing the choice).
170    pub fn as_secs(self) -> u64 {
171        match self {
172            GraphSpan::Fit => 0,
173            GraphSpan::Secs(secs) => secs,
174        }
175    }
176
177    /// The inverse of [`GraphSpan::as_secs`].
178    pub fn from_secs(secs: u64) -> Self {
179        if secs == 0 {
180            GraphSpan::Fit
181        } else {
182            GraphSpan::Secs(secs)
183        }
184    }
185}
186
187/// Columns the process table can be sorted by.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum SortKey {
190    Pid,
191    Name,
192    Threads,
193    User,
194    Memory,
195    Cpu,
196    /// Disk reads plus writes.
197    Disk,
198    Gpu,
199    Vram,
200}
201
202impl SortKey {
203    /// Sort keys in column order, used to step left/right.
204    pub const ALL: [SortKey; 9] = [
205        SortKey::Pid,
206        SortKey::Name,
207        SortKey::Threads,
208        SortKey::User,
209        SortKey::Memory,
210        SortKey::Cpu,
211        SortKey::Disk,
212        SortKey::Gpu,
213        SortKey::Vram,
214    ];
215
216    /// Whether "descending" is the natural order (largest consumers first).
217    pub fn descending_by_default(self) -> bool {
218        !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
219    }
220}
221
222/// One row of the process table: the process plus its GPU usage.
223#[derive(Debug, Clone)]
224pub struct ProcessRow<'a> {
225    pub info: &'a ProcessInfo,
226    /// Highest per-GPU SM utilization of the process.
227    pub gpu_percent: Option<f32>,
228    /// VRAM used by the process across all GPUs.
229    pub vram_bytes: Option<u64>,
230    /// In the tree view, the lines leading to the process ("│ ├ "), to be
231    /// put before its name; empty otherwise.
232    pub tree_prefix: String,
233}
234
235/// Graph histories of one host, updated from each snapshot.
236#[derive(Debug, Default, Clone)]
237pub struct MetricHistory {
238    /// Total CPU usage (%).
239    pub cpu: History,
240    /// Used memory (% of total).
241    pub memory: History,
242    /// Utilization (%) per GPU index.
243    pub gpus: Vec<History>,
244    /// Receive rate of `net_interface` (bytes/s).
245    pub net_rx: History,
246    /// Transmit rate of `net_interface` (bytes/s).
247    pub net_tx: History,
248    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
249    pub net_interface: Option<String>,
250}
251
252impl MetricHistory {
253    /// Appends the values of a new snapshot.
254    pub fn record(&mut self, snapshot: &Snapshot) {
255        self.cpu.push(snapshot.cpu.total_percent as f64);
256        let memory = &snapshot.memory;
257        if memory.total_bytes > 0 {
258            self.memory
259                .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
260        }
261        self.gpus.resize_with(snapshot.gpus.len(), History::default);
262        for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
263            history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
264        }
265        if self.net_interface.is_none() {
266            self.net_interface = default_interface(snapshot);
267        }
268        let (rx, tx) = self
269            .net_interface
270            .as_deref()
271            .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
272            .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
273            .unwrap_or((0.0, 0.0));
274        self.net_rx.push(rx);
275        self.net_tx.push(tx);
276    }
277
278    /// Switches the recorded network interface, clearing its history.
279    pub fn select_interface(&mut self, name: &str) {
280        if self.net_interface.as_deref() != Some(name) {
281            self.net_interface = Some(name.to_owned());
282            // The old samples belong to another interface.
283            self.net_rx = History::default();
284            self.net_tx = History::default();
285        }
286    }
287
288    /// Switches to the interface after the current one in `snapshot` and
289    /// returns its name.
290    pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
291        let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
292        if names.is_empty() {
293            return None;
294        }
295        let current = self
296            .net_interface
297            .as_deref()
298            .and_then(|name| names.iter().position(|n| *n == name));
299        let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
300        self.select_interface(&next);
301        Some(next)
302    }
303}
304
305/// How urgent an [`Alert`] is.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
307pub enum Severity {
308    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
309    Warning,
310    /// Data at risk (errors, a missing device, a failing drive).
311    Critical,
312}
313
314/// A condition on a host that needs attention, for overviews.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct Alert {
317    pub severity: Severity,
318    pub text: String,
319}
320
321/// Identifies an alert across samples: its severity and text with numbers
322/// masked, so "3 I/O errors" becoming "4 I/O errors" or a temperature
323/// changing does not count as a new alert.
324pub fn alert_key(alert: &Alert) -> String {
325    let mut key = format!("{:?}:", alert.severity);
326    let mut in_number = false;
327    for c in alert.text.chars() {
328        if c.is_ascii_digit() || (in_number && c == '.') {
329            if !in_number {
330                key.push('#');
331            }
332            in_number = true;
333        } else {
334            key.push(c);
335            in_number = false;
336        }
337    }
338    key
339}
340
341/// Whether an [`AlertEvent`] is an alert appearing or going away.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum AlertChange {
344    Raised,
345    Cleared,
346}
347
348/// One entry of the [`AlertLog`].
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct AlertEvent {
351    /// When the change was seen, in milliseconds since the Unix epoch.
352    pub at_ms: u64,
353    pub host: String,
354    pub change: AlertChange,
355    /// The alert as raised, or as last seen before it cleared.
356    pub alert: Alert,
357}
358
359/// Events kept by an [`AlertLog`].
360const ALERT_LOG_LEN: usize = 500;
361
362/// The alerts of every host as they appear and clear, so that what
363/// happened while nobody watched can be looked up later.
364#[derive(Debug, Default)]
365pub struct AlertLog {
366    /// Newest last.
367    events: VecDeque<AlertEvent>,
368    /// Each host's alerts at its previous update.
369    active: HashMap<String, Vec<Alert>>,
370    /// Alerts raised since [`AlertLog::mark_read`], and the worst of them.
371    unread: (usize, Option<Severity>),
372}
373
374impl AlertLog {
375    /// Records how the alerts of `host` changed since its previous update.
376    pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) {
377        let previous = self.active.remove(host).unwrap_or_default();
378        let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
379        let (before, now) = (keys(&previous), keys(alerts));
380        let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
381        let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
382        let mut changes: Vec<(AlertChange, &Alert)> = raised
383            .map(|a| (AlertChange::Raised, a))
384            .chain(cleared.map(|a| (AlertChange::Cleared, a)))
385            .collect();
386        // Cleared first, so that a changed alert reads "went away, came back".
387        changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
388        for (change, alert) in changes {
389            if change == AlertChange::Raised {
390                self.unread.0 += 1;
391                self.unread.1 = self.unread.1.max(Some(alert.severity));
392            }
393            if self.events.len() == ALERT_LOG_LEN {
394                self.events.pop_front();
395            }
396            self.events.push_back(AlertEvent {
397                at_ms,
398                host: host.to_owned(),
399                change,
400                alert: alert.clone(),
401            });
402        }
403        self.active.insert(host.to_owned(), alerts.to_vec());
404    }
405
406    /// All events, oldest first.
407    pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
408        self.events.iter()
409    }
410
411    /// Alerts raised since the log was last read, and the most severe one.
412    pub fn unread(&self) -> (usize, Option<Severity>) {
413        self.unread
414    }
415
416    /// Marks every event as read.
417    pub fn mark_read(&mut self) {
418        self.unread = (0, None);
419    }
420}
421
422/// How long it has been since `since` (e.g. a host's last data) and when
423/// that was, in local time: "5m (since 14:03)", "2d03h (since Tue 14:03)".
424pub fn since_note(since: SystemTime, now: SystemTime) -> String {
425    let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
426    let span = match secs {
427        0..60 => format!("{secs}s"),
428        60..3_600 => format!("{}m", secs / 60),
429        3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
430        _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
431    };
432    let at: DateTime<Local> = since.into();
433    let today: DateTime<Local> = now.into();
434    let clock = if at.date_naive() == today.date_naive() {
435        at.format("%H:%M")
436    } else if secs < 6 * 86_400 {
437        at.format("%a %H:%M")
438    } else {
439        at.format("%b %d %H:%M")
440    };
441    format!("{span} (since {clock})")
442}
443
444/// "agent 0.8.2" when `snapshot` comes from a rigtop of another version
445/// than this one (an agent not updated yet), else `None`.
446pub fn version_note(snapshot: &Snapshot) -> Option<String> {
447    let version = &snapshot.host.rigtop_version;
448    (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
449        .then(|| format!("agent {version}"))
450}
451
452/// Hover text explaining a [`version_note`].
453pub fn version_hint(snapshot: &Snapshot) -> String {
454    format!(
455        "this host runs rigtop {}, this machine {}: install the same version on both \
456         for every reading",
457        snapshot.host.rigtop_version,
458        env!("CARGO_PKG_VERSION")
459    )
460}
461
462/// [`alerts_with`] the default thresholds.
463pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
464    alerts_with(snapshot, &AlertsConfig::default())
465}
466
467/// Conditions in `snapshot` that need attention, most severe first:
468/// storage errors and failing drives, degraded RAID, full filesystems, hot
469/// CPUs, GPUs and drives, judged by `limits` (0 turns a threshold off).
470pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
471    // A threshold of 0 (or less) is turned off.
472    let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
473    let mut alerts = Vec::new();
474    let mut add = |severity, text: String| alerts.push(Alert { severity, text });
475    for fs in &snapshot.raid.btrfs {
476        let name = fs
477            .mount_points
478            .first()
479            .map_or(fs.fsid.as_str(), String::as_str);
480        let missing = fs.device_stats.iter().filter(|d| d.missing).count();
481        if missing > 0 {
482            add(
483                Severity::Critical,
484                format!("btrfs {name}: {missing} device(s) missing"),
485            );
486        }
487        let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
488        if errors > 0 {
489            // Which drives: the one to replace.
490            let devices: Vec<String> = fs
491                .device_stats
492                .iter()
493                .filter(|d| d.total_errors() > 0)
494                .map(|d| d.label())
495                .collect();
496            add(
497                Severity::Critical,
498                format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
499            );
500        }
501        if let Some(scrub) = &fs.scrub {
502            if scrub.uncorrectable > 0 {
503                add(
504                    Severity::Critical,
505                    format!(
506                        "btrfs {name}: scrub found {} uncorrectable errors",
507                        scrub.uncorrectable
508                    ),
509                );
510            } else if scrub.errors > 0 {
511                add(
512                    Severity::Warning,
513                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
514                );
515            }
516        }
517    }
518    for md in &snapshot.raid.md {
519        if md.active && md.is_degraded() {
520            add(
521                Severity::Critical,
522                format!("{} ({}) degraded", md.name, md.level),
523            );
524        } else if !md.active {
525            add(Severity::Warning, format!("{} inactive", md.name));
526        }
527        if let Some(sync) = &md.sync {
528            add(
529                Severity::Warning,
530                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
531            );
532        }
533    }
534    for smart in &snapshot.smart {
535        if let Some(error) = &smart.error {
536            add(
537                Severity::Warning,
538                format!("{}: SMART unreadable ({error})", smart.device),
539            );
540        } else if smart.passed == Some(false) {
541            add(
542                Severity::Critical,
543                format!("{}: SMART failed", smart.device),
544            );
545        } else if smart.needs_attention() {
546            add(
547                Severity::Critical,
548                format!("{}: SMART warning", smart.device),
549            );
550        }
551    }
552    for fs in &snapshot.filesystems {
553        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
554        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
555        let name = fs
556            .mount_points
557            .first()
558            .map_or(fs.device.as_str(), String::as_str);
559        if reached(percent, limits.filesystem_critical_percent) {
560            add(Severity::Critical, format!("{name} {percent:.0}% full"));
561        } else if reached(percent, limits.filesystem_warning_percent) {
562            add(Severity::Warning, format!("{name} {percent:.0}% full"));
563        }
564    }
565    let hottest_cpu = snapshot
566        .cpu
567        .temperatures
568        .iter()
569        .map(|t| t.celsius)
570        .fold(f32::NAN, f32::max);
571    if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
572        add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
573    }
574    for gpu in &snapshot.gpus {
575        if let Some(t) = gpu
576            .temperature_c
577            .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
578        {
579            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
580        }
581    }
582    for (device, celsius) in drive_temperatures(snapshot) {
583        if reached(celsius as f64, limits.drive_warning_celsius as f64) {
584            add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
585        }
586    }
587    // Stable sort keeps the order above within each severity.
588    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
589    alerts
590}
591
592/// (device, °C) of each drive: SMART results, then NVMe drives without
593/// them from their hwmon sensor ("Composite", the drive's own summary).
594fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
595    let mut drives: Vec<(String, f32)> = snapshot
596        .smart
597        .iter()
598        .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
599        .collect();
600    for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
601        // The chip key is "nvme:<device>".
602        let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
603        if drives.iter().any(|(d, _)| d == device) {
604            continue;
605        }
606        let composite = chip
607            .temperatures
608            .iter()
609            .find(|t| t.label == "Composite")
610            .or(chip.temperatures.first());
611        if let Some(t) = composite {
612            drives.push((device.to_owned(), t.celsius));
613        }
614    }
615    drives
616}
617
618/// How the process table is filtered and sorted.
619#[derive(Debug, Clone, Copy)]
620pub struct ProcessQuery<'q> {
621    /// Column to sort by.
622    pub sort_key: SortKey,
623    /// Largest first when true.
624    pub descending: bool,
625    /// Case-insensitive substring of name, command or user, or an exact pid.
626    pub filter: &'q str,
627    /// Include kernel threads (`kworker` and friends).
628    pub show_kernel_threads: bool,
629    /// Children under their parents, siblings sorted by `sort_key`. A
630    /// filter keeps the ancestors of what it matches.
631    pub tree: bool,
632}
633
634/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
635pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
636    // pid -> (max SM %, total VRAM) across GPUs.
637    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
638    for gpu in &snapshot.gpus {
639        for p in &gpu.processes {
640            let entry = gpu_usage.entry(p.pid).or_default();
641            if let Some(sm) = p.sm_percent {
642                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
643            }
644            if let Some(mem) = p.memory_bytes {
645                entry.1 = Some(entry.1.unwrap_or(0) + mem);
646            }
647        }
648    }
649
650    let filter = query.filter.to_lowercase();
651    let matches = |p: &ProcessInfo| {
652        filter.is_empty()
653            || p.name.to_lowercase().contains(&filter)
654            || p.command.to_lowercase().contains(&filter)
655            || p.user.to_lowercase().contains(&filter)
656            || p.pid.to_string() == filter
657    };
658    let candidates = snapshot
659        .processes
660        .iter()
661        .filter(|p| query.show_kernel_threads || !p.kernel_thread);
662    let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
663        with_ancestors(candidates.collect(), matches)
664    } else {
665        candidates.filter(|p| matches(p)).collect()
666    };
667    let mut rows: Vec<ProcessRow<'a>> = shown
668        .into_iter()
669        .map(|info| {
670            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
671            ProcessRow {
672                info,
673                gpu_percent,
674                vram_bytes,
675                tree_prefix: String::new(),
676            }
677        })
678        .collect();
679
680    let key = query.sort_key;
681    rows.sort_by(|a, b| {
682        let ordering = match key {
683            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
684            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
685            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
686            SortKey::User => a.info.user.cmp(&b.info.user),
687            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
688            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
689            SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
690            SortKey::Gpu => a
691                .gpu_percent
692                .unwrap_or(-1.0)
693                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
694            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
695        }
696        // Stable tie-breaker so rows do not jump between frames.
697        .then(a.info.pid.cmp(&b.info.pid));
698        if query.descending {
699            ordering.reverse()
700        } else {
701            ordering
702        }
703    });
704    if query.tree { into_tree(rows) } else { rows }
705}
706
707/// The processes `matches` accepts, plus their ancestors among `processes`
708/// (so a filtered tree keeps its shape), in the order of `processes`.
709fn with_ancestors(
710    processes: Vec<&ProcessInfo>,
711    matches: impl Fn(&ProcessInfo) -> bool,
712) -> Vec<&ProcessInfo> {
713    let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
714    let mut keep: HashSet<u32> = HashSet::new();
715    for process in processes.iter().filter(|p| matches(p)) {
716        let mut current = Some(*process);
717        // Stops at a process already kept, which also ends parent loops.
718        while let Some(p) = current
719            && keep.insert(p.pid)
720        {
721            current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
722        }
723    }
724    processes
725        .into_iter()
726        .filter(|p| keep.contains(&p.pid))
727        .collect()
728}
729
730/// Reorders sorted `rows` depth-first under their parents, keeping the
731/// sorted order among siblings, and fills in [`ProcessRow::tree_prefix`].
732/// Processes whose parent is not among the rows are roots.
733fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
734    let index: HashMap<u32, usize> = rows
735        .iter()
736        .enumerate()
737        .map(|(i, row)| (row.info.pid, i))
738        .collect();
739    let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
740    let mut roots = Vec::new();
741    for (i, row) in rows.iter().enumerate() {
742        match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
743            Some(&parent) if parent != i => children[parent].push(i),
744            _ => roots.push(i),
745        }
746    }
747    let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
748    let mut visited = vec![false; rows.len()];
749    // Processes in a parent loop have no root; they start trees of their own.
750    let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
751    for start in starts {
752        // (row, lines of the ancestors, whether it is its parent's last child)
753        let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
754        while let Some((i, lead, last)) = stack.pop() {
755            if std::mem::replace(&mut visited[i], true) {
756                continue;
757            }
758            let (prefix, child_lead) = match last {
759                None => (String::new(), String::new()),
760                Some(true) => (format!("{lead}└ "), format!("{lead}  ")),
761                Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
762            };
763            order.push((i, prefix));
764            let kids = &children[i];
765            for (k, &child) in kids.iter().enumerate().rev() {
766                stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
767            }
768        }
769    }
770    let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
771    order
772        .into_iter()
773        .filter_map(|(i, prefix)| {
774            let mut row = rows[i].take()?;
775            row.tree_prefix = prefix;
776            Some(row)
777        })
778        .collect()
779}
780
781/// Disk reads plus writes of a process (bytes/s), the Disk column.
782pub fn disk_rate(process: &ProcessInfo) -> f64 {
783    process.read_bytes_per_sec + process.write_bytes_per_sec
784}
785
786/// The Disk column's text: the total rate, blank when idle (also where the
787/// counters cannot be read, e.g. other users' processes on Linux).
788pub fn disk_rate_text(process: &ProcessInfo) -> String {
789    let rate = disk_rate(process);
790    if rate > 0.0 {
791        format::rate(rate)
792    } else {
793        String::new()
794    }
795}
796
797/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
798/// read (Windows; see [`Snapshot::lhm_error`]).
799pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
800
801/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
802pub fn lhm_help(error: &str) -> String {
803    format!(
804        "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
805         Start it and turn on Options → Remote Web Server → Run.\n\
806         ([lhm] url = \"\" in the configuration hides this note.)\n\n\
807         {error}"
808    )
809}
810
811/// Name of a signal as shown to the user.
812pub fn signal_name(signal: ProcessSignal) -> &'static str {
813    match signal {
814        ProcessSignal::Terminate => "SIGTERM",
815        ProcessSignal::Kill => "SIGKILL",
816    }
817}
818
819/// Returns true for container bridges, veth pairs and loopback, which are
820/// skipped when choosing and listing interfaces.
821pub fn is_virtual_interface(name: &str) -> bool {
822    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
823}
824
825/// Picks the physical interface with the most traffic so far.
826pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
827    snapshot
828        .networks
829        .iter()
830        .filter(|n| !is_virtual_interface(&n.name))
831        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
832        .or_else(|| snapshot.networks.first())
833        .map(|n| n.name.clone())
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
840
841    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
842        ProcessInfo {
843            pid,
844            name: name.to_owned(),
845            cpu_percent: cpu,
846            kernel_thread: kernel,
847            ..Default::default()
848        }
849    }
850
851    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
852        ProcessQuery {
853            sort_key,
854            descending: sort_key.descending_by_default(),
855            filter: "",
856            show_kernel_threads: false,
857            tree: false,
858        }
859    }
860
861    #[test]
862    fn history_keeps_newest_samples() {
863        let mut history = History::default();
864        for i in 0..(HISTORY_LEN + 5) {
865            history.push(i as f64);
866        }
867        assert_eq!(
868            history.last(2),
869            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
870        );
871        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
872        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
873    }
874
875    #[test]
876    fn graph_spans_spread_samples_over_the_width() {
877        let second = Duration::from_secs(1);
878        let mut history = History::default();
879        for i in 0..120 {
880            history.push(i as f64);
881        }
882        // Fit: one sample per column, the newest ones.
883        assert_eq!(
884            history.graph(3, GraphSpan::Fit, second),
885            vec![117.0, 118.0, 119.0]
886        );
887        // A minute on 30 columns: two samples averaged per column.
888        let minute = history.graph(30, GraphSpan::Secs(60), second);
889        assert_eq!(minute.len(), 30);
890        assert_eq!(minute[0], 60.5);
891        assert_eq!(minute[29], 118.5);
892        // Fewer samples than columns: interpolated between them.
893        let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
894        assert_eq!(stretched.len(), 120);
895        assert_eq!(&stretched[..2], &[90.0, 90.0]);
896        assert_eq!(&stretched[2..4], &[90.125, 90.375]);
897        assert_eq!(stretched[119], 119.0);
898        assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
899        // An hour with two minutes recorded fills the right 1/30.
900        let hour = history.graph(300, GraphSpan::Secs(3600), second);
901        assert_eq!(hour.len(), 10);
902        assert_eq!(hour[0], 5.5);
903        assert!(
904            History::default()
905                .graph(10, GraphSpan::Secs(60), second)
906                .is_empty()
907        );
908    }
909
910    #[test]
911    fn long_spans_draw_from_the_averages() {
912        let second = Duration::from_secs(1);
913        let mut history = History::default();
914        // Two hours: the first at 0, the second at 100.
915        for i in 0..7200 {
916            history.push(if i < 3600 { 0.0 } else { 100.0 });
917        }
918        // 6 hours with 2 recorded: the right third, half low and half high.
919        let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
920        assert_eq!(six.len(), 30);
921        assert_eq!((six[0], six[29]), (0.0, 100.0));
922        // An hour still comes from the samples themselves.
923        let hour = history.graph(60, GraphSpan::Secs(3600), second);
924        assert!(hour.iter().all(|v| *v == 100.0));
925        // The averages stay bounded.
926        for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
927            history.push(i as f64);
928        }
929        assert_eq!(history.coarse.len(), COARSE_LEN);
930    }
931
932    #[test]
933    fn graph_span_choices_cycle() {
934        let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
935            .take(6)
936            .map(GraphSpan::label)
937            .collect();
938        assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
939        assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
940        assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
941        assert_eq!(
942            GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
943            GraphSpan::Secs(300)
944        );
945        assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
946        // A stored span that is not a choice starts over.
947        assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
948    }
949
950    #[test]
951    fn rows_filter_sort_and_join_gpu_usage() {
952        let snapshot = Snapshot {
953            processes: vec![
954                process(10, "bash", 5.0, false),
955                process(2, "kworker/0:1", 50.0, true),
956                process(30, "python", 1.0, false),
957            ],
958            gpus: vec![GpuStats {
959                processes: vec![GpuProcess {
960                    pid: 30,
961                    memory_bytes: Some(1 << 30),
962                    sm_percent: Some(70.0),
963                }],
964                ..Default::default()
965            }],
966            ..Default::default()
967        };
968        let pids = |q: &ProcessQuery| {
969            process_rows(&snapshot, q)
970                .iter()
971                .map(|r| r.info.pid)
972                .collect::<Vec<_>>()
973        };
974        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
975        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
976        assert_eq!(
977            pids(&ProcessQuery {
978                show_kernel_threads: true,
979                ..query(SortKey::Cpu)
980            }),
981            vec![2, 10, 30]
982        );
983        assert_eq!(
984            pids(&ProcessQuery {
985                filter: "PYT",
986                ..query(SortKey::Pid)
987            }),
988            vec![30]
989        );
990        let rows = process_rows(&snapshot, &query(SortKey::Pid));
991        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
992        assert_eq!(rows[1].gpu_percent, Some(70.0));
993    }
994
995    #[test]
996    fn since_notes_say_how_long_and_since_when() {
997        let now = SystemTime::now();
998        let ago = |secs: u64| now - Duration::from_secs(secs);
999        assert!(since_note(ago(42), now).starts_with("42s (since "));
1000        assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1001        assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1002        assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1003        // A clock that moved backwards does not underflow.
1004        assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1005    }
1006
1007    #[test]
1008    fn tree_puts_children_under_parents() {
1009        let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1010            parent_pid: Some(parent),
1011            ..process(pid, name, cpu, false)
1012        };
1013        let snapshot = Snapshot {
1014            processes: vec![
1015                process(1, "init", 0.0, false),
1016                child(10, 1, "sshd", 1.0),
1017                child(11, 10, "bash", 2.0),
1018                child(12, 11, "python", 50.0),
1019                child(20, 1, "cron", 3.0),
1020                // Parent not listed (e.g. not readable): a root of its own.
1021                child(30, 999, "orphan", 4.0),
1022                // A parent loop must not hang or drop the processes.
1023                child(40, 41, "loop-a", 0.0),
1024                child(41, 40, "loop-b", 0.0),
1025            ],
1026            ..Default::default()
1027        };
1028        let tree = |filter| {
1029            process_rows(
1030                &snapshot,
1031                &ProcessQuery {
1032                    filter,
1033                    tree: true,
1034                    ..query(SortKey::Cpu)
1035                },
1036            )
1037            .iter()
1038            .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1039            .collect::<Vec<_>>()
1040        };
1041        // Roots and siblings by CPU, largest first (ties: higher pid first).
1042        assert_eq!(
1043            tree(""),
1044            vec![
1045                "orphan",
1046                "init",
1047                "├ cron",
1048                "└ sshd",
1049                "  └ bash",
1050                "    └ python",
1051                "loop-b",
1052                "└ loop-a",
1053            ]
1054        );
1055        // A filter keeps the ancestors of what it matches.
1056        assert_eq!(
1057            tree("pyth"),
1058            vec!["init", "└ sshd", "  └ bash", "    └ python"]
1059        );
1060    }
1061
1062    #[test]
1063    fn disk_column_sums_reads_and_writes() {
1064        let reader = ProcessInfo {
1065            read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1066            ..process(10, "reader", 0.0, false)
1067        };
1068        let writer = ProcessInfo {
1069            read_bytes_per_sec: 1024.0 * 1024.0,
1070            write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1071            ..process(20, "writer", 0.0, false)
1072        };
1073        let idle = process(30, "idle", 0.0, false);
1074        assert_eq!(disk_rate_text(&writer), "5.0M/s");
1075        assert_eq!(disk_rate_text(&idle), "");
1076        let snapshot = Snapshot {
1077            processes: vec![idle, reader, writer],
1078            ..Default::default()
1079        };
1080        let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1081            .iter()
1082            .map(|r| r.info.pid)
1083            .collect();
1084        assert_eq!(pids, vec![20, 10, 30]);
1085    }
1086
1087    #[test]
1088    fn metric_history_records_and_switches_interfaces() {
1089        let snapshot = Snapshot {
1090            networks: vec![
1091                NetworkStats {
1092                    name: "enp9s0".to_owned(),
1093                    rx_total_bytes: 10,
1094                    rx_bytes_per_sec: 100.0,
1095                    ..Default::default()
1096                },
1097                NetworkStats {
1098                    name: "wlp7s0".to_owned(),
1099                    tx_bytes_per_sec: 5.0,
1100                    ..Default::default()
1101                },
1102            ],
1103            gpus: vec![GpuStats {
1104                utilization_percent: Some(40.0),
1105                ..Default::default()
1106            }],
1107            ..Default::default()
1108        };
1109        let mut history = MetricHistory::default();
1110        history.record(&snapshot);
1111        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1112        assert_eq!(history.net_rx.last(1), vec![100.0]);
1113        assert_eq!(history.gpus[0].last(1), vec![40.0]);
1114        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1115        // Switching clears the samples of the previous interface.
1116        assert!(history.net_rx.last(10).is_empty());
1117        history.record(&snapshot);
1118        assert_eq!(history.net_tx.last(1), vec![5.0]);
1119    }
1120
1121    #[test]
1122    fn alerts_flag_storage_problems_first() {
1123        use crate::snapshot::{
1124            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1125        };
1126        let snapshot = Snapshot {
1127            filesystems: vec![FilesystemUsage {
1128                mount_points: vec!["/data".to_owned()],
1129                total_bytes: 100,
1130                available_bytes: 8,
1131                ..Default::default()
1132            }],
1133            raid: RaidStats {
1134                btrfs: vec![BtrfsFilesystem {
1135                    mount_points: vec!["/".to_owned()],
1136                    device_stats: vec![BtrfsDeviceStats {
1137                        devid: 1,
1138                        corruption_errs: 2,
1139                        ..Default::default()
1140                    }],
1141                    ..Default::default()
1142                }],
1143                md: vec![MdArray {
1144                    name: "md0".to_owned(),
1145                    active: true,
1146                    level: "raid1".to_owned(),
1147                    expected_devices: Some(2),
1148                    working_devices: Some(1),
1149                    ..Default::default()
1150                }],
1151            },
1152            smart: vec![SmartDevice {
1153                device: "sda".to_owned(),
1154                passed: Some(true),
1155                ..Default::default()
1156            }],
1157            ..Default::default()
1158        };
1159        let alerts = alerts(&snapshot);
1160        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1161        assert_eq!(
1162            texts,
1163            vec![
1164                "btrfs /: 2 I/O errors (devid 1)",
1165                "md0 (raid1) degraded",
1166                "/data 92% full"
1167            ]
1168        );
1169        assert_eq!(alerts[0].severity, Severity::Critical);
1170        assert_eq!(alerts[2].severity, Severity::Warning);
1171        assert!(super::alerts(&Snapshot::default()).is_empty());
1172    }
1173
1174    #[test]
1175    fn version_notes_only_for_other_versions() {
1176        let mut snapshot = Snapshot::default();
1177        assert_eq!(version_note(&snapshot), None);
1178        snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1179        assert_eq!(version_note(&snapshot), None);
1180        snapshot.host.rigtop_version = "0.1.0".to_owned();
1181        assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1182        assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1183    }
1184
1185    #[test]
1186    fn alert_log_records_alerts_appearing_and_clearing() {
1187        let alert = |severity, text: &str| Alert {
1188            severity,
1189            text: text.to_owned(),
1190        };
1191        let full = alert(Severity::Warning, "/data 91% full");
1192        let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1193        let mut log = AlertLog::default();
1194        log.update("nas", std::slice::from_ref(&full), 1_000);
1195        // Numbers changing is the same alert.
1196        log.update(
1197            "nas",
1198            &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1199            2_000,
1200        );
1201        log.update("zefat", &[], 2_000);
1202        assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1203        log.mark_read();
1204        log.update("nas", std::slice::from_ref(&errors), 3_000);
1205        assert_eq!(log.unread(), (0, None));
1206        let summary: Vec<(u64, AlertChange, &str)> = log
1207            .events()
1208            .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1209            .collect();
1210        assert_eq!(
1211            summary,
1212            [
1213                (1_000, AlertChange::Raised, "/data 91% full"),
1214                (
1215                    2_000,
1216                    AlertChange::Raised,
1217                    "btrfs /: 2 I/O errors (nvme1n1)"
1218                ),
1219                (3_000, AlertChange::Cleared, "/data 92% full"),
1220            ]
1221        );
1222        for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1223            log.update(
1224                "flappy",
1225                &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1226                i,
1227            );
1228        }
1229        assert_eq!(log.events().count(), ALERT_LOG_LEN);
1230    }
1231
1232    #[test]
1233    fn alert_thresholds_come_from_the_configuration() {
1234        use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1235        let temp = |label: &str, celsius| Temperature {
1236            label: label.to_owned(),
1237            celsius,
1238            ..Default::default()
1239        };
1240        let mut snapshot = Snapshot {
1241            filesystems: vec![FilesystemUsage {
1242                mount_points: vec!["/data".to_owned()],
1243                total_bytes: 100,
1244                available_bytes: 8,
1245                ..Default::default()
1246            }],
1247            gpus: vec![GpuStats {
1248                index: 0,
1249                temperature_c: Some(80.0),
1250                ..Default::default()
1251            }],
1252            smart: vec![SmartDevice {
1253                device: "nvme0".to_owned(),
1254                temperature_c: Some(72.0),
1255                ..Default::default()
1256            }],
1257            // nvme0 also has SMART (reported once); nvme1 only its sensor.
1258            sensors: vec![
1259                SensorChip {
1260                    key: "nvme:nvme0".to_owned(),
1261                    name: "nvme".to_owned(),
1262                    temperatures: vec![temp("Composite", 71.0)],
1263                    ..Default::default()
1264                },
1265                SensorChip {
1266                    key: "nvme:nvme1".to_owned(),
1267                    name: "nvme".to_owned(),
1268                    temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1269                    ..Default::default()
1270                },
1271            ],
1272            ..Default::default()
1273        };
1274        snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1275        let texts = |limits: &AlertsConfig| -> Vec<String> {
1276            alerts_with(&snapshot, limits)
1277                .into_iter()
1278                .map(|a| a.text)
1279                .collect()
1280        };
1281        assert_eq!(
1282            texts(&AlertsConfig::default()),
1283            [
1284                "/data 92% full",
1285                "CPU at 97°C",
1286                "nvme0 at 72°C",
1287                "nvme1 at 74°C"
1288            ]
1289        );
1290        let custom = AlertsConfig {
1291            filesystem_warning_percent: 0.0,
1292            cpu_warning_celsius: 0.0,
1293            gpu_warning_celsius: 75.0,
1294            drive_warning_celsius: 73.0,
1295            ..Default::default()
1296        };
1297        assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1298        let critical = AlertsConfig {
1299            filesystem_critical_percent: 90.0,
1300            ..custom
1301        };
1302        assert_eq!(
1303            alerts_with(&snapshot, &critical)[0],
1304            Alert {
1305                severity: Severity::Critical,
1306                text: "/data 92% full".to_owned()
1307            }
1308        );
1309    }
1310
1311    #[test]
1312    fn interface_helpers() {
1313        assert!(is_virtual_interface("veth5a74356"));
1314        assert!(!is_virtual_interface("enp9s0"));
1315        let snapshot = Snapshot {
1316            networks: vec![
1317                NetworkStats {
1318                    name: "docker0".to_owned(),
1319                    rx_total_bytes: 1 << 40,
1320                    ..Default::default()
1321                },
1322                NetworkStats {
1323                    name: "enp9s0".to_owned(),
1324                    rx_total_bytes: 1 << 30,
1325                    ..Default::default()
1326                },
1327            ],
1328            ..Default::default()
1329        };
1330        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1331        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1332    }
1333}