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, IntegratedGpu};
16use crate::format;
17use crate::snapshot::{
18    CpuStats, GpuStats, NetworkStats, OomKill, PcieLink, Pressure, ProcessInfo, Snapshot,
19};
20use crate::source::ProcessSignal;
21
22/// Number of samples kept for graphs: over an hour at the default interval
23/// of 1 s, and wider than any realistic terminal in braille dots (2 samples
24/// per cell).
25pub const HISTORY_LEN: usize = 4096;
26
27/// Interface name prefixes skipped when choosing the default network
28/// interface (container bridges and virtual links).
29const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
30
31/// Samples averaged into one point of the coarse history (a minute at the
32/// default interval of 1 s).
33pub const COARSE_FACTOR: usize = 60;
34/// Points kept in the coarse history (a day at the default interval).
35pub const COARSE_LEN: usize = 1440;
36
37/// Fixed-size history of samples, newest last, plus a coarse history of
38/// averages for long time spans.
39#[derive(Debug, Default, Clone)]
40pub struct History {
41    samples: VecDeque<f64>,
42    coarse: VecDeque<f64>,
43    /// Sum and count of the samples not yet averaged into `coarse`.
44    pending: (f64, usize),
45}
46
47impl History {
48    /// Appends a sample, dropping the oldest when full.
49    pub fn push(&mut self, value: f64) {
50        if self.samples.len() == HISTORY_LEN {
51            self.samples.pop_front();
52        }
53        self.samples.push_back(value);
54        self.pending.0 += value;
55        self.pending.1 += 1;
56        if self.pending.1 == COARSE_FACTOR {
57            if self.coarse.len() == COARSE_LEN {
58                self.coarse.pop_front();
59            }
60            self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
61            self.pending = (0.0, 0);
62        }
63    }
64
65    /// The newest `n` samples (or fewer), oldest first.
66    pub fn last(&self, n: usize) -> Vec<f64> {
67        let skip = self.samples.len().saturating_sub(n);
68        self.samples.iter().skip(skip).copied().collect()
69    }
70
71    /// Largest of the newest `n` samples.
72    pub fn max_of_last(&self, n: usize) -> f64 {
73        self.last(n).into_iter().fold(0.0, f64::max)
74    }
75
76    /// Values for a graph `columns` wide, oldest first. [`GraphSpan::Fit`]
77    /// gives the newest sample per column; a time span gives the samples of
78    /// that span (one every `interval`) spread over the columns, averaged
79    /// where several share a column and interpolated where there are fewer
80    /// samples than columns. While the history is shorter than the span,
81    /// fewer values come back, so the graph fills up from the right.
82    pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
83        let GraphSpan::Secs(secs) = span else {
84            return self.last(columns);
85        };
86        let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
87        if wanted <= HISTORY_LEN {
88            spread(&self.last(wanted), wanted, columns)
89        } else {
90            // Longer than the samples kept: from the averages.
91            let wanted = wanted.div_ceil(COARSE_FACTOR);
92            let skip = self.coarse.len().saturating_sub(wanted);
93            let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
94            spread(&points, wanted, columns)
95        }
96    }
97}
98
99/// `samples` (the newest of `wanted`) spread over `columns`: averaged where
100/// several share a column, interpolated where there are fewer samples than
101/// columns, and over a proportional share of the columns at the right while
102/// fewer than `wanted` exist.
103fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
104    if columns == 0 || samples.is_empty() {
105        return Vec::new();
106    }
107    // The share of the columns that the available samples cover.
108    let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
109    let shown = shown.clamp(1, columns);
110    let per_column = samples.len() as f64 / shown as f64;
111    if per_column < 1.0 {
112        // Stretched: a straight line between neighbouring samples.
113        let last = samples.len() - 1;
114        return (0..shown)
115            .map(|i| {
116                let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
117                let (below, frac) = (at.floor() as usize, at.fract());
118                let above = (below + 1).min(last);
119                samples[below] * (1.0 - frac) + samples[above] * frac
120            })
121            .collect();
122    }
123    (0..shown)
124        .map(|i| {
125            let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
126            let start = ((i as f64 * per_column) as usize).min(end - 1);
127            let part = &samples[start..end];
128            part.iter().sum::<f64>() / part.len() as f64
129        })
130        .collect()
131}
132
133/// How much time the graphs cover.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum GraphSpan {
136    /// One sample per graph column, so the time covered grows with the
137    /// width and the interval.
138    #[default]
139    Fit,
140    /// The last this many seconds, spread over the width.
141    Secs(u64),
142}
143
144impl GraphSpan {
145    /// The choices [`GraphSpan::next`] steps through.
146    pub const CHOICES: [GraphSpan; 7] = [
147        GraphSpan::Fit,
148        GraphSpan::Secs(60),
149        GraphSpan::Secs(5 * 60),
150        GraphSpan::Secs(15 * 60),
151        GraphSpan::Secs(60 * 60),
152        GraphSpan::Secs(6 * 60 * 60),
153        GraphSpan::Secs(24 * 60 * 60),
154    ];
155
156    /// The choice after this one, back to the first after the last.
157    pub fn next(self) -> Self {
158        let index = Self::CHOICES
159            .iter()
160            .position(|c| *c == self)
161            .map_or(0, |i| i + 1);
162        Self::CHOICES[index % Self::CHOICES.len()]
163    }
164
165    /// Short name: "fit", "1m", "1h".
166    pub fn label(self) -> String {
167        match self {
168            GraphSpan::Fit => "fit".to_owned(),
169            GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
170            GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
171            GraphSpan::Secs(secs) => format!("{secs}s"),
172        }
173    }
174
175    /// Seconds covered, 0 for [`GraphSpan::Fit`] (for storing the choice).
176    pub fn as_secs(self) -> u64 {
177        match self {
178            GraphSpan::Fit => 0,
179            GraphSpan::Secs(secs) => secs,
180        }
181    }
182
183    /// The inverse of [`GraphSpan::as_secs`].
184    pub fn from_secs(secs: u64) -> Self {
185        if secs == 0 {
186            GraphSpan::Fit
187        } else {
188            GraphSpan::Secs(secs)
189        }
190    }
191}
192
193/// Columns the process table can be sorted by.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum SortKey {
196    Pid,
197    Name,
198    Threads,
199    User,
200    /// Container name; processes outside containers last.
201    Container,
202    Memory,
203    Cpu,
204    /// Disk reads plus writes.
205    Disk,
206    Gpu,
207    Vram,
208}
209
210impl SortKey {
211    /// Sort keys in column order, used to step left/right.
212    pub const ALL: [SortKey; 10] = [
213        SortKey::Pid,
214        SortKey::Name,
215        SortKey::Threads,
216        SortKey::User,
217        SortKey::Container,
218        SortKey::Memory,
219        SortKey::Cpu,
220        SortKey::Disk,
221        SortKey::Gpu,
222        SortKey::Vram,
223    ];
224
225    /// Whether "descending" is the natural order (largest consumers first).
226    pub fn descending_by_default(self) -> bool {
227        !matches!(
228            self,
229            SortKey::Pid | SortKey::Name | SortKey::User | SortKey::Container
230        )
231    }
232}
233
234/// One row of the process table: the process plus its GPU usage.
235#[derive(Debug, Clone)]
236pub struct ProcessRow<'a> {
237    pub info: &'a ProcessInfo,
238    /// Highest per-GPU SM utilization of the process.
239    pub gpu_percent: Option<f32>,
240    /// VRAM used by the process across all GPUs.
241    pub vram_bytes: Option<u64>,
242    /// In the tree view, the lines leading to the process ("│ ├ "), to be
243    /// put before its name; empty otherwise.
244    pub tree_prefix: String,
245}
246
247/// Graph histories of one host, updated from each snapshot.
248#[derive(Debug, Default, Clone)]
249pub struct MetricHistory {
250    /// Total CPU usage (%).
251    pub cpu: History,
252    /// Used memory (% of total).
253    pub memory: History,
254    /// Utilization (%) per GPU index.
255    pub gpus: Vec<History>,
256    /// Receive rate of `net_interface` (bytes/s).
257    pub net_rx: History,
258    /// Transmit rate of `net_interface` (bytes/s).
259    pub net_tx: History,
260    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
261    pub net_interface: Option<String>,
262}
263
264impl MetricHistory {
265    /// Appends the values of a new snapshot.
266    pub fn record(&mut self, snapshot: &Snapshot) {
267        self.cpu.push(snapshot.cpu.total_percent as f64);
268        let memory = &snapshot.memory;
269        if memory.total_bytes > 0 {
270            self.memory
271                .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
272        }
273        self.gpus.resize_with(snapshot.gpus.len(), History::default);
274        for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
275            history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
276        }
277        if self.net_interface.is_none() {
278            self.net_interface = default_interface(snapshot);
279        }
280        let (rx, tx) = self
281            .net_interface
282            .as_deref()
283            .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
284            .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
285            .unwrap_or((0.0, 0.0));
286        self.net_rx.push(rx);
287        self.net_tx.push(tx);
288    }
289
290    /// Switches the recorded network interface, clearing its history.
291    pub fn select_interface(&mut self, name: &str) {
292        if self.net_interface.as_deref() != Some(name) {
293            self.net_interface = Some(name.to_owned());
294            // The old samples belong to another interface.
295            self.net_rx = History::default();
296            self.net_tx = History::default();
297        }
298    }
299
300    /// Switches to the interface after the current one in `snapshot` and
301    /// returns its name.
302    pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
303        let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
304        if names.is_empty() {
305            return None;
306        }
307        let current = self
308            .net_interface
309            .as_deref()
310            .and_then(|name| names.iter().position(|n| *n == name));
311        let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
312        self.select_interface(&next);
313        Some(next)
314    }
315}
316
317/// How urgent an [`Alert`] is.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
319#[serde(rename_all = "lowercase")]
320pub enum Severity {
321    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
322    Warning,
323    /// Data at risk (errors, a missing device, a failing drive).
324    Critical,
325}
326
327/// A condition on a host that needs attention, for overviews.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Alert {
330    pub severity: Severity,
331    pub text: String,
332}
333
334/// Identifies an alert across samples: its severity and text with numbers
335/// masked, so "3 I/O errors" becoming "4 I/O errors" or a temperature
336/// changing does not count as a new alert.
337pub fn alert_key(alert: &Alert) -> String {
338    let mut key = format!("{:?}:", alert.severity);
339    let mut in_number = false;
340    for c in alert.text.chars() {
341        if c.is_ascii_digit() || (in_number && c == '.') {
342            if !in_number {
343                key.push('#');
344            }
345            in_number = true;
346        } else {
347            key.push(c);
348            in_number = false;
349        }
350    }
351    key
352}
353
354/// Whether an [`AlertEvent`] is an alert appearing or going away, or
355/// something that happened once.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "lowercase")]
358pub enum AlertChange {
359    Raised,
360    Cleared,
361    /// A one-off event, not a lasting condition (a watched process exited):
362    /// never cleared. Older rigtops skip these lines of a saved log.
363    Notice,
364}
365
366/// One entry of the [`AlertLog`].
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct AlertEvent {
369    /// When the change was seen, in milliseconds since the Unix epoch.
370    pub at_ms: u64,
371    pub host: String,
372    pub change: AlertChange,
373    /// The alert as raised, or as last seen before it cleared.
374    pub alert: Alert,
375}
376
377/// Events kept by an [`AlertLog`].
378const ALERT_LOG_LEN: usize = 500;
379
380/// The alerts of every host as they appear and clear, so that what
381/// happened while nobody watched can be looked up later.
382#[derive(Debug, Default)]
383pub struct AlertLog {
384    /// Newest last.
385    events: VecDeque<AlertEvent>,
386    /// Each host's alerts at its previous update.
387    active: HashMap<String, Vec<Alert>>,
388    /// Alerts raised since [`AlertLog::mark_read`], and the worst of them.
389    unread: (usize, Option<Severity>),
390    /// Where events are kept between runs (JSON lines), if anywhere.
391    file: Option<PathBuf>,
392}
393
394/// Where a front-end ("tui", "gui") keeps its alert history: the state
395/// directory (`~/.local/state/rigtop/` on Linux), else the local data
396/// directory (macOS, Windows).
397pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
398    dirs::state_dir()
399        .or_else(dirs::data_local_dir)
400        .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
401}
402
403impl AlertLog {
404    /// A log kept in `path`: the events of earlier runs are loaded, alerts
405    /// they left raised count as still present (so a restart does not log
406    /// them again), and new events are appended. A file that cannot be
407    /// read or written leaves the log working in memory.
408    pub fn persistent(path: PathBuf) -> Self {
409        let mut log = Self::default();
410        let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
411            .map(|text| {
412                text.lines()
413                    .filter_map(|line| serde_json::from_str(line).ok())
414                    .collect()
415            })
416            .unwrap_or_default();
417        let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
418        for event in loaded.iter().filter(|e| e.change != AlertChange::Notice) {
419            let active = log.active.entry(event.host.clone()).or_default();
420            let key = alert_key(&event.alert);
421            active.retain(|a| alert_key(a) != key);
422            if event.change == AlertChange::Raised {
423                active.push(event.alert.clone());
424            }
425        }
426        log.events = loaded.into_iter().skip(keep).collect();
427        // Rewrite a file grown well past what is shown.
428        if keep > ALERT_LOG_LEN {
429            let text: String = log
430                .events
431                .iter()
432                .filter_map(|e| serde_json::to_string(e).ok())
433                .map(|line| line + "\n")
434                .collect();
435            let tmp = path.with_extension("jsonl.tmp");
436            let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
437        }
438        log.file = Some(path);
439        log
440    }
441
442    /// Appends `event` to the file, if the log has one.
443    fn save(&self, event: &AlertEvent) {
444        let Some(path) = &self.file else {
445            return;
446        };
447        let Ok(line) = serde_json::to_string(event) else {
448            return;
449        };
450        if let Some(dir) = path.parent() {
451            let _ = fs::create_dir_all(dir);
452        }
453        // One write per line, so that lines from concurrent runs never mix.
454        let _ = OpenOptions::new()
455            .create(true)
456            .append(true)
457            .open(path)
458            .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
459    }
460
461    /// Records how the alerts of `host` changed since its previous update,
462    /// and returns those changes.
463    pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) -> Vec<AlertEvent> {
464        let previous = self.active.remove(host).unwrap_or_default();
465        let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
466        let (before, now) = (keys(&previous), keys(alerts));
467        let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
468        let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
469        let mut changes: Vec<(AlertChange, &Alert)> = raised
470            .map(|a| (AlertChange::Raised, a))
471            .chain(cleared.map(|a| (AlertChange::Cleared, a)))
472            .collect();
473        // Cleared first, so that a changed alert reads "went away, came back".
474        changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
475        let mut new_events = Vec::with_capacity(changes.len());
476        for (change, alert) in changes {
477            new_events.push(self.push(AlertEvent {
478                at_ms,
479                host: host.to_owned(),
480                change,
481                alert: alert.clone(),
482            }));
483        }
484        self.active.insert(host.to_owned(), alerts.to_vec());
485        new_events
486    }
487
488    /// [`AlertLog::update`] for `alerts` that leave out `snoozed` ones (see
489    /// [`crate::snooze`]): an alert snoozed while it was raised leaves
490    /// quietly instead of logging "cleared", and is raised again if it is
491    /// still there when the snooze ends.
492    pub fn update_snoozed(
493        &mut self,
494        host: &str,
495        alerts: &[Alert],
496        snoozed: &[Alert],
497        at_ms: u64,
498    ) -> Vec<AlertEvent> {
499        if let Some(previous) = self.active.get_mut(host) {
500            let quiet: HashSet<String> = snoozed.iter().map(alert_key).collect();
501            previous.retain(|a| !quiet.contains(&alert_key(a)));
502        }
503        self.update(host, alerts, at_ms)
504    }
505
506    /// Records a one-off event of `host` ([`AlertChange::Notice`]), such as
507    /// a watched process exiting, and returns it.
508    pub fn record(&mut self, host: &str, alert: Alert, at_ms: u64) -> AlertEvent {
509        self.push(AlertEvent {
510            at_ms,
511            host: host.to_owned(),
512            change: AlertChange::Notice,
513            alert,
514        })
515    }
516
517    /// Adds `event` to the log (and its file), counting it as unread unless
518    /// it is a clearing.
519    fn push(&mut self, event: AlertEvent) -> AlertEvent {
520        if event.change != AlertChange::Cleared {
521            self.unread.0 += 1;
522            self.unread.1 = self.unread.1.max(Some(event.alert.severity));
523        }
524        if self.events.len() == ALERT_LOG_LEN {
525            self.events.pop_front();
526        }
527        self.save(&event);
528        self.events.push_back(event.clone());
529        event
530    }
531
532    /// All events, oldest first.
533    pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
534        self.events.iter()
535    }
536
537    /// Alerts raised since the log was last read, and the most severe one.
538    pub fn unread(&self) -> (usize, Option<Severity>) {
539        self.unread
540    }
541
542    /// Marks every event as read.
543    pub fn mark_read(&mut self) {
544        self.unread = (0, None);
545    }
546}
547
548/// When an [`AlertEvent`] happened, as short as is clear: "14:03:12"
549/// today, "Tue 14:03" this week, "Oct 21 14:03" before.
550pub fn event_time(at_ms: u64, now: SystemTime) -> String {
551    let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
552    let today: DateTime<Local> = now.into();
553    let days = (today.date_naive() - at.date_naive()).num_days();
554    if days == 0 {
555        at.format("%H:%M:%S").to_string()
556    } else if (1..6).contains(&days) {
557        at.format("%a %H:%M").to_string()
558    } else {
559        at.format("%b %d %H:%M").to_string()
560    }
561}
562
563/// Whether `process` is one `pattern` names: its name, or the file name
564/// of a word of its command line (`train.py` for `python3 jobs/train.py`).
565pub fn process_matches(process: &ProcessInfo, pattern: &str) -> bool {
566    let pattern = pattern.trim();
567    !pattern.is_empty()
568        && (process.name == pattern
569            || process
570                .command
571                .split_whitespace()
572                .any(|word| word == pattern || word.rsplit(['/', '\\']).next() == Some(pattern)))
573}
574
575/// A process to announce when it exits.
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct WatchedProcess {
578    pub pid: u32,
579    /// Its name when the watch began; another name under the same pid
580    /// means it exited and the pid was reused.
581    pub name: String,
582    /// What the exit names it by, when not `name` (e.g. the script).
583    pub label: Option<String>,
584    /// When it started (milliseconds since the Unix epoch, by the host's
585    /// run time).
586    pub started_ms: u64,
587}
588
589/// What is known about a [`WatchedProcess`] from a snapshot.
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591pub enum WatchState {
592    Running,
593    Exited,
594    /// The source does not report it (yet): an agent before 0.19, or the
595    /// request has not reached the source.
596    Unknown,
597}
598
599/// The processes of one host to announce when they exit. The front-end
600/// passes [`ExitWatch::pids`] to the source
601/// ([`crate::DataSource::watch_processes`]), which then keeps them in
602/// snapshots whatever the process limit.
603#[derive(Debug, Clone, Default)]
604pub struct ExitWatch {
605    processes: Vec<WatchedProcess>,
606}
607
608impl ExitWatch {
609    /// Whether `pid` is watched.
610    pub fn contains(&self, pid: u32) -> bool {
611        self.processes.iter().any(|w| w.pid == pid)
612    }
613
614    /// Starts or stops watching `process`; true when it is now watched.
615    pub fn toggle(&mut self, process: &ProcessInfo, now_ms: u64) -> bool {
616        if self.contains(process.pid) {
617            self.processes.retain(|w| w.pid != process.pid);
618            return false;
619        }
620        self.watch_as(process, None, now_ms);
621        true
622    }
623
624    /// Starts watching `process` unless it is watched already, naming it
625    /// `label` when it exits. True when it was not watched before.
626    pub fn watch_as(&mut self, process: &ProcessInfo, label: Option<&str>, now_ms: u64) -> bool {
627        if self.contains(process.pid) {
628            return false;
629        }
630        self.processes.push(WatchedProcess {
631            pid: process.pid,
632            name: process.name.clone(),
633            label: label.map(str::to_owned),
634            started_ms: now_ms.saturating_sub(process.run_time_secs * 1000),
635        });
636        true
637    }
638
639    /// The watched pids, for the source.
640    pub fn pids(&self) -> Vec<u32> {
641        self.processes.iter().map(|w| w.pid).collect()
642    }
643
644    /// What `snapshot` says about the watched process `pid`.
645    pub fn state(&self, snapshot: &Snapshot, pid: u32) -> WatchState {
646        match self.processes.iter().find(|w| w.pid == pid) {
647            Some(watched) => watch_state(snapshot, watched),
648            None => WatchState::Unknown,
649        }
650    }
651
652    /// Stops watching the processes that exited by `snapshot` and returns
653    /// an alert for each: "train.py (pid 1234) exited after 3h12m".
654    pub fn check(&mut self, snapshot: &Snapshot, now_ms: u64) -> Vec<Alert> {
655        let mut exited = Vec::new();
656        self.processes.retain(|watched| {
657            if watch_state(snapshot, watched) != WatchState::Exited {
658                return true;
659            }
660            let ran = now_ms.saturating_sub(watched.started_ms) / 1000;
661            exited.push(Alert {
662                severity: Severity::Warning,
663                text: format!(
664                    "{} (pid {}) exited after {}",
665                    watched.label.as_deref().unwrap_or(&watched.name),
666                    watched.pid,
667                    format::duration(ran)
668                ),
669            });
670            false
671        });
672        exited
673    }
674}
675
676/// Whether `watched` still runs according to `snapshot`. Running only
677/// when the source reports it, since only then will its exit be seen.
678fn watch_state(snapshot: &Snapshot, watched: &WatchedProcess) -> WatchState {
679    let reported = snapshot.watched_pids.contains(&watched.pid);
680    match snapshot.processes.iter().find(|p| p.pid == watched.pid) {
681        // The pid now belongs to another program.
682        Some(process) if process.name != watched.name => WatchState::Exited,
683        Some(_) if reported => WatchState::Running,
684        None if reported => WatchState::Exited,
685        _ => WatchState::Unknown,
686    }
687}
688
689/// What a process detail view shows, as label and value rows: who started
690/// it, who runs it, since when, what it uses, and its whole command line.
691/// `None` once the process is gone.
692pub fn process_details(
693    snapshot: &Snapshot,
694    pid: u32,
695    now: SystemTime,
696) -> Option<Vec<(&'static str, String)>> {
697    let by_pid: HashMap<u32, &ProcessInfo> =
698        snapshot.processes.iter().map(|p| (p.pid, p)).collect();
699    let process = by_pid.get(&pid)?;
700    let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
701
702    // Parents up to the first process, oldest first; bounded in case of a
703    // loop in inconsistent data.
704    let mut chain = Vec::new();
705    let mut parent = process.parent_pid;
706    while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
707        match by_pid.get(&ppid) {
708            Some(p) => {
709                chain.push(format!("{} ({})", p.name, p.pid));
710                parent = p.parent_pid.filter(|&next| next != p.pid);
711            }
712            None => {
713                chain.push(ppid.to_string());
714                break;
715            }
716        }
717    }
718    if !chain.is_empty() {
719        chain.reverse();
720        rows.push(("Parents", chain.join(" › ")));
721    }
722    let children = snapshot
723        .processes
724        .iter()
725        .filter(|p| p.parent_pid == Some(pid))
726        .count();
727    if children > 0 {
728        rows.push(("Children", children.to_string()));
729    }
730    rows.push(("User", process.user.clone()));
731    if let Some(container) = &process.container {
732        rows.push(("Container", container.clone()));
733    }
734    if !process.state.is_empty() {
735        rows.push(("State", process.state.clone()));
736    }
737    if let Some(nice) = process.nice {
738        rows.push(("Nice", nice.to_string()));
739    }
740    let started = now - Duration::from_secs(process.run_time_secs);
741    rows.push(("Running", since_note(started, now)));
742    rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
743    rows.push(("Memory", format::bytes(process.memory_bytes)));
744    if let Some(threads) = process.threads {
745        rows.push(("Threads", threads.to_string()));
746    }
747    rows.push((
748        "Disk",
749        format!(
750            "read {}, write {}",
751            format::rate(process.read_bytes_per_sec),
752            format::rate(process.write_bytes_per_sec)
753        ),
754    ));
755    for gpu in &snapshot.gpus {
756        if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
757            let mut text = format!("GPU {}", gpu.index);
758            if let Some(sm) = usage.sm_percent {
759                text.push_str(&format!(" {sm:.0}%"));
760            }
761            if let Some(memory) = usage.memory_bytes {
762                text.push_str(&format!(", {} VRAM", format::bytes(memory)));
763            }
764            rows.push(("GPU", text));
765        }
766    }
767    let command = if process.command.is_empty() {
768        format!("[{}]", process.name)
769    } else {
770        process.command.clone()
771    };
772    rows.push(("Command", command));
773    Some(rows)
774}
775
776/// Where a host goes in an overview sorted by attention: 0 for one that is
777/// unreachable or has a critical alert, 1 for one with stale data or a
778/// warning, 2 for the rest.
779pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
780    let worst = alerts.iter().map(|a| a.severity).max();
781    if unreachable || worst == Some(Severity::Critical) {
782        0
783    } else if stale || worst == Some(Severity::Warning) {
784        1
785    } else {
786        2
787    }
788}
789
790/// The CPU clock as "1.9/5.7GHz": the average over the cores, then the
791/// fastest core (idle cores sit low and pull the average down, so the
792/// fastest one tells whether the CPU boosts). `None` without readings.
793pub fn cpu_clock_text(cpu: &CpuStats) -> Option<String> {
794    let cores: Vec<u64> = cpu
795        .per_core_mhz
796        .iter()
797        .copied()
798        .filter(|&m| m > 0)
799        .collect();
800    let max = *cores.iter().max()?;
801    let average = cores.iter().sum::<u64>() as f64 / cores.len() as f64;
802    Some(format!(
803        "{:.1}/{:.1}GHz",
804        average / 1000.0,
805        max as f64 / 1000.0
806    ))
807}
808
809/// What [`cpu_clock_text`] shows, for a tooltip.
810pub const CPU_CLOCK_HINT: &str = "average over the cores / fastest core";
811
812/// The addresses line of the network panel for interface `net`: its IPv4
813/// addresses, the public one, then the first IPv6 address with how many
814/// more there are (SLAAC and temporary addresses add up):
815/// "192.168.1.10/24 · public 203.0.113.7 · 2001:db8::10/64 +2". `None`
816/// when there is nothing to show. [`NetworkStats::addresses`] has them all.
817pub fn address_text(net: Option<&NetworkStats>, public_ip: Option<&str>) -> Option<String> {
818    let addresses = net.map_or(&[][..], |n| n.addresses.as_slice());
819    let (v6, v4): (Vec<&String>, Vec<&String>) = addresses.iter().partition(|a| a.contains(':'));
820    let mut parts: Vec<String> = v4.into_iter().cloned().collect();
821    if let Some(public) = public_ip {
822        parts.push(format!("public {public}"));
823    }
824    if let Some(first) = v6.first() {
825        parts.push(match v6.len() - 1 {
826            0 => (*first).clone(),
827            more => format!("{first} +{more}"),
828        });
829    }
830    (!parts.is_empty()).then(|| parts.join(" · "))
831}
832
833/// Whether `gpu` is an integrated GPU on a host that also has a discrete
834/// one: then it mostly sits idle or draws the desktop, and the discrete
835/// GPU is the one worth watching.
836pub fn is_secondary_gpu(snapshot: &Snapshot, gpu: &GpuStats) -> bool {
837    gpu.integrated && snapshot.gpus.iter().any(|g| !g.integrated)
838}
839
840/// How a GPU is drawn in the host view's GPU panel.
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum GpuRow {
843    /// Name, meters and history graph.
844    Full,
845    /// A single line.
846    Compact,
847}
848
849/// The GPUs of the host view's GPU panel, each with its position in
850/// `snapshot.gpus` (the index of its history) and how it is drawn:
851/// integrated GPUs next to a discrete one as `mode` says.
852pub fn panel_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> Vec<(usize, &GpuStats, GpuRow)> {
853    snapshot
854        .gpus
855        .iter()
856        .enumerate()
857        .filter_map(|(i, gpu)| {
858            let row = match (is_secondary_gpu(snapshot, gpu), mode) {
859                (false, _) | (true, IntegratedGpu::Full) => GpuRow::Full,
860                (true, IntegratedGpu::Compact) => GpuRow::Compact,
861                (true, IntegratedGpu::Hide) => return None,
862            };
863            Some((i, gpu, row))
864        })
865        .collect()
866}
867
868/// The GPUs worth a line on an overview card: those with a utilization or
869/// memory reading (an Intel iGPU, whose utilization needs perf, would only
870/// say "--%"), and no integrated GPU next to a discrete one unless `mode`
871/// is [`IntegratedGpu::Full`].
872pub fn card_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> impl Iterator<Item = &GpuStats> {
873    snapshot
874        .gpus
875        .iter()
876        .filter(|g| g.utilization_percent.is_some() || g.memory_used_bytes.is_some())
877        .filter(move |g| mode == IntegratedGpu::Full || !is_secondary_gpu(snapshot, g))
878}
879
880/// A GPU's name with its power draw, for a card's detail line:
881/// "NVIDIA GeForce RTX 4090 · 52/450W".
882pub fn gpu_detail(gpu: &GpuStats) -> String {
883    let mut text = gpu.name.clone();
884    match (gpu.power_w, gpu.power_limit_w) {
885        (Some(w), Some(limit)) => text.push_str(&format!(" · {w:.0}/{limit:.0}W")),
886        (Some(w), None) => text.push_str(&format!(" · {w:.0}W")),
887        _ => {}
888    }
889    text
890}
891
892/// Short notes for a GPU's title line, with how much attention each needs
893/// (`None`: just information): running at its power limit, slowed down by
894/// the hardware, ECC memory errors.
895pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
896    let mut notes = Vec::new();
897    let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
898    if !slowdown.is_empty() {
899        notes.push((
900            format!("slowed: {}", slowdown.join(", ")),
901            Some(Severity::Warning),
902        ));
903    } else if !gpu.throttle_reasons.is_empty() {
904        notes.push(("power cap".to_owned(), None));
905    }
906    if let Some(ecc) = gpu.ecc {
907        if ecc.uncorrected > 0 {
908            notes.push((
909                format!("ECC {} uncorrected", ecc.uncorrected),
910                Some(Severity::Critical),
911            ));
912        } else if ecc.corrected > 0 {
913            notes.push((format!("ECC {} corrected", ecc.corrected), None));
914        }
915    }
916    notes
917}
918
919/// A GPU's PCIe link for a tooltip: "PCIe gen 4 x8 (up to gen 4 x16)".
920pub fn pcie_text(link: &PcieLink) -> String {
921    let now = format!("PCIe gen {} x{}", link.generation, link.width);
922    if (link.generation, link.width) == (link.max_generation, link.max_width) {
923        now
924    } else {
925        format!(
926            "{now} (up to gen {} x{}; the link slows down when idle)",
927            link.max_generation, link.max_width
928        )
929    }
930}
931
932/// How long it has been since `since` (e.g. a host's last data) and when
933/// that was, in local time: "5m (since 14:03)", "2d03h (since Tue 14:03)".
934pub fn since_note(since: SystemTime, now: SystemTime) -> String {
935    let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
936    let span = match secs {
937        0..60 => format!("{secs}s"),
938        60..3_600 => format!("{}m", secs / 60),
939        3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
940        _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
941    };
942    let at: DateTime<Local> = since.into();
943    let today: DateTime<Local> = now.into();
944    let clock = if at.date_naive() == today.date_naive() {
945        at.format("%H:%M")
946    } else if secs < 6 * 86_400 {
947        at.format("%a %H:%M")
948    } else {
949        at.format("%b %d %H:%M")
950    };
951    format!("{span} (since {clock})")
952}
953
954/// "agent 0.8.2" when `snapshot` comes from a rigtop of another version
955/// than this one (an agent not updated yet), else `None`.
956pub fn version_note(snapshot: &Snapshot) -> Option<String> {
957    let version = &snapshot.host.rigtop_version;
958    (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
959        .then(|| format!("agent {version}"))
960}
961
962/// Hover text explaining a [`version_note`].
963pub fn version_hint(snapshot: &Snapshot) -> String {
964    format!(
965        "this host runs rigtop {}, this machine {}: install the same version on both \
966         for every reading",
967        snapshot.host.rigtop_version,
968        env!("CARGO_PKG_VERSION")
969    )
970}
971
972/// Shown next to the uptime of a host that needs a reboot to finish an
973/// update ([`crate::snapshot::HostInfo::reboot_required`]).
974pub const REBOOT_NOTE: &str = "reboot needed";
975
976/// Hover text for [`REBOOT_NOTE`], with the reason when known.
977pub fn reboot_hint(reason: &str) -> String {
978    let mut hint = "a reboot is needed to finish an update".to_owned();
979    if !reason.is_empty() {
980        hint.push_str(&format!(" ({reason})"));
981    }
982    hint
983}
984
985/// How a disk's request time looks: yellow from 100 ms, red from 500 ms
986/// (an SSD answers in well under a millisecond, a busy hard disk in tens).
987pub fn latency_severity(ms: f32) -> Option<Severity> {
988    if ms >= 500.0 {
989        Some(Severity::Critical)
990    } else if ms >= 100.0 {
991        Some(Severity::Warning)
992    } else {
993        None
994    }
995}
996
997/// Round trips at least this long (milliseconds) are shown as slow.
998pub const SLOW_LINK_MS: f32 = 500.0;
999
1000/// The round trip of a remote host's link, for its title ("12ms"), and
1001/// whether it is slow.
1002pub fn link_rtt_text(snapshot: &Snapshot) -> Option<(String, bool)> {
1003    let ms = snapshot.link_rtt_ms?;
1004    Some((format::latency(ms), ms >= SLOW_LINK_MS))
1005}
1006
1007/// Hover text for [`link_rtt_text`].
1008pub const LINK_RTT_HINT: &str = "round trip to the agent over SSH (every 5 s)";
1009
1010/// The "42.1%" of a running scan's "42.12% done".
1011fn scan_percent(scan: &str) -> Option<String> {
1012    let done = scan.find("% done")?;
1013    let start = scan[..done].rfind(' ').map_or(0, |i| i + 1);
1014    let value: f32 = scan[start..done].parse().ok()?;
1015    Some(format!("{value:.1}%"))
1016}
1017
1018/// A ZFS pool's `scan:` line made short, with how much attention it needs:
1019/// "scrub Oct 31, repaired 27.7M" (warning), "scrub Oct 31, 442 errors"
1020/// (critical), "resilver 42.1% done", "no scrub yet" (warning).
1021pub fn zfs_scan_summary(scan: &str) -> (String, Option<Severity>) {
1022    // "... on Sat Oct 31 19:33:41 2026" -> "Oct 31".
1023    let date = scan.rsplit_once(" on ").map(|(_, when)| {
1024        let words: Vec<&str> = when.split_whitespace().collect();
1025        match words.as_slice() {
1026            [_, month, day, ..] => format!("{month} {day}"),
1027            _ => when.to_owned(),
1028        }
1029    });
1030    let errors = scan
1031        .split_once(" with ")
1032        .and_then(|(_, rest)| rest.split_whitespace().next()?.parse::<u64>().ok())
1033        .unwrap_or(0);
1034    let repaired = scan
1035        .split_once("repaired ")
1036        .and_then(|(_, rest)| rest.split_whitespace().next())
1037        .filter(|amount| *amount != "0B");
1038    if scan.starts_with("none requested") {
1039        return ("no scrub yet".to_owned(), Some(Severity::Warning));
1040    }
1041    if scan.contains("in progress") || scan.starts_with("scrub paused") {
1042        let what = scan.split_whitespace().next().unwrap_or("scan");
1043        let state = if scan.starts_with("scrub paused") {
1044            "paused"
1045        } else {
1046            "running"
1047        };
1048        let done = scan_percent(scan).map_or(String::new(), |p| format!(" {p}"));
1049        let severity = (what == "resilver").then_some(Severity::Warning);
1050        return (format!("{what} {state}{done}"), severity);
1051    }
1052    let what = if scan.starts_with("resilvered") {
1053        "resilver"
1054    } else {
1055        "scrub"
1056    };
1057    let mut text = match &date {
1058        Some(date) => format!("{what} {date}"),
1059        None => what.to_owned(),
1060    };
1061    let severity = if errors > 0 {
1062        text.push_str(&format!(", {errors} errors"));
1063        Some(Severity::Critical)
1064    } else if let (Some(amount), "scrub") = (repaired, what) {
1065        text.push_str(&format!(", repaired {amount}"));
1066        Some(Severity::Warning)
1067    } else {
1068        text.push_str(", ok");
1069        None
1070    };
1071    (text, severity)
1072}
1073
1074/// Label before the pressure readings ("Stall cpu 3% mem 0% io 6%").
1075pub const PRESSURE_LABEL: &str = "Stall";
1076
1077/// One pressure reading for display: the share of the last 10 seconds in
1078/// which some task waited for the resource.
1079#[derive(Debug, Clone, PartialEq)]
1080pub struct PressureItem {
1081    /// "cpu", "mem" or "io".
1082    pub name: &'static str,
1083    /// e.g. "6%".
1084    pub value: String,
1085    /// Its alert threshold is reached.
1086    pub warn: bool,
1087}
1088
1089/// The readings shown next to the load average: those of at least 1%, or
1090/// raising an alert under `limits` (marked). Empty while nothing waits.
1091pub fn pressure_items(pressure: &Pressure, limits: &AlertsConfig) -> Vec<PressureItem> {
1092    let over = |value: f32, limit: f64| limit > 0.0 && value as f64 >= limit;
1093    let item = |name, value: f32, warn| PressureItem {
1094        name,
1095        value: format!("{value:.0}%"),
1096        warn,
1097    };
1098    let items = [
1099        item("cpu", pressure.cpu.some_avg10, false),
1100        item(
1101            "mem",
1102            pressure.memory.some_avg10,
1103            over(
1104                pressure.memory.some_avg60,
1105                limits.memory_pressure_warning_percent,
1106            ),
1107        ),
1108        item(
1109            "io",
1110            pressure.io.some_avg10,
1111            over(pressure.io.full_avg60, limits.io_pressure_warning_percent),
1112        ),
1113    ];
1114    items
1115        .into_iter()
1116        .filter(|item| item.warn || item.value != "0%")
1117        .collect()
1118}
1119
1120/// Hover text for the pressure readings: what they mean, with the "full"
1121/// figures and the minute averages.
1122pub fn pressure_details(pressure: &Pressure) -> String {
1123    let line = |name: &str, levels: &crate::snapshot::PressureLevels| {
1124        format!(
1125            "{name}: some {:.0}% / {:.0}%, full {:.0}% / {:.0}%",
1126            levels.some_avg10, levels.some_avg60, levels.full_avg10, levels.full_avg60
1127        )
1128    };
1129    [
1130        "Share of time tasks waited (10 s / 60 s averages); some: at least one \
1131         task, full: all of them"
1132            .to_owned(),
1133        line("cpu", &pressure.cpu),
1134        line("memory", &pressure.memory),
1135        line("io", &pressure.io),
1136    ]
1137    .join("\n")
1138}
1139
1140/// [`alerts_with`] the default thresholds.
1141pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
1142    alerts_with(snapshot, &AlertsConfig::default())
1143}
1144
1145/// Conditions in `snapshot` that need attention, most severe first:
1146/// storage errors and failing drives, degraded RAID, full filesystems, OOM
1147/// kills, full memory, hot CPUs, GPUs and drives, judged by `limits` (0
1148/// turns a threshold off).
1149pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
1150    // A threshold of 0 (or less) is turned off.
1151    let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
1152    let mut alerts = Vec::new();
1153    let mut add = |severity, text: String| alerts.push(Alert { severity, text });
1154    for fs in &snapshot.raid.btrfs {
1155        let name = fs
1156            .mount_points
1157            .first()
1158            .map_or(fs.fsid.as_str(), String::as_str);
1159        let missing = fs.device_stats.iter().filter(|d| d.missing).count();
1160        if missing > 0 {
1161            add(
1162                Severity::Critical,
1163                format!("btrfs {name}: {missing} device(s) missing"),
1164            );
1165        }
1166        let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
1167        if errors > 0 {
1168            // Which drives: the one to replace.
1169            let devices: Vec<String> = fs
1170                .device_stats
1171                .iter()
1172                .filter(|d| d.total_errors() > 0)
1173                .map(|d| d.label())
1174                .collect();
1175            add(
1176                Severity::Critical,
1177                format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
1178            );
1179        }
1180        if let Some(scrub) = &fs.scrub {
1181            if scrub.uncorrectable > 0 {
1182                add(
1183                    Severity::Critical,
1184                    format!(
1185                        "btrfs {name}: scrub found {} uncorrectable errors",
1186                        scrub.uncorrectable
1187                    ),
1188                );
1189            } else if scrub.errors > 0 {
1190                add(
1191                    Severity::Warning,
1192                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
1193                );
1194            }
1195        }
1196    }
1197    for md in &snapshot.raid.md {
1198        if md.active && md.is_degraded() {
1199            add(
1200                Severity::Critical,
1201                format!("{} ({}) degraded", md.name, md.level),
1202            );
1203        } else if !md.active {
1204            add(Severity::Warning, format!("{} inactive", md.name));
1205        }
1206        if let Some(sync) = &md.sync {
1207            add(
1208                Severity::Warning,
1209                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
1210            );
1211        }
1212    }
1213    for pool in &snapshot.raid.zfs {
1214        if pool.is_failing() {
1215            add(
1216                Severity::Critical,
1217                format!("zfs {} {}", pool.name, pool.state),
1218            );
1219        } else if !pool.is_online() {
1220            add(
1221                Severity::Warning,
1222                format!("zfs {} {}", pool.name, pool.state),
1223            );
1224        }
1225        if let Some(errors) = pool.data_errors.filter(|e| *e > 0) {
1226            add(
1227                Severity::Critical,
1228                format!("zfs {}: {errors} data errors", pool.name),
1229            );
1230        }
1231        for device in pool.troubled_devices() {
1232            let text = if device.is_unhealthy() {
1233                format!("zfs {}: {} {}", pool.name, device.name, device.state)
1234            } else {
1235                format!(
1236                    "zfs {}: {} has {} errors",
1237                    pool.name,
1238                    device.name,
1239                    device.errors()
1240                )
1241            };
1242            add(Severity::Warning, text);
1243        }
1244        if let Some(scan) = &pool.scan
1245            && scan.starts_with("resilver in progress")
1246        {
1247            let done = scan_percent(scan).unwrap_or_default();
1248            add(
1249                Severity::Warning,
1250                format!("zfs {} resilver {done}", pool.name),
1251            );
1252        }
1253    }
1254    for unit in &snapshot.failed_units {
1255        if !limits.ignored_units.contains(unit) {
1256            add(Severity::Warning, format!("systemd: {unit} failed"));
1257        }
1258    }
1259    for smart in &snapshot.smart {
1260        if let Some(error) = &smart.error {
1261            add(
1262                Severity::Warning,
1263                format!("{}: SMART unreadable ({error})", smart.device),
1264            );
1265        } else if smart.passed == Some(false) {
1266            add(
1267                Severity::Critical,
1268                format!("{}: SMART failed", smart.device),
1269            );
1270        } else if smart.needs_attention() {
1271            add(
1272                Severity::Critical,
1273                format!("{}: SMART warning", smart.device),
1274            );
1275        }
1276    }
1277    for fs in &snapshot.filesystems {
1278        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
1279        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
1280        let name = fs
1281            .mount_points
1282            .first()
1283            .map_or(fs.device.as_str(), String::as_str);
1284        if reached(percent, limits.filesystem_critical_percent) {
1285            add(Severity::Critical, format!("{name} {percent:.0}% full"));
1286        } else if reached(percent, limits.filesystem_warning_percent) {
1287            add(Severity::Warning, format!("{name} {percent:.0}% full"));
1288        }
1289        let soon = limits.filesystem_full_warning_hours * 3600.0;
1290        if let Some(secs) = fs.full_in_secs
1291            && soon > 0.0
1292            && secs as f64 <= soon
1293        {
1294            add(
1295                Severity::Warning,
1296                format!("{name} full in ~{}", format::duration(secs)),
1297            );
1298        }
1299    }
1300    if let Some(text) = oom_alert(&snapshot.memory.oom_kills) {
1301        add(Severity::Critical, text);
1302    }
1303    let memory = &snapshot.memory;
1304    let percent = memory.used_bytes as f64 / memory.total_bytes.max(1) as f64 * 100.0;
1305    if memory.total_bytes > 0 && reached(percent, limits.memory_warning_percent) {
1306        add(Severity::Warning, format!("memory {percent:.0}% used"));
1307    }
1308    if let Some(pressure) = &snapshot.pressure {
1309        let memory = pressure.memory.some_avg60 as f64;
1310        if reached(memory, limits.memory_pressure_warning_percent) {
1311            add(Severity::Warning, format!("memory pressure {memory:.0}%"));
1312        }
1313        let io = pressure.io.full_avg60 as f64;
1314        if reached(io, limits.io_pressure_warning_percent) {
1315            add(Severity::Warning, format!("I/O pressure {io:.0}%"));
1316        }
1317    }
1318    let hottest_cpu = snapshot
1319        .cpu
1320        .temperatures
1321        .iter()
1322        .map(|t| t.celsius)
1323        .chain(snapshot.cpu.per_core_celsius.iter().flatten().copied())
1324        .fold(f32::NAN, f32::max);
1325    if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
1326        add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
1327    }
1328    for gpu in &snapshot.gpus {
1329        if let Some(t) = gpu
1330            .temperature_c
1331            .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
1332        {
1333            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
1334        }
1335        if let (Some(used), Some(total)) = (gpu.memory_used_bytes, gpu.memory_total_bytes)
1336            && total > 0
1337        {
1338            let percent = used as f64 / total as f64 * 100.0;
1339            if reached(percent, limits.gpu_memory_warning_percent) {
1340                add(
1341                    Severity::Warning,
1342                    format!("GPU {} memory {percent:.0}% used", gpu.index),
1343                );
1344            }
1345        }
1346        let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
1347        if !slowdown.is_empty() {
1348            add(
1349                Severity::Warning,
1350                format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
1351            );
1352        }
1353        if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
1354            add(
1355                Severity::Critical,
1356                format!(
1357                    "GPU {}: {} uncorrected ECC errors",
1358                    gpu.index, ecc.uncorrected
1359                ),
1360            );
1361        }
1362    }
1363    for (device, celsius) in drive_temperatures(snapshot) {
1364        if reached(celsius as f64, limits.drive_warning_celsius as f64) {
1365            add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
1366        }
1367    }
1368    // Stable sort keeps the order above within each severity.
1369    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
1370    alerts
1371}
1372
1373/// The alert for processes the OOM killer stopped: the likely ones by
1374/// name ("OOM killer stopped python3 (likely)"), so that another victim is
1375/// a new alert.
1376fn oom_alert(kills: &[OomKill]) -> Option<String> {
1377    let mut names: Vec<&str> = Vec::new();
1378    for name in kills
1379        .iter()
1380        .rev()
1381        .filter_map(|k| k.likely_process.as_deref())
1382    {
1383        if !names.contains(&name) {
1384            names.push(name);
1385        }
1386    }
1387    let likely = if names.is_empty() {
1388        String::new()
1389    } else {
1390        format!(" (likely {})", names.join(", "))
1391    };
1392    match kills.len() {
1393        0 => None,
1394        1 => Some(match names.first() {
1395            Some(name) => format!("OOM killer stopped {name} (likely)"),
1396            None => "OOM killer stopped a process".to_owned(),
1397        }),
1398        n => Some(format!("OOM killer stopped {n} processes{likely}")),
1399    }
1400}
1401
1402/// (device, °C) of each drive: SMART results, then NVMe drives without
1403/// them from their hwmon sensor ("Composite", the drive's own summary).
1404fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
1405    let mut drives: Vec<(String, f32)> = snapshot
1406        .smart
1407        .iter()
1408        .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
1409        .collect();
1410    for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
1411        // The chip key is "nvme:<device>".
1412        let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
1413        if drives.iter().any(|(d, _)| d == device) {
1414            continue;
1415        }
1416        let composite = chip
1417            .temperatures
1418            .iter()
1419            .find(|t| t.label == "Composite")
1420            .or(chip.temperatures.first());
1421        if let Some(t) = composite {
1422            drives.push((device.to_owned(), t.celsius));
1423        }
1424    }
1425    drives
1426}
1427
1428/// pid -> (highest SM %, total VRAM) across the GPUs.
1429fn gpu_usage_by_pid(snapshot: &Snapshot) -> HashMap<u32, (Option<f32>, Option<u64>)> {
1430    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
1431    for gpu in &snapshot.gpus {
1432        for p in &gpu.processes {
1433            let entry = gpu_usage.entry(p.pid).or_default();
1434            if let Some(sm) = p.sm_percent {
1435                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
1436            }
1437            if let Some(mem) = p.memory_bytes {
1438                entry.1 = Some(entry.1.unwrap_or(0) + mem);
1439            }
1440        }
1441    }
1442    gpu_usage
1443}
1444
1445/// What the processes of one container use together.
1446#[derive(Debug, Clone, PartialEq)]
1447pub struct ContainerUsage {
1448    pub name: String,
1449    pub processes: usize,
1450    pub cpu_percent: f32,
1451    pub memory_bytes: u64,
1452    /// Summed over its processes, where any uses a GPU.
1453    pub gpu_percent: Option<f32>,
1454    pub vram_bytes: Option<u64>,
1455}
1456
1457/// The containers of `snapshot`'s processes with what they use, the
1458/// busiest (CPU, then memory) first. Empty when no process runs in one.
1459pub fn container_usage(snapshot: &Snapshot) -> Vec<ContainerUsage> {
1460    let gpu_usage = gpu_usage_by_pid(snapshot);
1461    let mut by_name: HashMap<&str, ContainerUsage> = HashMap::new();
1462    for process in &snapshot.processes {
1463        let Some(name) = process.container.as_deref() else {
1464            continue;
1465        };
1466        let usage = by_name.entry(name).or_insert_with(|| ContainerUsage {
1467            name: name.to_owned(),
1468            processes: 0,
1469            cpu_percent: 0.0,
1470            memory_bytes: 0,
1471            gpu_percent: None,
1472            vram_bytes: None,
1473        });
1474        usage.processes += 1;
1475        usage.cpu_percent += process.cpu_percent;
1476        usage.memory_bytes += process.memory_bytes;
1477        if let Some((gpu, vram)) = gpu_usage.get(&process.pid) {
1478            if let Some(gpu) = gpu {
1479                usage.gpu_percent = Some(usage.gpu_percent.unwrap_or(0.0) + gpu);
1480            }
1481            if let Some(vram) = vram {
1482                usage.vram_bytes = Some(usage.vram_bytes.unwrap_or(0) + vram);
1483            }
1484        }
1485    }
1486    let mut list: Vec<ContainerUsage> = by_name.into_values().collect();
1487    list.sort_by(|a, b| {
1488        b.cpu_percent
1489            .total_cmp(&a.cpu_percent)
1490            .then(b.memory_bytes.cmp(&a.memory_bytes))
1491            .then(a.name.cmp(&b.name))
1492    });
1493    list
1494}
1495
1496/// How the process table is filtered and sorted.
1497#[derive(Debug, Clone, Copy)]
1498pub struct ProcessQuery<'q> {
1499    /// Column to sort by.
1500    pub sort_key: SortKey,
1501    /// Largest first when true.
1502    pub descending: bool,
1503    /// Case-insensitive substring of name, command or user, or an exact pid.
1504    pub filter: &'q str,
1505    /// Include kernel threads (`kworker` and friends).
1506    pub show_kernel_threads: bool,
1507    /// Children under their parents, siblings sorted by `sort_key`. A
1508    /// filter keeps the ancestors of what it matches.
1509    pub tree: bool,
1510}
1511
1512/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
1513pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
1514    let gpu_usage = gpu_usage_by_pid(snapshot);
1515
1516    let filter = query.filter.to_lowercase();
1517    let matches = |p: &ProcessInfo| {
1518        filter.is_empty()
1519            || p.name.to_lowercase().contains(&filter)
1520            || p.command.to_lowercase().contains(&filter)
1521            || p.user.to_lowercase().contains(&filter)
1522            || p.container
1523                .as_ref()
1524                .is_some_and(|c| c.to_lowercase().contains(&filter))
1525            || p.pid.to_string() == filter
1526    };
1527    let candidates = snapshot
1528        .processes
1529        .iter()
1530        .filter(|p| query.show_kernel_threads || !p.kernel_thread);
1531    let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
1532        with_ancestors(candidates.collect(), matches)
1533    } else {
1534        candidates.filter(|p| matches(p)).collect()
1535    };
1536    let mut rows: Vec<ProcessRow<'a>> = shown
1537        .into_iter()
1538        .map(|info| {
1539            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
1540            ProcessRow {
1541                info,
1542                gpu_percent,
1543                vram_bytes,
1544                tree_prefix: String::new(),
1545            }
1546        })
1547        .collect();
1548
1549    let key = query.sort_key;
1550    rows.sort_by(|a, b| {
1551        let ordering = match key {
1552            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
1553            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
1554            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
1555            SortKey::User => a.info.user.cmp(&b.info.user),
1556            SortKey::Container => match (&a.info.container, &b.info.container) {
1557                (Some(a), Some(b)) => a.cmp(b),
1558                (Some(_), None) => std::cmp::Ordering::Less,
1559                (None, Some(_)) => std::cmp::Ordering::Greater,
1560                (None, None) => std::cmp::Ordering::Equal,
1561            },
1562            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
1563            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
1564            SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
1565            SortKey::Gpu => a
1566                .gpu_percent
1567                .unwrap_or(-1.0)
1568                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
1569            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
1570        }
1571        // Stable tie-breaker so rows do not jump between frames.
1572        .then(a.info.pid.cmp(&b.info.pid));
1573        if query.descending {
1574            ordering.reverse()
1575        } else {
1576            ordering
1577        }
1578    });
1579    if query.tree { into_tree(rows) } else { rows }
1580}
1581
1582/// The processes `matches` accepts, plus their ancestors among `processes`
1583/// (so a filtered tree keeps its shape), in the order of `processes`.
1584fn with_ancestors(
1585    processes: Vec<&ProcessInfo>,
1586    matches: impl Fn(&ProcessInfo) -> bool,
1587) -> Vec<&ProcessInfo> {
1588    let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
1589    let mut keep: HashSet<u32> = HashSet::new();
1590    for process in processes.iter().filter(|p| matches(p)) {
1591        let mut current = Some(*process);
1592        // Stops at a process already kept, which also ends parent loops.
1593        while let Some(p) = current
1594            && keep.insert(p.pid)
1595        {
1596            current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1597        }
1598    }
1599    processes
1600        .into_iter()
1601        .filter(|p| keep.contains(&p.pid))
1602        .collect()
1603}
1604
1605/// Reorders sorted `rows` depth-first under their parents, keeping the
1606/// sorted order among siblings, and fills in [`ProcessRow::tree_prefix`].
1607/// Processes whose parent is not among the rows are roots.
1608fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1609    let index: HashMap<u32, usize> = rows
1610        .iter()
1611        .enumerate()
1612        .map(|(i, row)| (row.info.pid, i))
1613        .collect();
1614    let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1615    let mut roots = Vec::new();
1616    for (i, row) in rows.iter().enumerate() {
1617        match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1618            Some(&parent) if parent != i => children[parent].push(i),
1619            _ => roots.push(i),
1620        }
1621    }
1622    let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1623    let mut visited = vec![false; rows.len()];
1624    // Processes in a parent loop have no root; they start trees of their own.
1625    let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1626    for start in starts {
1627        // (row, lines of the ancestors, whether it is its parent's last child)
1628        let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1629        while let Some((i, lead, last)) = stack.pop() {
1630            if std::mem::replace(&mut visited[i], true) {
1631                continue;
1632            }
1633            let (prefix, child_lead) = match last {
1634                None => (String::new(), String::new()),
1635                Some(true) => (format!("{lead}└ "), format!("{lead}  ")),
1636                Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1637            };
1638            order.push((i, prefix));
1639            let kids = &children[i];
1640            for (k, &child) in kids.iter().enumerate().rev() {
1641                stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1642            }
1643        }
1644    }
1645    let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1646    order
1647        .into_iter()
1648        .filter_map(|(i, prefix)| {
1649            let mut row = rows[i].take()?;
1650            row.tree_prefix = prefix;
1651            Some(row)
1652        })
1653        .collect()
1654}
1655
1656/// Disk reads plus writes of a process (bytes/s), the Disk column.
1657pub fn disk_rate(process: &ProcessInfo) -> f64 {
1658    process.read_bytes_per_sec + process.write_bytes_per_sec
1659}
1660
1661/// The Disk column's text: the total rate, blank when idle (also where the
1662/// counters cannot be read, e.g. other users' processes on Linux).
1663pub fn disk_rate_text(process: &ProcessInfo) -> String {
1664    let rate = disk_rate(process);
1665    if rate > 0.0 {
1666        format::rate(rate)
1667    } else {
1668        String::new()
1669    }
1670}
1671
1672/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
1673/// read (Windows; see [`Snapshot::lhm_error`]).
1674pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1675
1676/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
1677pub fn lhm_help(error: &str) -> String {
1678    format!(
1679        "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1680         Start it and turn on Options → Remote Web Server → Run.\n\
1681         ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1682         {error}"
1683    )
1684}
1685
1686/// Name of a signal as shown to the user ("renice" for a priority; see
1687/// [`action_text`] for the value).
1688pub fn signal_name(signal: ProcessSignal) -> &'static str {
1689    match signal {
1690        ProcessSignal::Terminate => "SIGTERM",
1691        ProcessSignal::Kill => "SIGKILL",
1692        ProcessSignal::Stop => "SIGSTOP",
1693        ProcessSignal::Continue => "SIGCONT",
1694        ProcessSignal::Nice(_) => "renice",
1695    }
1696}
1697
1698/// What was done, for status lines: "SIGSTOP", "nice 10".
1699pub fn action_text(signal: ProcessSignal) -> String {
1700    match signal {
1701        ProcessSignal::Nice(nice) => format!("nice {nice}"),
1702        other => signal_name(other).to_owned(),
1703    }
1704}
1705
1706/// Steps of the priority keys and menu items.
1707pub const NICE_STEP: i32 = 5;
1708
1709/// The nice value one step lower (`later`) or higher in priority than
1710/// `process`'s (0 when unknown), within -20..=19; `None` at the end.
1711pub fn nice_step(process: &ProcessInfo, later: bool) -> Option<i32> {
1712    let current = process.nice.unwrap_or(0);
1713    let next = if later {
1714        (current + NICE_STEP).min(19)
1715    } else {
1716        (current - NICE_STEP).max(-20)
1717    };
1718    (next != current).then_some(next)
1719}
1720
1721/// Whether `process` is paused (SIGSTOP, or stopped by a debugger).
1722pub fn is_stopped(process: &ProcessInfo) -> bool {
1723    process.state == "Stopped" || process.state == "Tracing"
1724}
1725
1726/// Returns true for container bridges, veth pairs and loopback, which are
1727/// skipped when choosing and listing interfaces.
1728pub fn is_virtual_interface(name: &str) -> bool {
1729    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1730}
1731
1732/// Picks the physical interface with the most traffic so far.
1733pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1734    snapshot
1735        .networks
1736        .iter()
1737        .filter(|n| !is_virtual_interface(&n.name))
1738        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1739        .or_else(|| snapshot.networks.first())
1740        .map(|n| n.name.clone())
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746    use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1747
1748    #[test]
1749    fn process_details_rows() {
1750        let mut snapshot = Snapshot::default();
1751        let mut shell = process(20, "zsh", 0.0, false);
1752        shell.parent_pid = Some(1);
1753        let mut python = process(30, "python", 99.5, false);
1754        python.parent_pid = Some(20);
1755        python.command = "python train.py --epochs 10".to_owned();
1756        python.container = Some("trainer".to_owned());
1757        python.run_time_secs = 3_660;
1758        snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1759        snapshot.gpus = vec![GpuStats {
1760            index: 0,
1761            processes: vec![GpuProcess {
1762                pid: 30,
1763                memory_bytes: Some(2 << 30),
1764                sm_percent: Some(97.0),
1765            }],
1766            ..Default::default()
1767        }];
1768        let now = SystemTime::now();
1769        let rows = process_details(&snapshot, 30, now).unwrap();
1770        let get = |label: &str| {
1771            rows.iter()
1772                .find(|(l, _)| *l == label)
1773                .map(|(_, v)| v.as_str())
1774        };
1775        assert_eq!(get("Program"), Some("python (pid 30)"));
1776        assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1777        assert_eq!(get("Children"), None);
1778        assert_eq!(get("Container"), Some("trainer"));
1779        assert!(get("Running").unwrap().starts_with("1h01m (since "));
1780        assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1781        assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1782        let shell = process_details(&snapshot, 20, now).unwrap();
1783        assert!(shell.contains(&("Children", "1".to_owned())));
1784        assert!(process_details(&snapshot, 99, now).is_none());
1785    }
1786
1787    #[test]
1788    fn attention_ranks() {
1789        let alert = |severity| Alert {
1790            severity,
1791            text: String::new(),
1792        };
1793        assert_eq!(attention_rank(&[], false, false), 2);
1794        assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1795        assert_eq!(attention_rank(&[], false, true), 1);
1796        assert_eq!(
1797            attention_rank(
1798                &[alert(Severity::Warning), alert(Severity::Critical)],
1799                false,
1800                false
1801            ),
1802            0
1803        );
1804        assert_eq!(attention_rank(&[], true, false), 0);
1805    }
1806
1807    #[test]
1808    fn gpu_notes_and_pcie() {
1809        let mut gpu = GpuStats {
1810            throttle_reasons: vec!["power cap".to_owned()],
1811            ..Default::default()
1812        };
1813        assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1814        gpu.throttle_reasons.push("thermal".to_owned());
1815        gpu.ecc = Some(EccErrors {
1816            corrected: 3,
1817            uncorrected: 0,
1818        });
1819        assert_eq!(
1820            gpu_notes(&gpu),
1821            [
1822                ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1823                ("ECC 3 corrected".to_owned(), None)
1824            ]
1825        );
1826        let full = PcieLink {
1827            generation: 4,
1828            width: 16,
1829            max_generation: 4,
1830            max_width: 16,
1831        };
1832        assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1833        let idle = PcieLink {
1834            generation: 1,
1835            width: 8,
1836            ..full
1837        };
1838        assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1839    }
1840
1841    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1842        ProcessInfo {
1843            pid,
1844            name: name.to_owned(),
1845            cpu_percent: cpu,
1846            kernel_thread: kernel,
1847            ..Default::default()
1848        }
1849    }
1850
1851    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1852        ProcessQuery {
1853            sort_key,
1854            descending: sort_key.descending_by_default(),
1855            filter: "",
1856            show_kernel_threads: false,
1857            tree: false,
1858        }
1859    }
1860
1861    #[test]
1862    fn history_keeps_newest_samples() {
1863        let mut history = History::default();
1864        for i in 0..(HISTORY_LEN + 5) {
1865            history.push(i as f64);
1866        }
1867        assert_eq!(
1868            history.last(2),
1869            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1870        );
1871        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1872        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1873    }
1874
1875    #[test]
1876    fn graph_spans_spread_samples_over_the_width() {
1877        let second = Duration::from_secs(1);
1878        let mut history = History::default();
1879        for i in 0..120 {
1880            history.push(i as f64);
1881        }
1882        // Fit: one sample per column, the newest ones.
1883        assert_eq!(
1884            history.graph(3, GraphSpan::Fit, second),
1885            vec![117.0, 118.0, 119.0]
1886        );
1887        // A minute on 30 columns: two samples averaged per column.
1888        let minute = history.graph(30, GraphSpan::Secs(60), second);
1889        assert_eq!(minute.len(), 30);
1890        assert_eq!(minute[0], 60.5);
1891        assert_eq!(minute[29], 118.5);
1892        // Fewer samples than columns: interpolated between them.
1893        let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1894        assert_eq!(stretched.len(), 120);
1895        assert_eq!(&stretched[..2], &[90.0, 90.0]);
1896        assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1897        assert_eq!(stretched[119], 119.0);
1898        assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1899        // An hour with two minutes recorded fills the right 1/30.
1900        let hour = history.graph(300, GraphSpan::Secs(3600), second);
1901        assert_eq!(hour.len(), 10);
1902        assert_eq!(hour[0], 5.5);
1903        assert!(
1904            History::default()
1905                .graph(10, GraphSpan::Secs(60), second)
1906                .is_empty()
1907        );
1908    }
1909
1910    #[test]
1911    fn long_spans_draw_from_the_averages() {
1912        let second = Duration::from_secs(1);
1913        let mut history = History::default();
1914        // Two hours: the first at 0, the second at 100.
1915        for i in 0..7200 {
1916            history.push(if i < 3600 { 0.0 } else { 100.0 });
1917        }
1918        // 6 hours with 2 recorded: the right third, half low and half high.
1919        let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1920        assert_eq!(six.len(), 30);
1921        assert_eq!((six[0], six[29]), (0.0, 100.0));
1922        // An hour still comes from the samples themselves.
1923        let hour = history.graph(60, GraphSpan::Secs(3600), second);
1924        assert!(hour.iter().all(|v| *v == 100.0));
1925        // The averages stay bounded.
1926        for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1927            history.push(i as f64);
1928        }
1929        assert_eq!(history.coarse.len(), COARSE_LEN);
1930    }
1931
1932    #[test]
1933    fn graph_span_choices_cycle() {
1934        let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1935            .take(6)
1936            .map(GraphSpan::label)
1937            .collect();
1938        assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1939        assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1940        assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1941        assert_eq!(
1942            GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1943            GraphSpan::Secs(300)
1944        );
1945        assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1946        // A stored span that is not a choice starts over.
1947        assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1948    }
1949
1950    #[test]
1951    fn rows_filter_sort_and_join_gpu_usage() {
1952        let snapshot = Snapshot {
1953            processes: vec![
1954                process(10, "bash", 5.0, false),
1955                process(2, "kworker/0:1", 50.0, true),
1956                process(30, "python", 1.0, false),
1957            ],
1958            gpus: vec![GpuStats {
1959                processes: vec![GpuProcess {
1960                    pid: 30,
1961                    memory_bytes: Some(1 << 30),
1962                    sm_percent: Some(70.0),
1963                }],
1964                ..Default::default()
1965            }],
1966            ..Default::default()
1967        };
1968        let pids = |q: &ProcessQuery| {
1969            process_rows(&snapshot, q)
1970                .iter()
1971                .map(|r| r.info.pid)
1972                .collect::<Vec<_>>()
1973        };
1974        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1975        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1976        assert_eq!(
1977            pids(&ProcessQuery {
1978                show_kernel_threads: true,
1979                ..query(SortKey::Cpu)
1980            }),
1981            vec![2, 10, 30]
1982        );
1983        assert_eq!(
1984            pids(&ProcessQuery {
1985                filter: "PYT",
1986                ..query(SortKey::Pid)
1987            }),
1988            vec![30]
1989        );
1990        let rows = process_rows(&snapshot, &query(SortKey::Pid));
1991        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1992        assert_eq!(rows[1].gpu_percent, Some(70.0));
1993    }
1994
1995    #[test]
1996    fn since_notes_say_how_long_and_since_when() {
1997        let now = SystemTime::now();
1998        let ago = |secs: u64| now - Duration::from_secs(secs);
1999        assert!(since_note(ago(42), now).starts_with("42s (since "));
2000        assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
2001        assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
2002        assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
2003        // A clock that moved backwards does not underflow.
2004        assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
2005    }
2006
2007    #[test]
2008    fn tree_puts_children_under_parents() {
2009        let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
2010            parent_pid: Some(parent),
2011            ..process(pid, name, cpu, false)
2012        };
2013        let snapshot = Snapshot {
2014            processes: vec![
2015                process(1, "init", 0.0, false),
2016                child(10, 1, "sshd", 1.0),
2017                child(11, 10, "bash", 2.0),
2018                child(12, 11, "python", 50.0),
2019                child(20, 1, "cron", 3.0),
2020                // Parent not listed (e.g. not readable): a root of its own.
2021                child(30, 999, "orphan", 4.0),
2022                // A parent loop must not hang or drop the processes.
2023                child(40, 41, "loop-a", 0.0),
2024                child(41, 40, "loop-b", 0.0),
2025            ],
2026            ..Default::default()
2027        };
2028        let tree = |filter| {
2029            process_rows(
2030                &snapshot,
2031                &ProcessQuery {
2032                    filter,
2033                    tree: true,
2034                    ..query(SortKey::Cpu)
2035                },
2036            )
2037            .iter()
2038            .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
2039            .collect::<Vec<_>>()
2040        };
2041        // Roots and siblings by CPU, largest first (ties: higher pid first).
2042        assert_eq!(
2043            tree(""),
2044            vec![
2045                "orphan",
2046                "init",
2047                "├ cron",
2048                "└ sshd",
2049                "  └ bash",
2050                "    └ python",
2051                "loop-b",
2052                "└ loop-a",
2053            ]
2054        );
2055        // A filter keeps the ancestors of what it matches.
2056        assert_eq!(
2057            tree("pyth"),
2058            vec!["init", "└ sshd", "  └ bash", "    └ python"]
2059        );
2060    }
2061
2062    #[test]
2063    fn disk_column_sums_reads_and_writes() {
2064        let reader = ProcessInfo {
2065            read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
2066            ..process(10, "reader", 0.0, false)
2067        };
2068        let writer = ProcessInfo {
2069            read_bytes_per_sec: 1024.0 * 1024.0,
2070            write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
2071            ..process(20, "writer", 0.0, false)
2072        };
2073        let idle = process(30, "idle", 0.0, false);
2074        assert_eq!(disk_rate_text(&writer), "5.0M/s");
2075        assert_eq!(disk_rate_text(&idle), "");
2076        let snapshot = Snapshot {
2077            processes: vec![idle, reader, writer],
2078            ..Default::default()
2079        };
2080        let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
2081            .iter()
2082            .map(|r| r.info.pid)
2083            .collect();
2084        assert_eq!(pids, vec![20, 10, 30]);
2085    }
2086
2087    #[test]
2088    fn metric_history_records_and_switches_interfaces() {
2089        let snapshot = Snapshot {
2090            networks: vec![
2091                NetworkStats {
2092                    name: "enp9s0".to_owned(),
2093                    rx_total_bytes: 10,
2094                    rx_bytes_per_sec: 100.0,
2095                    ..Default::default()
2096                },
2097                NetworkStats {
2098                    name: "wlp7s0".to_owned(),
2099                    tx_bytes_per_sec: 5.0,
2100                    ..Default::default()
2101                },
2102            ],
2103            gpus: vec![GpuStats {
2104                utilization_percent: Some(40.0),
2105                ..Default::default()
2106            }],
2107            ..Default::default()
2108        };
2109        let mut history = MetricHistory::default();
2110        history.record(&snapshot);
2111        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
2112        assert_eq!(history.net_rx.last(1), vec![100.0]);
2113        assert_eq!(history.gpus[0].last(1), vec![40.0]);
2114        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
2115        // Switching clears the samples of the previous interface.
2116        assert!(history.net_rx.last(10).is_empty());
2117        history.record(&snapshot);
2118        assert_eq!(history.net_tx.last(1), vec![5.0]);
2119    }
2120
2121    #[test]
2122    fn alerts_flag_storage_problems_first() {
2123        use crate::snapshot::{
2124            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
2125            ZfsPool,
2126        };
2127        let snapshot = Snapshot {
2128            filesystems: vec![FilesystemUsage {
2129                mount_points: vec!["/data".to_owned()],
2130                total_bytes: 100,
2131                available_bytes: 8,
2132                ..Default::default()
2133            }],
2134            raid: RaidStats {
2135                btrfs: vec![BtrfsFilesystem {
2136                    mount_points: vec!["/".to_owned()],
2137                    device_stats: vec![BtrfsDeviceStats {
2138                        devid: 1,
2139                        corruption_errs: 2,
2140                        ..Default::default()
2141                    }],
2142                    ..Default::default()
2143                }],
2144                md: vec![MdArray {
2145                    name: "md0".to_owned(),
2146                    active: true,
2147                    level: "raid1".to_owned(),
2148                    expected_devices: Some(2),
2149                    working_devices: Some(1),
2150                    ..Default::default()
2151                }],
2152                zfs: vec![
2153                    ZfsPool {
2154                        name: "tank".to_owned(),
2155                        state: "DEGRADED".to_owned(),
2156                        ..Default::default()
2157                    },
2158                    ZfsPool {
2159                        name: "cold".to_owned(),
2160                        state: "OFFLINE".to_owned(),
2161                        ..Default::default()
2162                    },
2163                    ZfsPool {
2164                        name: "fast".to_owned(),
2165                        state: "ONLINE".to_owned(),
2166                        ..Default::default()
2167                    },
2168                ],
2169            },
2170            smart: vec![SmartDevice {
2171                device: "sda".to_owned(),
2172                passed: Some(true),
2173                ..Default::default()
2174            }],
2175            ..Default::default()
2176        };
2177        let alerts = alerts(&snapshot);
2178        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
2179        assert_eq!(
2180            texts,
2181            vec![
2182                "btrfs /: 2 I/O errors (devid 1)",
2183                "md0 (raid1) degraded",
2184                "zfs tank DEGRADED",
2185                "zfs cold OFFLINE",
2186                "/data 92% full"
2187            ]
2188        );
2189        assert_eq!(alerts[0].severity, Severity::Critical);
2190        assert_eq!(alerts[2].severity, Severity::Critical);
2191        assert_eq!(alerts[3].severity, Severity::Warning);
2192        assert!(super::alerts(&Snapshot::default()).is_empty());
2193    }
2194
2195    #[test]
2196    fn event_times_are_as_short_as_is_clear() {
2197        let now = SystemTime::now();
2198        let ms = |t: SystemTime| {
2199            t.duration_since(SystemTime::UNIX_EPOCH)
2200                .unwrap()
2201                .as_millis() as u64
2202        };
2203        // "14:03:12" today; a weekday this week; a date before.
2204        assert_eq!(event_time(ms(now), now).len(), 8);
2205        let week_ago = now - Duration::from_secs(10 * 86_400);
2206        assert!(event_time(ms(week_ago), now).contains(' '));
2207        assert_ne!(event_time(ms(week_ago), now).len(), 8);
2208    }
2209
2210    #[test]
2211    fn snoozing_a_raised_alert_logs_no_clearing() {
2212        let disk = Alert {
2213            severity: Severity::Warning,
2214            text: "sdc: SMART warning".to_owned(),
2215        };
2216        let mut log = AlertLog::default();
2217        assert_eq!(log.update("nas", std::slice::from_ref(&disk), 1).len(), 1);
2218        // Snoozed: it leaves quietly.
2219        assert!(
2220            log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 2)
2221                .is_empty()
2222        );
2223        assert!(
2224            log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 3)
2225                .is_empty()
2226        );
2227        // The snooze ended with the alert still there: raised again.
2228        let events = log.update_snoozed("nas", std::slice::from_ref(&disk), &[], 4);
2229        assert_eq!(events.len(), 1);
2230        assert_eq!(events[0].change, AlertChange::Raised);
2231        // A real clearing is still logged.
2232        assert_eq!(
2233            log.update_snoozed("nas", &[], &[], 5)[0].change,
2234            AlertChange::Cleared
2235        );
2236    }
2237
2238    #[test]
2239    fn alert_history_survives_a_restart() {
2240        let tmp = tempfile::tempdir().unwrap();
2241        let path = tmp.path().join("state/alerts-tui.jsonl");
2242        let degraded = Alert {
2243            severity: Severity::Critical,
2244            text: "md0 (raid1) degraded".to_owned(),
2245        };
2246        let full = Alert {
2247            severity: Severity::Warning,
2248            text: "/srv 92% full".to_owned(),
2249        };
2250        let mut log = AlertLog::persistent(path.clone());
2251        log.update("nas", &[degraded.clone(), full.clone()], 1_000);
2252        log.update("nas", std::slice::from_ref(&degraded), 2_000);
2253        assert_eq!(log.events().count(), 3);
2254
2255        // The next run sees the same events, and the alert still present
2256        // is not logged again.
2257        let mut log = AlertLog::persistent(path.clone());
2258        assert_eq!(log.events().count(), 3);
2259        assert_eq!(log.unread(), (0, None));
2260        assert!(
2261            log.update("nas", std::slice::from_ref(&degraded), 3_000)
2262                .is_empty()
2263        );
2264        assert_eq!(log.events().count(), 3);
2265        let changes = log.update("nas", &[], 4_000);
2266        assert_eq!(changes.len(), 1);
2267        assert_eq!(changes[0].alert, degraded);
2268        let last = log.events().last().unwrap();
2269        assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
2270        assert_eq!(AlertLog::persistent(path.clone()).events().count(), 4);
2271
2272        // A one-off notice is unread, kept, and not taken for a lasting
2273        // alert by the next run (which would log it cleared).
2274        let exited = Alert {
2275            severity: Severity::Warning,
2276            text: "train.py (pid 7) exited after 3h".to_owned(),
2277        };
2278        let mut log = AlertLog::persistent(path.clone());
2279        log.record("nas", exited.clone(), 5_000);
2280        assert_eq!(log.unread(), (1, Some(Severity::Warning)));
2281        let mut log = AlertLog::persistent(path);
2282        assert!(log.update("nas", &[], 6_000).is_empty());
2283        let last = log.events().last().unwrap();
2284        assert_eq!((last.change, &last.alert), (AlertChange::Notice, &exited));
2285    }
2286
2287    #[test]
2288    fn zfs_devices_data_errors_and_scans() {
2289        use crate::snapshot::{RaidStats, ZfsDevice, ZfsPool};
2290        let device = |name: &str, depth, state: &str, checksum| ZfsDevice {
2291            name: name.to_owned(),
2292            depth,
2293            state: state.to_owned(),
2294            checksum_errors: checksum,
2295            ..Default::default()
2296        };
2297        let snapshot = Snapshot {
2298            raid: RaidStats {
2299                zfs: vec![ZfsPool {
2300                    name: "tank".to_owned(),
2301                    state: "DEGRADED".to_owned(),
2302                    devices: vec![
2303                        device("raidz1-0", 0, "DEGRADED", 0),
2304                        device("sda", 1, "ONLINE", 491),
2305                        device("sdb", 1, "OFFLINE", 0),
2306                        device("sdc", 1, "ONLINE", 0),
2307                    ],
2308                    scan: Some(
2309                        "resilver in progress since Sat Oct 31 19:33:41 2026 · 42.12% done"
2310                            .to_owned(),
2311                    ),
2312                    data_errors: Some(3),
2313                    ..Default::default()
2314                }],
2315                ..Default::default()
2316            },
2317            ..Default::default()
2318        };
2319        let texts: Vec<String> = alerts(&snapshot).into_iter().map(|a| a.text).collect();
2320        for expected in [
2321            "zfs tank DEGRADED",
2322            "zfs tank: 3 data errors",
2323            "zfs tank: sda has 491 errors",
2324            "zfs tank: sdb OFFLINE",
2325            "zfs tank resilver 42.1%",
2326        ] {
2327            assert!(
2328                texts.iter().any(|t| t == expected),
2329                "{expected} in {texts:?}"
2330            );
2331        }
2332
2333        let summary = |s| zfs_scan_summary(s);
2334        assert_eq!(
2335            summary("scrub repaired 0B in 00:00:00 with 0 errors on Sat Oct 31 19:33:21 2026"),
2336            ("scrub Oct 31, ok".to_owned(), None)
2337        );
2338        assert_eq!(
2339            summary("scrub repaired 27.7M in 00:00:00 with 0 errors on Sat Oct 31 19:33:41 2026"),
2340            (
2341                "scrub Oct 31, repaired 27.7M".to_owned(),
2342                Some(Severity::Warning)
2343            )
2344        );
2345        assert_eq!(
2346            summary("scrub repaired 22K in 00:00:00 with 442 errors on Sat Oct 31 19:33:54 2026").1,
2347            Some(Severity::Critical)
2348        );
2349        assert_eq!(
2350            summary("resilvered 36K in 00:00:00 with 0 errors on Sat Oct 31 19:33:54 2026").0,
2351            "resilver Oct 31, ok"
2352        );
2353        assert_eq!(
2354            summary("scrub paused since Sat Oct 31 19:34:09 2026 · 0B repaired, 75.76% done").0,
2355            "scrub paused 75.8%"
2356        );
2357        assert_eq!(summary("none requested").0, "no scrub yet");
2358    }
2359
2360    #[test]
2361    fn cards_skip_gpus_without_readings() {
2362        let snapshot = Snapshot {
2363            gpus: vec![
2364                GpuStats {
2365                    index: 0,
2366                    name: "Intel UHD Graphics".to_owned(),
2367                    temperature_c: Some(50.0),
2368                    ..Default::default()
2369                },
2370                GpuStats {
2371                    index: 1,
2372                    utilization_percent: Some(3.0),
2373                    ..Default::default()
2374                },
2375            ],
2376            ..Default::default()
2377        };
2378        let shown: Vec<u32> = card_gpus(&snapshot, IntegratedGpu::Compact)
2379            .map(|g| g.index)
2380            .collect();
2381        assert_eq!(shown, [1]);
2382    }
2383
2384    #[test]
2385    fn cpu_clock_shows_the_average_and_the_fastest_core() {
2386        let cpu = CpuStats {
2387            per_core_mhz: vec![600, 600, 5_700, 0],
2388            ..Default::default()
2389        };
2390        // Cores reading 0 (unknown) are left out of the average.
2391        assert_eq!(cpu_clock_text(&cpu).as_deref(), Some("2.3/5.7GHz"));
2392        assert_eq!(cpu_clock_text(&CpuStats::default()), None);
2393    }
2394
2395    #[test]
2396    fn address_line_lists_the_interface_then_the_public_address() {
2397        let net = NetworkStats {
2398            addresses: vec!["192.0.2.10/24".to_owned(), "2001:db8::10/64".to_owned()],
2399            ..Default::default()
2400        };
2401        assert_eq!(
2402            address_text(Some(&net), Some("203.0.113.7")).as_deref(),
2403            Some("192.0.2.10/24 · public 203.0.113.7 · 2001:db8::10/64")
2404        );
2405        let many = NetworkStats {
2406            addresses: vec![
2407                "192.0.2.10/24".to_owned(),
2408                "2001:db8::10/64".to_owned(),
2409                "2001:db8::a/64".to_owned(),
2410                "2001:db8::b/64".to_owned(),
2411            ],
2412            ..Default::default()
2413        };
2414        assert_eq!(
2415            address_text(Some(&many), None).as_deref(),
2416            Some("192.0.2.10/24 · 2001:db8::10/64 +2")
2417        );
2418        assert_eq!(
2419            address_text(Some(&net), None).as_deref(),
2420            Some("192.0.2.10/24 · 2001:db8::10/64")
2421        );
2422        assert_eq!(address_text(None, None), None);
2423        assert_eq!(
2424            address_text(Some(&NetworkStats::default()), None),
2425            None,
2426            "an interface without addresses"
2427        );
2428    }
2429
2430    #[test]
2431    fn integrated_gpus_step_aside_for_a_discrete_one() {
2432        let gpu = |index, integrated| GpuStats {
2433            index,
2434            integrated,
2435            utilization_percent: Some(1.0),
2436            memory_used_bytes: Some(1 << 30),
2437            ..Default::default()
2438        };
2439        let desktop = Snapshot {
2440            gpus: vec![gpu(0, false), gpu(1, false), gpu(2, true)],
2441            ..Default::default()
2442        };
2443        let rows = |snapshot, mode| -> Vec<(usize, GpuRow)> {
2444            panel_gpus(snapshot, mode)
2445                .into_iter()
2446                .map(|(i, _, row)| (i, row))
2447                .collect()
2448        };
2449        assert_eq!(
2450            rows(&desktop, IntegratedGpu::Compact),
2451            [(0, GpuRow::Full), (1, GpuRow::Full), (2, GpuRow::Compact)]
2452        );
2453        assert_eq!(
2454            rows(&desktop, IntegratedGpu::Hide),
2455            [(0, GpuRow::Full), (1, GpuRow::Full)]
2456        );
2457        assert_eq!(rows(&desktop, IntegratedGpu::Full).len(), 3);
2458        let cards =
2459            |snapshot, mode| -> Vec<u32> { card_gpus(snapshot, mode).map(|g| g.index).collect() };
2460        assert_eq!(cards(&desktop, IntegratedGpu::Compact), [0, 1]);
2461        assert_eq!(cards(&desktop, IntegratedGpu::Full), [0, 1, 2]);
2462
2463        // A laptop with only its APU shows it in full, whatever the mode.
2464        let laptop = Snapshot {
2465            gpus: vec![gpu(0, true)],
2466            ..Default::default()
2467        };
2468        assert_eq!(rows(&laptop, IntegratedGpu::Hide), [(0, GpuRow::Full)]);
2469        assert_eq!(cards(&laptop, IntegratedGpu::Compact), [0]);
2470    }
2471
2472    #[test]
2473    fn containers_add_up_their_processes() {
2474        use crate::snapshot::GpuProcess;
2475        let process = |pid, container: Option<&str>, cpu, mem| ProcessInfo {
2476            pid,
2477            container: container.map(str::to_owned),
2478            cpu_percent: cpu,
2479            memory_bytes: mem,
2480            ..Default::default()
2481        };
2482        let snapshot = Snapshot {
2483            processes: vec![
2484                process(1, None, 50.0, 100),
2485                process(2, Some("db"), 3.0, 500),
2486                process(3, Some("web"), 10.0, 200),
2487                process(4, Some("web"), 5.0, 300),
2488            ],
2489            gpus: vec![GpuStats {
2490                processes: vec![GpuProcess {
2491                    pid: 3,
2492                    sm_percent: Some(40.0),
2493                    memory_bytes: Some(1 << 30),
2494                }],
2495                ..Default::default()
2496            }],
2497            ..Default::default()
2498        };
2499        let usage = container_usage(&snapshot);
2500        assert_eq!(usage.len(), 2, "processes outside containers are left out");
2501        assert_eq!(usage[0].name, "web");
2502        assert_eq!(usage[0].processes, 2);
2503        assert_eq!(usage[0].cpu_percent, 15.0);
2504        assert_eq!(usage[0].memory_bytes, 500);
2505        assert_eq!(usage[0].gpu_percent, Some(40.0));
2506        assert_eq!(usage[0].vram_bytes, Some(1 << 30));
2507        assert_eq!(usage[1].gpu_percent, None);
2508        assert!(container_usage(&Snapshot::default()).is_empty());
2509    }
2510
2511    #[test]
2512    fn processes_match_by_name_or_file() {
2513        let process = ProcessInfo {
2514            name: "python3".to_owned(),
2515            command: "/usr/bin/python3 /home/me/jobs/train.py --epochs 3".to_owned(),
2516            ..Default::default()
2517        };
2518        assert!(process_matches(&process, "python3"));
2519        assert!(process_matches(&process, "train.py"));
2520        assert!(process_matches(&process, " train.py "));
2521        assert!(process_matches(&process, "/home/me/jobs/train.py"));
2522        assert!(!process_matches(&process, "train"));
2523        assert!(!process_matches(&process, "epochs"));
2524        assert!(!process_matches(&process, ""));
2525    }
2526
2527    #[test]
2528    fn exit_watch_reports_exits_it_can_tell() {
2529        let process = |pid: u32, name: &str| ProcessInfo {
2530            pid,
2531            name: name.to_owned(),
2532            run_time_secs: 3_600,
2533            ..Default::default()
2534        };
2535        let mut watch = ExitWatch::default();
2536        assert!(watch.toggle(&process(7, "train.py"), 10_000_000));
2537        assert!(watch.toggle(&process(8, "make"), 10_000_000));
2538        assert!(!watch.toggle(&process(8, "make"), 10_000_000));
2539        assert_eq!(watch.pids(), [7]);
2540
2541        // An agent that does not watch (before 0.19): gone from a limited
2542        // list says nothing.
2543        let mut snapshot = Snapshot::default();
2544        assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2545        assert!(watch.check(&snapshot, 10_000_000).is_empty());
2546        // Listed but not reported: its exit would go unnoticed.
2547        snapshot.processes = vec![process(7, "train.py")];
2548        assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2549
2550        snapshot.watched_pids = vec![7];
2551        snapshot.processes = vec![process(7, "train.py")];
2552        assert_eq!(watch.state(&snapshot, 7), WatchState::Running);
2553        assert!(watch.check(&snapshot, 10_000_000).is_empty());
2554
2555        // Gone while watched: exited, after its run time plus the wait.
2556        snapshot.processes.clear();
2557        assert_eq!(watch.state(&snapshot, 7), WatchState::Exited);
2558        let alerts = watch.check(&snapshot, 10_000_000 + 1_800_000);
2559        assert_eq!(alerts.len(), 1);
2560        assert_eq!(alerts[0].text, "train.py (pid 7) exited after 1h30m");
2561        assert_eq!(watch.pids(), Vec::<u32>::new(), "reported once");
2562
2563        // The pid taken by another program also means it exited.
2564        watch.toggle(&process(9, "rsync"), 0);
2565        snapshot.processes = vec![process(9, "bash")];
2566        assert_eq!(watch.check(&snapshot, 0).len(), 1);
2567
2568        // Watched under another name (the script, not the interpreter).
2569        let python = process(12, "python3");
2570        assert!(watch.watch_as(&python, Some("train.py"), 0));
2571        assert!(!watch.watch_as(&python, Some("train.py"), 0), "once");
2572        snapshot.processes.clear();
2573        snapshot.watched_pids = vec![12];
2574        assert_eq!(
2575            watch.check(&snapshot, 60_000)[0].text,
2576            "train.py (pid 12) exited after 01:00"
2577        );
2578    }
2579
2580    #[test]
2581    fn failed_units_warn_unless_ignored() {
2582        let snapshot = Snapshot {
2583            failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
2584            ..Default::default()
2585        };
2586        let limits = AlertsConfig {
2587            ignored_units: vec!["wait-online.service".to_owned()],
2588            ..Default::default()
2589        };
2590        let alerts = alerts_with(&snapshot, &limits);
2591        assert_eq!(alerts.len(), 1);
2592        assert_eq!(alerts[0].text, "systemd: nginx.service failed");
2593        assert_eq!(alerts[0].severity, Severity::Warning);
2594    }
2595
2596    #[test]
2597    fn version_notes_only_for_other_versions() {
2598        let mut snapshot = Snapshot::default();
2599        assert_eq!(version_note(&snapshot), None);
2600        snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
2601        assert_eq!(version_note(&snapshot), None);
2602        snapshot.host.rigtop_version = "0.1.0".to_owned();
2603        assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
2604        assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
2605    }
2606
2607    #[test]
2608    fn alert_log_records_alerts_appearing_and_clearing() {
2609        let alert = |severity, text: &str| Alert {
2610            severity,
2611            text: text.to_owned(),
2612        };
2613        let full = alert(Severity::Warning, "/data 91% full");
2614        let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
2615        let mut log = AlertLog::default();
2616        log.update("nas", std::slice::from_ref(&full), 1_000);
2617        // Numbers changing is the same alert.
2618        log.update(
2619            "nas",
2620            &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
2621            2_000,
2622        );
2623        log.update("zefat", &[], 2_000);
2624        assert_eq!(log.unread(), (2, Some(Severity::Critical)));
2625        log.mark_read();
2626        log.update("nas", std::slice::from_ref(&errors), 3_000);
2627        assert_eq!(log.unread(), (0, None));
2628        let summary: Vec<(u64, AlertChange, &str)> = log
2629            .events()
2630            .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
2631            .collect();
2632        assert_eq!(
2633            summary,
2634            [
2635                (1_000, AlertChange::Raised, "/data 91% full"),
2636                (
2637                    2_000,
2638                    AlertChange::Raised,
2639                    "btrfs /: 2 I/O errors (nvme1n1)"
2640                ),
2641                (3_000, AlertChange::Cleared, "/data 92% full"),
2642            ]
2643        );
2644        for i in 0..(ALERT_LOG_LEN as u64 + 10) {
2645            log.update(
2646                "flappy",
2647                &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
2648                i,
2649            );
2650        }
2651        assert_eq!(log.events().count(), ALERT_LOG_LEN);
2652    }
2653
2654    #[test]
2655    fn filesystems_filling_up_warn() {
2656        use crate::snapshot::FilesystemUsage;
2657        let filling = |full_in_secs| Snapshot {
2658            filesystems: vec![FilesystemUsage {
2659                mount_points: vec!["/srv".to_owned()],
2660                total_bytes: 100,
2661                available_bytes: 50,
2662                full_in_secs,
2663                ..Default::default()
2664            }],
2665            ..Default::default()
2666        };
2667        let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<String> {
2668            alerts_with(snapshot, limits)
2669                .into_iter()
2670                .map(|a| a.text)
2671                .collect()
2672        };
2673        let defaults = AlertsConfig::default();
2674        assert_eq!(
2675            texts(&filling(Some(5 * 3600)), &defaults),
2676            ["/srv full in ~5h00m"]
2677        );
2678        assert!(texts(&filling(Some(30 * 3600)), &defaults).is_empty());
2679        assert!(texts(&filling(None), &defaults).is_empty());
2680        let off = AlertsConfig {
2681            filesystem_full_warning_hours: 0.0,
2682            ..Default::default()
2683        };
2684        assert!(texts(&filling(Some(60)), &off).is_empty());
2685    }
2686
2687    #[test]
2688    fn alert_thresholds_come_from_the_configuration() {
2689        use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
2690        let temp = |label: &str, celsius| Temperature {
2691            label: label.to_owned(),
2692            celsius,
2693            ..Default::default()
2694        };
2695        let mut snapshot = Snapshot {
2696            filesystems: vec![FilesystemUsage {
2697                mount_points: vec!["/data".to_owned()],
2698                total_bytes: 100,
2699                available_bytes: 8,
2700                ..Default::default()
2701            }],
2702            gpus: vec![GpuStats {
2703                index: 0,
2704                temperature_c: Some(80.0),
2705                ..Default::default()
2706            }],
2707            smart: vec![SmartDevice {
2708                device: "nvme0".to_owned(),
2709                temperature_c: Some(72.0),
2710                ..Default::default()
2711            }],
2712            // nvme0 also has SMART (reported once); nvme1 only its sensor.
2713            sensors: vec![
2714                SensorChip {
2715                    key: "nvme:nvme0".to_owned(),
2716                    name: "nvme".to_owned(),
2717                    temperatures: vec![temp("Composite", 71.0)],
2718                    ..Default::default()
2719                },
2720                SensorChip {
2721                    key: "nvme:nvme1".to_owned(),
2722                    name: "nvme".to_owned(),
2723                    temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
2724                    ..Default::default()
2725                },
2726            ],
2727            ..Default::default()
2728        };
2729        snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
2730        let texts_of = |snapshot: &Snapshot| -> Vec<String> {
2731            alerts_with(snapshot, &AlertsConfig::default())
2732                .into_iter()
2733                .map(|a| a.text)
2734                .collect()
2735        };
2736        let texts = |limits: &AlertsConfig| -> Vec<String> {
2737            alerts_with(&snapshot, limits)
2738                .into_iter()
2739                .map(|a| a.text)
2740                .collect()
2741        };
2742        assert_eq!(
2743            texts(&AlertsConfig::default()),
2744            [
2745                "/data 92% full",
2746                "CPU at 97°C",
2747                "nvme0 at 72°C",
2748                "nvme1 at 74°C"
2749            ]
2750        );
2751        let custom = AlertsConfig {
2752            filesystem_warning_percent: 0.0,
2753            cpu_warning_celsius: 0.0,
2754            gpu_warning_celsius: 75.0,
2755            drive_warning_celsius: 73.0,
2756            ..Default::default()
2757        };
2758        assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
2759
2760        // Hardware slowdowns warn; running at the power limit does not.
2761        let mut gpu = snapshot.gpus[0].clone();
2762        gpu.throttle_reasons = vec!["power cap".to_owned()];
2763        let mut capped = snapshot.clone();
2764        capped.gpus = vec![gpu.clone()];
2765        assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
2766        gpu.throttle_reasons.push("hw thermal".to_owned());
2767        gpu.ecc = Some(EccErrors {
2768            corrected: 5,
2769            uncorrected: 2,
2770        });
2771        capped.gpus = vec![gpu];
2772        let alerts = alerts_with(&capped, &AlertsConfig::default());
2773        assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
2774        assert_eq!(alerts[0].severity, Severity::Critical);
2775        assert!(
2776            alerts
2777                .iter()
2778                .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
2779        );
2780        let critical = AlertsConfig {
2781            filesystem_critical_percent: 90.0,
2782            ..custom
2783        };
2784        assert_eq!(
2785            alerts_with(&snapshot, &critical)[0],
2786            Alert {
2787                severity: Severity::Critical,
2788                text: "/data 92% full".to_owned()
2789            }
2790        );
2791    }
2792
2793    #[test]
2794    fn pressure_alerts_and_readings() {
2795        use crate::snapshot::PressureLevels;
2796        let levels = |some: f32, full: f32| PressureLevels {
2797            some_avg10: some,
2798            some_avg60: some,
2799            full_avg10: full,
2800            full_avg60: full,
2801        };
2802        let mut snapshot = Snapshot {
2803            pressure: Some(Pressure {
2804                cpu: levels(40.0, 0.0),
2805                memory: levels(0.2, 0.1),
2806                io: levels(80.0, 12.0),
2807            }),
2808            ..Default::default()
2809        };
2810        let limits = AlertsConfig::default();
2811        // A busy CPU and a long copy (some I/O, little full) are fine.
2812        assert!(alerts_with(&snapshot, &limits).is_empty());
2813        let names = |snapshot: &Snapshot| -> Vec<(&'static str, String, bool)> {
2814            pressure_items(snapshot.pressure.as_ref().unwrap(), &limits)
2815                .into_iter()
2816                .map(|i| (i.name, i.value, i.warn))
2817                .collect()
2818        };
2819        // Memory under 1% is left out.
2820        assert_eq!(
2821            names(&snapshot),
2822            [
2823                ("cpu", "40%".to_owned(), false),
2824                ("io", "80%".to_owned(), false)
2825            ]
2826        );
2827
2828        snapshot.pressure = Some(Pressure {
2829            cpu: levels(0.0, 0.0),
2830            memory: levels(25.0, 4.0),
2831            io: levels(90.0, 45.0),
2832        });
2833        let texts: Vec<String> = alerts_with(&snapshot, &limits)
2834            .into_iter()
2835            .map(|a| a.text)
2836            .collect();
2837        assert_eq!(texts, ["memory pressure 25%", "I/O pressure 45%"]);
2838        assert_eq!(
2839            names(&snapshot),
2840            [
2841                ("mem", "25%".to_owned(), true),
2842                ("io", "90%".to_owned(), true)
2843            ]
2844        );
2845        assert!(
2846            pressure_details(snapshot.pressure.as_ref().unwrap())
2847                .contains("io: some 90% / 90%, full 45% / 45%")
2848        );
2849    }
2850
2851    #[test]
2852    fn memory_alerts() {
2853        use crate::snapshot::{MemoryStats, OomKill};
2854        let kill = |name: Option<&str>| OomKill {
2855            at_ms: 1,
2856            likely_process: name.map(str::to_owned),
2857        };
2858        let mut snapshot = Snapshot {
2859            memory: MemoryStats {
2860                total_bytes: 100,
2861                used_bytes: 93,
2862                oom_kills: vec![kill(Some("python3"))],
2863                ..Default::default()
2864            },
2865            gpus: vec![GpuStats {
2866                index: 1,
2867                memory_used_bytes: Some(99),
2868                memory_total_bytes: Some(100),
2869                ..Default::default()
2870            }],
2871            ..Default::default()
2872        };
2873        let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<(Severity, String)> {
2874            alerts_with(snapshot, limits)
2875                .into_iter()
2876                .map(|a| (a.severity, a.text))
2877                .collect()
2878        };
2879        // VRAM is off by default (frameworks reserve it all).
2880        assert_eq!(
2881            texts(&snapshot, &AlertsConfig::default()),
2882            [
2883                (
2884                    Severity::Critical,
2885                    "OOM killer stopped python3 (likely)".to_owned()
2886                ),
2887                (Severity::Warning, "memory 93% used".to_owned()),
2888            ]
2889        );
2890        let vram = AlertsConfig {
2891            memory_warning_percent: 0.0,
2892            gpu_memory_warning_percent: 98.0,
2893            ..Default::default()
2894        };
2895        snapshot.memory.oom_kills = vec![kill(Some("bash")), kill(None), kill(Some("python3"))];
2896        assert_eq!(
2897            texts(&snapshot, &vram),
2898            [
2899                (
2900                    Severity::Critical,
2901                    "OOM killer stopped 3 processes (likely python3, bash)".to_owned()
2902                ),
2903                (Severity::Warning, "GPU 1 memory 99% used".to_owned()),
2904            ]
2905        );
2906        snapshot.memory.oom_kills = vec![kill(None)];
2907        assert_eq!(texts(&snapshot, &vram)[0].1, "OOM killer stopped a process");
2908    }
2909
2910    #[test]
2911    fn interface_helpers() {
2912        assert!(is_virtual_interface("veth5a74356"));
2913        assert!(!is_virtual_interface("enp9s0"));
2914        let snapshot = Snapshot {
2915            networks: vec![
2916                NetworkStats {
2917                    name: "docker0".to_owned(),
2918                    rx_total_bytes: 1 << 40,
2919                    ..Default::default()
2920                },
2921                NetworkStats {
2922                    name: "enp9s0".to_owned(),
2923                    rx_total_bytes: 1 << 30,
2924                    ..Default::default()
2925                },
2926            ],
2927            ..Default::default()
2928        };
2929        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
2930        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
2931    }
2932}