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