Skip to main content

rigtop_core/
view.rs

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