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