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