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