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;
16use crate::format;
17use crate::snapshot::{GpuStats, PcieLink, ProcessInfo, Snapshot};
18use crate::source::ProcessSignal;
19
20/// Number of samples kept for graphs: over an hour at the default interval
21/// of 1 s, and wider than any realistic terminal in braille dots (2 samples
22/// per cell).
23pub const HISTORY_LEN: usize = 4096;
24
25/// Interface name prefixes skipped when choosing the default network
26/// interface (container bridges and virtual links).
27const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
28
29/// Samples averaged into one point of the coarse history (a minute at the
30/// default interval of 1 s).
31pub const COARSE_FACTOR: usize = 60;
32/// Points kept in the coarse history (a day at the default interval).
33pub const COARSE_LEN: usize = 1440;
34
35/// Fixed-size history of samples, newest last, plus a coarse history of
36/// averages for long time spans.
37#[derive(Debug, Default, Clone)]
38pub struct History {
39    samples: VecDeque<f64>,
40    coarse: VecDeque<f64>,
41    /// Sum and count of the samples not yet averaged into `coarse`.
42    pending: (f64, usize),
43}
44
45impl History {
46    /// Appends a sample, dropping the oldest when full.
47    pub fn push(&mut self, value: f64) {
48        if self.samples.len() == HISTORY_LEN {
49            self.samples.pop_front();
50        }
51        self.samples.push_back(value);
52        self.pending.0 += value;
53        self.pending.1 += 1;
54        if self.pending.1 == COARSE_FACTOR {
55            if self.coarse.len() == COARSE_LEN {
56                self.coarse.pop_front();
57            }
58            self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
59            self.pending = (0.0, 0);
60        }
61    }
62
63    /// The newest `n` samples (or fewer), oldest first.
64    pub fn last(&self, n: usize) -> Vec<f64> {
65        let skip = self.samples.len().saturating_sub(n);
66        self.samples.iter().skip(skip).copied().collect()
67    }
68
69    /// Largest of the newest `n` samples.
70    pub fn max_of_last(&self, n: usize) -> f64 {
71        self.last(n).into_iter().fold(0.0, f64::max)
72    }
73
74    /// Values for a graph `columns` wide, oldest first. [`GraphSpan::Fit`]
75    /// gives the newest sample per column; a time span gives the samples of
76    /// that span (one every `interval`) spread over the columns, averaged
77    /// where several share a column and interpolated where there are fewer
78    /// samples than columns. While the history is shorter than the span,
79    /// fewer values come back, so the graph fills up from the right.
80    pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
81        let GraphSpan::Secs(secs) = span else {
82            return self.last(columns);
83        };
84        let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
85        if wanted <= HISTORY_LEN {
86            spread(&self.last(wanted), wanted, columns)
87        } else {
88            // Longer than the samples kept: from the averages.
89            let wanted = wanted.div_ceil(COARSE_FACTOR);
90            let skip = self.coarse.len().saturating_sub(wanted);
91            let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
92            spread(&points, wanted, columns)
93        }
94    }
95}
96
97/// `samples` (the newest of `wanted`) spread over `columns`: averaged where
98/// several share a column, interpolated where there are fewer samples than
99/// columns, and over a proportional share of the columns at the right while
100/// fewer than `wanted` exist.
101fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
102    if columns == 0 || samples.is_empty() {
103        return Vec::new();
104    }
105    // The share of the columns that the available samples cover.
106    let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
107    let shown = shown.clamp(1, columns);
108    let per_column = samples.len() as f64 / shown as f64;
109    if per_column < 1.0 {
110        // Stretched: a straight line between neighbouring samples.
111        let last = samples.len() - 1;
112        return (0..shown)
113            .map(|i| {
114                let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
115                let (below, frac) = (at.floor() as usize, at.fract());
116                let above = (below + 1).min(last);
117                samples[below] * (1.0 - frac) + samples[above] * frac
118            })
119            .collect();
120    }
121    (0..shown)
122        .map(|i| {
123            let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
124            let start = ((i as f64 * per_column) as usize).min(end - 1);
125            let part = &samples[start..end];
126            part.iter().sum::<f64>() / part.len() as f64
127        })
128        .collect()
129}
130
131/// How much time the graphs cover.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum GraphSpan {
134    /// One sample per graph column, so the time covered grows with the
135    /// width and the interval.
136    #[default]
137    Fit,
138    /// The last this many seconds, spread over the width.
139    Secs(u64),
140}
141
142impl GraphSpan {
143    /// The choices [`GraphSpan::next`] steps through.
144    pub const CHOICES: [GraphSpan; 7] = [
145        GraphSpan::Fit,
146        GraphSpan::Secs(60),
147        GraphSpan::Secs(5 * 60),
148        GraphSpan::Secs(15 * 60),
149        GraphSpan::Secs(60 * 60),
150        GraphSpan::Secs(6 * 60 * 60),
151        GraphSpan::Secs(24 * 60 * 60),
152    ];
153
154    /// The choice after this one, back to the first after the last.
155    pub fn next(self) -> Self {
156        let index = Self::CHOICES
157            .iter()
158            .position(|c| *c == self)
159            .map_or(0, |i| i + 1);
160        Self::CHOICES[index % Self::CHOICES.len()]
161    }
162
163    /// Short name: "fit", "1m", "1h".
164    pub fn label(self) -> String {
165        match self {
166            GraphSpan::Fit => "fit".to_owned(),
167            GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
168            GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
169            GraphSpan::Secs(secs) => format!("{secs}s"),
170        }
171    }
172
173    /// Seconds covered, 0 for [`GraphSpan::Fit`] (for storing the choice).
174    pub fn as_secs(self) -> u64 {
175        match self {
176            GraphSpan::Fit => 0,
177            GraphSpan::Secs(secs) => secs,
178        }
179    }
180
181    /// The inverse of [`GraphSpan::as_secs`].
182    pub fn from_secs(secs: u64) -> Self {
183        if secs == 0 {
184            GraphSpan::Fit
185        } else {
186            GraphSpan::Secs(secs)
187        }
188    }
189}
190
191/// Columns the process table can be sorted by.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum SortKey {
194    Pid,
195    Name,
196    Threads,
197    User,
198    /// Container name; processes outside containers last.
199    Container,
200    Memory,
201    Cpu,
202    /// Disk reads plus writes.
203    Disk,
204    Gpu,
205    Vram,
206}
207
208impl SortKey {
209    /// Sort keys in column order, used to step left/right.
210    pub const ALL: [SortKey; 10] = [
211        SortKey::Pid,
212        SortKey::Name,
213        SortKey::Threads,
214        SortKey::User,
215        SortKey::Container,
216        SortKey::Memory,
217        SortKey::Cpu,
218        SortKey::Disk,
219        SortKey::Gpu,
220        SortKey::Vram,
221    ];
222
223    /// Whether "descending" is the natural order (largest consumers first).
224    pub fn descending_by_default(self) -> bool {
225        !matches!(
226            self,
227            SortKey::Pid | SortKey::Name | SortKey::User | SortKey::Container
228        )
229    }
230}
231
232/// One row of the process table: the process plus its GPU usage.
233#[derive(Debug, Clone)]
234pub struct ProcessRow<'a> {
235    pub info: &'a ProcessInfo,
236    /// Highest per-GPU SM utilization of the process.
237    pub gpu_percent: Option<f32>,
238    /// VRAM used by the process across all GPUs.
239    pub vram_bytes: Option<u64>,
240    /// In the tree view, the lines leading to the process ("│ ├ "), to be
241    /// put before its name; empty otherwise.
242    pub tree_prefix: String,
243}
244
245/// Graph histories of one host, updated from each snapshot.
246#[derive(Debug, Default, Clone)]
247pub struct MetricHistory {
248    /// Total CPU usage (%).
249    pub cpu: History,
250    /// Used memory (% of total).
251    pub memory: History,
252    /// Utilization (%) per GPU index.
253    pub gpus: Vec<History>,
254    /// Receive rate of `net_interface` (bytes/s).
255    pub net_rx: History,
256    /// Transmit rate of `net_interface` (bytes/s).
257    pub net_tx: History,
258    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
259    pub net_interface: Option<String>,
260}
261
262impl MetricHistory {
263    /// Appends the values of a new snapshot.
264    pub fn record(&mut self, snapshot: &Snapshot) {
265        self.cpu.push(snapshot.cpu.total_percent as f64);
266        let memory = &snapshot.memory;
267        if memory.total_bytes > 0 {
268            self.memory
269                .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
270        }
271        self.gpus.resize_with(snapshot.gpus.len(), History::default);
272        for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
273            history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
274        }
275        if self.net_interface.is_none() {
276            self.net_interface = default_interface(snapshot);
277        }
278        let (rx, tx) = self
279            .net_interface
280            .as_deref()
281            .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
282            .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
283            .unwrap_or((0.0, 0.0));
284        self.net_rx.push(rx);
285        self.net_tx.push(tx);
286    }
287
288    /// Switches the recorded network interface, clearing its history.
289    pub fn select_interface(&mut self, name: &str) {
290        if self.net_interface.as_deref() != Some(name) {
291            self.net_interface = Some(name.to_owned());
292            // The old samples belong to another interface.
293            self.net_rx = History::default();
294            self.net_tx = History::default();
295        }
296    }
297
298    /// Switches to the interface after the current one in `snapshot` and
299    /// returns its name.
300    pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
301        let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
302        if names.is_empty() {
303            return None;
304        }
305        let current = self
306            .net_interface
307            .as_deref()
308            .and_then(|name| names.iter().position(|n| *n == name));
309        let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
310        self.select_interface(&next);
311        Some(next)
312    }
313}
314
315/// How urgent an [`Alert`] is.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum Severity {
319    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
320    Warning,
321    /// Data at risk (errors, a missing device, a failing drive).
322    Critical,
323}
324
325/// A condition on a host that needs attention, for overviews.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct Alert {
328    pub severity: Severity,
329    pub text: String,
330}
331
332/// Identifies an alert across samples: its severity and text with numbers
333/// masked, so "3 I/O errors" becoming "4 I/O errors" or a temperature
334/// changing does not count as a new alert.
335pub fn alert_key(alert: &Alert) -> String {
336    let mut key = format!("{:?}:", alert.severity);
337    let mut in_number = false;
338    for c in alert.text.chars() {
339        if c.is_ascii_digit() || (in_number && c == '.') {
340            if !in_number {
341                key.push('#');
342            }
343            in_number = true;
344        } else {
345            key.push(c);
346            in_number = false;
347        }
348    }
349    key
350}
351
352/// Whether an [`AlertEvent`] is an alert appearing or going away.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354#[serde(rename_all = "lowercase")]
355pub enum AlertChange {
356    Raised,
357    Cleared,
358}
359
360/// One entry of the [`AlertLog`].
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct AlertEvent {
363    /// When the change was seen, in milliseconds since the Unix epoch.
364    pub at_ms: u64,
365    pub host: String,
366    pub change: AlertChange,
367    /// The alert as raised, or as last seen before it cleared.
368    pub alert: Alert,
369}
370
371/// Events kept by an [`AlertLog`].
372const ALERT_LOG_LEN: usize = 500;
373
374/// The alerts of every host as they appear and clear, so that what
375/// happened while nobody watched can be looked up later.
376#[derive(Debug, Default)]
377pub struct AlertLog {
378    /// Newest last.
379    events: VecDeque<AlertEvent>,
380    /// Each host's alerts at its previous update.
381    active: HashMap<String, Vec<Alert>>,
382    /// Alerts raised since [`AlertLog::mark_read`], and the worst of them.
383    unread: (usize, Option<Severity>),
384    /// Where events are kept between runs (JSON lines), if anywhere.
385    file: Option<PathBuf>,
386}
387
388/// Where a front-end ("tui", "gui") keeps its alert history: the state
389/// directory (`~/.local/state/rigtop/` on Linux), else the local data
390/// directory (macOS, Windows).
391pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
392    dirs::state_dir()
393        .or_else(dirs::data_local_dir)
394        .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
395}
396
397impl AlertLog {
398    /// A log kept in `path`: the events of earlier runs are loaded, alerts
399    /// they left raised count as still present (so a restart does not log
400    /// them again), and new events are appended. A file that cannot be
401    /// read or written leaves the log working in memory.
402    pub fn persistent(path: PathBuf) -> Self {
403        let mut log = Self::default();
404        let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
405            .map(|text| {
406                text.lines()
407                    .filter_map(|line| serde_json::from_str(line).ok())
408                    .collect()
409            })
410            .unwrap_or_default();
411        let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
412        for event in &loaded {
413            let active = log.active.entry(event.host.clone()).or_default();
414            let key = alert_key(&event.alert);
415            active.retain(|a| alert_key(a) != key);
416            if event.change == AlertChange::Raised {
417                active.push(event.alert.clone());
418            }
419        }
420        log.events = loaded.into_iter().skip(keep).collect();
421        // Rewrite a file grown well past what is shown.
422        if keep > ALERT_LOG_LEN {
423            let text: String = log
424                .events
425                .iter()
426                .filter_map(|e| serde_json::to_string(e).ok())
427                .map(|line| line + "\n")
428                .collect();
429            let tmp = path.with_extension("jsonl.tmp");
430            let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
431        }
432        log.file = Some(path);
433        log
434    }
435
436    /// Appends `event` to the file, if the log has one.
437    fn save(&self, event: &AlertEvent) {
438        let Some(path) = &self.file else {
439            return;
440        };
441        let Ok(line) = serde_json::to_string(event) else {
442            return;
443        };
444        if let Some(dir) = path.parent() {
445            let _ = fs::create_dir_all(dir);
446        }
447        // One write per line, so that lines from concurrent runs never mix.
448        let _ = OpenOptions::new()
449            .create(true)
450            .append(true)
451            .open(path)
452            .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
453    }
454
455    /// Records how the alerts of `host` changed since its previous update,
456    /// and returns those changes.
457    pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) -> Vec<AlertEvent> {
458        let previous = self.active.remove(host).unwrap_or_default();
459        let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
460        let (before, now) = (keys(&previous), keys(alerts));
461        let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
462        let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
463        let mut changes: Vec<(AlertChange, &Alert)> = raised
464            .map(|a| (AlertChange::Raised, a))
465            .chain(cleared.map(|a| (AlertChange::Cleared, a)))
466            .collect();
467        // Cleared first, so that a changed alert reads "went away, came back".
468        changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
469        let mut new_events = Vec::with_capacity(changes.len());
470        for (change, alert) in changes {
471            if change == AlertChange::Raised {
472                self.unread.0 += 1;
473                self.unread.1 = self.unread.1.max(Some(alert.severity));
474            }
475            if self.events.len() == ALERT_LOG_LEN {
476                self.events.pop_front();
477            }
478            let event = AlertEvent {
479                at_ms,
480                host: host.to_owned(),
481                change,
482                alert: alert.clone(),
483            };
484            self.save(&event);
485            new_events.push(event.clone());
486            self.events.push_back(event);
487        }
488        self.active.insert(host.to_owned(), alerts.to_vec());
489        new_events
490    }
491
492    /// All events, oldest first.
493    pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
494        self.events.iter()
495    }
496
497    /// Alerts raised since the log was last read, and the most severe one.
498    pub fn unread(&self) -> (usize, Option<Severity>) {
499        self.unread
500    }
501
502    /// Marks every event as read.
503    pub fn mark_read(&mut self) {
504        self.unread = (0, None);
505    }
506}
507
508/// When an [`AlertEvent`] happened, as short as is clear: "14:03:12"
509/// today, "Tue 14:03" this week, "Oct 21 14:03" before.
510pub fn event_time(at_ms: u64, now: SystemTime) -> String {
511    let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
512    let today: DateTime<Local> = now.into();
513    let days = (today.date_naive() - at.date_naive()).num_days();
514    if days == 0 {
515        at.format("%H:%M:%S").to_string()
516    } else if (1..6).contains(&days) {
517        at.format("%a %H:%M").to_string()
518    } else {
519        at.format("%b %d %H:%M").to_string()
520    }
521}
522
523/// What a process detail view shows, as label and value rows: who started
524/// it, who runs it, since when, what it uses, and its whole command line.
525/// `None` once the process is gone.
526pub fn process_details(
527    snapshot: &Snapshot,
528    pid: u32,
529    now: SystemTime,
530) -> Option<Vec<(&'static str, String)>> {
531    let by_pid: HashMap<u32, &ProcessInfo> =
532        snapshot.processes.iter().map(|p| (p.pid, p)).collect();
533    let process = by_pid.get(&pid)?;
534    let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
535
536    // Parents up to the first process, oldest first; bounded in case of a
537    // loop in inconsistent data.
538    let mut chain = Vec::new();
539    let mut parent = process.parent_pid;
540    while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
541        match by_pid.get(&ppid) {
542            Some(p) => {
543                chain.push(format!("{} ({})", p.name, p.pid));
544                parent = p.parent_pid.filter(|&next| next != p.pid);
545            }
546            None => {
547                chain.push(ppid.to_string());
548                break;
549            }
550        }
551    }
552    if !chain.is_empty() {
553        chain.reverse();
554        rows.push(("Parents", chain.join(" › ")));
555    }
556    let children = snapshot
557        .processes
558        .iter()
559        .filter(|p| p.parent_pid == Some(pid))
560        .count();
561    if children > 0 {
562        rows.push(("Children", children.to_string()));
563    }
564    rows.push(("User", process.user.clone()));
565    if let Some(container) = &process.container {
566        rows.push(("Container", container.clone()));
567    }
568    if !process.state.is_empty() {
569        rows.push(("State", process.state.clone()));
570    }
571    let started = now - Duration::from_secs(process.run_time_secs);
572    rows.push(("Running", since_note(started, now)));
573    rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
574    rows.push(("Memory", format::bytes(process.memory_bytes)));
575    if let Some(threads) = process.threads {
576        rows.push(("Threads", threads.to_string()));
577    }
578    rows.push((
579        "Disk",
580        format!(
581            "read {}, write {}",
582            format::rate(process.read_bytes_per_sec),
583            format::rate(process.write_bytes_per_sec)
584        ),
585    ));
586    for gpu in &snapshot.gpus {
587        if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
588            let mut text = format!("GPU {}", gpu.index);
589            if let Some(sm) = usage.sm_percent {
590                text.push_str(&format!(" {sm:.0}%"));
591            }
592            if let Some(memory) = usage.memory_bytes {
593                text.push_str(&format!(", {} VRAM", format::bytes(memory)));
594            }
595            rows.push(("GPU", text));
596        }
597    }
598    let command = if process.command.is_empty() {
599        format!("[{}]", process.name)
600    } else {
601        process.command.clone()
602    };
603    rows.push(("Command", command));
604    Some(rows)
605}
606
607/// Where a host goes in an overview sorted by attention: 0 for one that is
608/// unreachable or has a critical alert, 1 for one with stale data or a
609/// warning, 2 for the rest.
610pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
611    let worst = alerts.iter().map(|a| a.severity).max();
612    if unreachable || worst == Some(Severity::Critical) {
613        0
614    } else if stale || worst == Some(Severity::Warning) {
615        1
616    } else {
617        2
618    }
619}
620
621/// Short notes for a GPU's title line, with how much attention each needs
622/// (`None`: just information): running at its power limit, slowed down by
623/// the hardware, ECC memory errors.
624pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
625    let mut notes = Vec::new();
626    let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
627    if !slowdown.is_empty() {
628        notes.push((
629            format!("slowed: {}", slowdown.join(", ")),
630            Some(Severity::Warning),
631        ));
632    } else if !gpu.throttle_reasons.is_empty() {
633        notes.push(("power cap".to_owned(), None));
634    }
635    if let Some(ecc) = gpu.ecc {
636        if ecc.uncorrected > 0 {
637            notes.push((
638                format!("ECC {} uncorrected", ecc.uncorrected),
639                Some(Severity::Critical),
640            ));
641        } else if ecc.corrected > 0 {
642            notes.push((format!("ECC {} corrected", ecc.corrected), None));
643        }
644    }
645    notes
646}
647
648/// A GPU's PCIe link for a tooltip: "PCIe gen 4 x8 (up to gen 4 x16)".
649pub fn pcie_text(link: &PcieLink) -> String {
650    let now = format!("PCIe gen {} x{}", link.generation, link.width);
651    if (link.generation, link.width) == (link.max_generation, link.max_width) {
652        now
653    } else {
654        format!(
655            "{now} (up to gen {} x{}; the link slows down when idle)",
656            link.max_generation, link.max_width
657        )
658    }
659}
660
661/// How long it has been since `since` (e.g. a host's last data) and when
662/// that was, in local time: "5m (since 14:03)", "2d03h (since Tue 14:03)".
663pub fn since_note(since: SystemTime, now: SystemTime) -> String {
664    let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
665    let span = match secs {
666        0..60 => format!("{secs}s"),
667        60..3_600 => format!("{}m", secs / 60),
668        3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
669        _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
670    };
671    let at: DateTime<Local> = since.into();
672    let today: DateTime<Local> = now.into();
673    let clock = if at.date_naive() == today.date_naive() {
674        at.format("%H:%M")
675    } else if secs < 6 * 86_400 {
676        at.format("%a %H:%M")
677    } else {
678        at.format("%b %d %H:%M")
679    };
680    format!("{span} (since {clock})")
681}
682
683/// "agent 0.8.2" when `snapshot` comes from a rigtop of another version
684/// than this one (an agent not updated yet), else `None`.
685pub fn version_note(snapshot: &Snapshot) -> Option<String> {
686    let version = &snapshot.host.rigtop_version;
687    (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
688        .then(|| format!("agent {version}"))
689}
690
691/// Hover text explaining a [`version_note`].
692pub fn version_hint(snapshot: &Snapshot) -> String {
693    format!(
694        "this host runs rigtop {}, this machine {}: install the same version on both \
695         for every reading",
696        snapshot.host.rigtop_version,
697        env!("CARGO_PKG_VERSION")
698    )
699}
700
701/// [`alerts_with`] the default thresholds.
702pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
703    alerts_with(snapshot, &AlertsConfig::default())
704}
705
706/// Conditions in `snapshot` that need attention, most severe first:
707/// storage errors and failing drives, degraded RAID, full filesystems, hot
708/// CPUs, GPUs and drives, judged by `limits` (0 turns a threshold off).
709pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
710    // A threshold of 0 (or less) is turned off.
711    let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
712    let mut alerts = Vec::new();
713    let mut add = |severity, text: String| alerts.push(Alert { severity, text });
714    for fs in &snapshot.raid.btrfs {
715        let name = fs
716            .mount_points
717            .first()
718            .map_or(fs.fsid.as_str(), String::as_str);
719        let missing = fs.device_stats.iter().filter(|d| d.missing).count();
720        if missing > 0 {
721            add(
722                Severity::Critical,
723                format!("btrfs {name}: {missing} device(s) missing"),
724            );
725        }
726        let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
727        if errors > 0 {
728            // Which drives: the one to replace.
729            let devices: Vec<String> = fs
730                .device_stats
731                .iter()
732                .filter(|d| d.total_errors() > 0)
733                .map(|d| d.label())
734                .collect();
735            add(
736                Severity::Critical,
737                format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
738            );
739        }
740        if let Some(scrub) = &fs.scrub {
741            if scrub.uncorrectable > 0 {
742                add(
743                    Severity::Critical,
744                    format!(
745                        "btrfs {name}: scrub found {} uncorrectable errors",
746                        scrub.uncorrectable
747                    ),
748                );
749            } else if scrub.errors > 0 {
750                add(
751                    Severity::Warning,
752                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
753                );
754            }
755        }
756    }
757    for md in &snapshot.raid.md {
758        if md.active && md.is_degraded() {
759            add(
760                Severity::Critical,
761                format!("{} ({}) degraded", md.name, md.level),
762            );
763        } else if !md.active {
764            add(Severity::Warning, format!("{} inactive", md.name));
765        }
766        if let Some(sync) = &md.sync {
767            add(
768                Severity::Warning,
769                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
770            );
771        }
772    }
773    for pool in &snapshot.raid.zfs {
774        if pool.is_failing() {
775            add(
776                Severity::Critical,
777                format!("zfs {} {}", pool.name, pool.state),
778            );
779        } else if !pool.is_online() {
780            add(
781                Severity::Warning,
782                format!("zfs {} {}", pool.name, pool.state),
783            );
784        }
785    }
786    for unit in &snapshot.failed_units {
787        if !limits.ignored_units.contains(unit) {
788            add(Severity::Warning, format!("systemd: {unit} failed"));
789        }
790    }
791    for smart in &snapshot.smart {
792        if let Some(error) = &smart.error {
793            add(
794                Severity::Warning,
795                format!("{}: SMART unreadable ({error})", smart.device),
796            );
797        } else if smart.passed == Some(false) {
798            add(
799                Severity::Critical,
800                format!("{}: SMART failed", smart.device),
801            );
802        } else if smart.needs_attention() {
803            add(
804                Severity::Critical,
805                format!("{}: SMART warning", smart.device),
806            );
807        }
808    }
809    for fs in &snapshot.filesystems {
810        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
811        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
812        let name = fs
813            .mount_points
814            .first()
815            .map_or(fs.device.as_str(), String::as_str);
816        if reached(percent, limits.filesystem_critical_percent) {
817            add(Severity::Critical, format!("{name} {percent:.0}% full"));
818        } else if reached(percent, limits.filesystem_warning_percent) {
819            add(Severity::Warning, format!("{name} {percent:.0}% full"));
820        }
821    }
822    let hottest_cpu = snapshot
823        .cpu
824        .temperatures
825        .iter()
826        .map(|t| t.celsius)
827        .fold(f32::NAN, f32::max);
828    if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
829        add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
830    }
831    for gpu in &snapshot.gpus {
832        if let Some(t) = gpu
833            .temperature_c
834            .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
835        {
836            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
837        }
838        let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
839        if !slowdown.is_empty() {
840            add(
841                Severity::Warning,
842                format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
843            );
844        }
845        if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
846            add(
847                Severity::Critical,
848                format!(
849                    "GPU {}: {} uncorrected ECC errors",
850                    gpu.index, ecc.uncorrected
851                ),
852            );
853        }
854    }
855    for (device, celsius) in drive_temperatures(snapshot) {
856        if reached(celsius as f64, limits.drive_warning_celsius as f64) {
857            add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
858        }
859    }
860    // Stable sort keeps the order above within each severity.
861    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
862    alerts
863}
864
865/// (device, °C) of each drive: SMART results, then NVMe drives without
866/// them from their hwmon sensor ("Composite", the drive's own summary).
867fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
868    let mut drives: Vec<(String, f32)> = snapshot
869        .smart
870        .iter()
871        .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
872        .collect();
873    for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
874        // The chip key is "nvme:<device>".
875        let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
876        if drives.iter().any(|(d, _)| d == device) {
877            continue;
878        }
879        let composite = chip
880            .temperatures
881            .iter()
882            .find(|t| t.label == "Composite")
883            .or(chip.temperatures.first());
884        if let Some(t) = composite {
885            drives.push((device.to_owned(), t.celsius));
886        }
887    }
888    drives
889}
890
891/// How the process table is filtered and sorted.
892#[derive(Debug, Clone, Copy)]
893pub struct ProcessQuery<'q> {
894    /// Column to sort by.
895    pub sort_key: SortKey,
896    /// Largest first when true.
897    pub descending: bool,
898    /// Case-insensitive substring of name, command or user, or an exact pid.
899    pub filter: &'q str,
900    /// Include kernel threads (`kworker` and friends).
901    pub show_kernel_threads: bool,
902    /// Children under their parents, siblings sorted by `sort_key`. A
903    /// filter keeps the ancestors of what it matches.
904    pub tree: bool,
905}
906
907/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
908pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
909    // pid -> (max SM %, total VRAM) across GPUs.
910    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
911    for gpu in &snapshot.gpus {
912        for p in &gpu.processes {
913            let entry = gpu_usage.entry(p.pid).or_default();
914            if let Some(sm) = p.sm_percent {
915                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
916            }
917            if let Some(mem) = p.memory_bytes {
918                entry.1 = Some(entry.1.unwrap_or(0) + mem);
919            }
920        }
921    }
922
923    let filter = query.filter.to_lowercase();
924    let matches = |p: &ProcessInfo| {
925        filter.is_empty()
926            || p.name.to_lowercase().contains(&filter)
927            || p.command.to_lowercase().contains(&filter)
928            || p.user.to_lowercase().contains(&filter)
929            || p.container
930                .as_ref()
931                .is_some_and(|c| c.to_lowercase().contains(&filter))
932            || p.pid.to_string() == filter
933    };
934    let candidates = snapshot
935        .processes
936        .iter()
937        .filter(|p| query.show_kernel_threads || !p.kernel_thread);
938    let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
939        with_ancestors(candidates.collect(), matches)
940    } else {
941        candidates.filter(|p| matches(p)).collect()
942    };
943    let mut rows: Vec<ProcessRow<'a>> = shown
944        .into_iter()
945        .map(|info| {
946            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
947            ProcessRow {
948                info,
949                gpu_percent,
950                vram_bytes,
951                tree_prefix: String::new(),
952            }
953        })
954        .collect();
955
956    let key = query.sort_key;
957    rows.sort_by(|a, b| {
958        let ordering = match key {
959            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
960            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
961            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
962            SortKey::User => a.info.user.cmp(&b.info.user),
963            SortKey::Container => match (&a.info.container, &b.info.container) {
964                (Some(a), Some(b)) => a.cmp(b),
965                (Some(_), None) => std::cmp::Ordering::Less,
966                (None, Some(_)) => std::cmp::Ordering::Greater,
967                (None, None) => std::cmp::Ordering::Equal,
968            },
969            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
970            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
971            SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
972            SortKey::Gpu => a
973                .gpu_percent
974                .unwrap_or(-1.0)
975                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
976            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
977        }
978        // Stable tie-breaker so rows do not jump between frames.
979        .then(a.info.pid.cmp(&b.info.pid));
980        if query.descending {
981            ordering.reverse()
982        } else {
983            ordering
984        }
985    });
986    if query.tree { into_tree(rows) } else { rows }
987}
988
989/// The processes `matches` accepts, plus their ancestors among `processes`
990/// (so a filtered tree keeps its shape), in the order of `processes`.
991fn with_ancestors(
992    processes: Vec<&ProcessInfo>,
993    matches: impl Fn(&ProcessInfo) -> bool,
994) -> Vec<&ProcessInfo> {
995    let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
996    let mut keep: HashSet<u32> = HashSet::new();
997    for process in processes.iter().filter(|p| matches(p)) {
998        let mut current = Some(*process);
999        // Stops at a process already kept, which also ends parent loops.
1000        while let Some(p) = current
1001            && keep.insert(p.pid)
1002        {
1003            current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1004        }
1005    }
1006    processes
1007        .into_iter()
1008        .filter(|p| keep.contains(&p.pid))
1009        .collect()
1010}
1011
1012/// Reorders sorted `rows` depth-first under their parents, keeping the
1013/// sorted order among siblings, and fills in [`ProcessRow::tree_prefix`].
1014/// Processes whose parent is not among the rows are roots.
1015fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1016    let index: HashMap<u32, usize> = rows
1017        .iter()
1018        .enumerate()
1019        .map(|(i, row)| (row.info.pid, i))
1020        .collect();
1021    let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1022    let mut roots = Vec::new();
1023    for (i, row) in rows.iter().enumerate() {
1024        match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1025            Some(&parent) if parent != i => children[parent].push(i),
1026            _ => roots.push(i),
1027        }
1028    }
1029    let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1030    let mut visited = vec![false; rows.len()];
1031    // Processes in a parent loop have no root; they start trees of their own.
1032    let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1033    for start in starts {
1034        // (row, lines of the ancestors, whether it is its parent's last child)
1035        let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1036        while let Some((i, lead, last)) = stack.pop() {
1037            if std::mem::replace(&mut visited[i], true) {
1038                continue;
1039            }
1040            let (prefix, child_lead) = match last {
1041                None => (String::new(), String::new()),
1042                Some(true) => (format!("{lead}└ "), format!("{lead}  ")),
1043                Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1044            };
1045            order.push((i, prefix));
1046            let kids = &children[i];
1047            for (k, &child) in kids.iter().enumerate().rev() {
1048                stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1049            }
1050        }
1051    }
1052    let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1053    order
1054        .into_iter()
1055        .filter_map(|(i, prefix)| {
1056            let mut row = rows[i].take()?;
1057            row.tree_prefix = prefix;
1058            Some(row)
1059        })
1060        .collect()
1061}
1062
1063/// Disk reads plus writes of a process (bytes/s), the Disk column.
1064pub fn disk_rate(process: &ProcessInfo) -> f64 {
1065    process.read_bytes_per_sec + process.write_bytes_per_sec
1066}
1067
1068/// The Disk column's text: the total rate, blank when idle (also where the
1069/// counters cannot be read, e.g. other users' processes on Linux).
1070pub fn disk_rate_text(process: &ProcessInfo) -> String {
1071    let rate = disk_rate(process);
1072    if rate > 0.0 {
1073        format::rate(rate)
1074    } else {
1075        String::new()
1076    }
1077}
1078
1079/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
1080/// read (Windows; see [`Snapshot::lhm_error`]).
1081pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1082
1083/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
1084pub fn lhm_help(error: &str) -> String {
1085    format!(
1086        "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1087         Start it and turn on Options → Remote Web Server → Run.\n\
1088         ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1089         {error}"
1090    )
1091}
1092
1093/// Name of a signal as shown to the user.
1094pub fn signal_name(signal: ProcessSignal) -> &'static str {
1095    match signal {
1096        ProcessSignal::Terminate => "SIGTERM",
1097        ProcessSignal::Kill => "SIGKILL",
1098    }
1099}
1100
1101/// Returns true for container bridges, veth pairs and loopback, which are
1102/// skipped when choosing and listing interfaces.
1103pub fn is_virtual_interface(name: &str) -> bool {
1104    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1105}
1106
1107/// Picks the physical interface with the most traffic so far.
1108pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1109    snapshot
1110        .networks
1111        .iter()
1112        .filter(|n| !is_virtual_interface(&n.name))
1113        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1114        .or_else(|| snapshot.networks.first())
1115        .map(|n| n.name.clone())
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1122
1123    #[test]
1124    fn process_details_rows() {
1125        let mut snapshot = Snapshot::default();
1126        let mut shell = process(20, "zsh", 0.0, false);
1127        shell.parent_pid = Some(1);
1128        let mut python = process(30, "python", 99.5, false);
1129        python.parent_pid = Some(20);
1130        python.command = "python train.py --epochs 10".to_owned();
1131        python.container = Some("trainer".to_owned());
1132        python.run_time_secs = 3_660;
1133        snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1134        snapshot.gpus = vec![GpuStats {
1135            index: 0,
1136            processes: vec![GpuProcess {
1137                pid: 30,
1138                memory_bytes: Some(2 << 30),
1139                sm_percent: Some(97.0),
1140            }],
1141            ..Default::default()
1142        }];
1143        let now = SystemTime::now();
1144        let rows = process_details(&snapshot, 30, now).unwrap();
1145        let get = |label: &str| {
1146            rows.iter()
1147                .find(|(l, _)| *l == label)
1148                .map(|(_, v)| v.as_str())
1149        };
1150        assert_eq!(get("Program"), Some("python (pid 30)"));
1151        assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1152        assert_eq!(get("Children"), None);
1153        assert_eq!(get("Container"), Some("trainer"));
1154        assert!(get("Running").unwrap().starts_with("1h01m (since "));
1155        assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1156        assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1157        let shell = process_details(&snapshot, 20, now).unwrap();
1158        assert!(shell.contains(&("Children", "1".to_owned())));
1159        assert!(process_details(&snapshot, 99, now).is_none());
1160    }
1161
1162    #[test]
1163    fn attention_ranks() {
1164        let alert = |severity| Alert {
1165            severity,
1166            text: String::new(),
1167        };
1168        assert_eq!(attention_rank(&[], false, false), 2);
1169        assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1170        assert_eq!(attention_rank(&[], false, true), 1);
1171        assert_eq!(
1172            attention_rank(
1173                &[alert(Severity::Warning), alert(Severity::Critical)],
1174                false,
1175                false
1176            ),
1177            0
1178        );
1179        assert_eq!(attention_rank(&[], true, false), 0);
1180    }
1181
1182    #[test]
1183    fn gpu_notes_and_pcie() {
1184        let mut gpu = GpuStats {
1185            throttle_reasons: vec!["power cap".to_owned()],
1186            ..Default::default()
1187        };
1188        assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1189        gpu.throttle_reasons.push("thermal".to_owned());
1190        gpu.ecc = Some(EccErrors {
1191            corrected: 3,
1192            uncorrected: 0,
1193        });
1194        assert_eq!(
1195            gpu_notes(&gpu),
1196            [
1197                ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1198                ("ECC 3 corrected".to_owned(), None)
1199            ]
1200        );
1201        let full = PcieLink {
1202            generation: 4,
1203            width: 16,
1204            max_generation: 4,
1205            max_width: 16,
1206        };
1207        assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1208        let idle = PcieLink {
1209            generation: 1,
1210            width: 8,
1211            ..full
1212        };
1213        assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1214    }
1215
1216    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1217        ProcessInfo {
1218            pid,
1219            name: name.to_owned(),
1220            cpu_percent: cpu,
1221            kernel_thread: kernel,
1222            ..Default::default()
1223        }
1224    }
1225
1226    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1227        ProcessQuery {
1228            sort_key,
1229            descending: sort_key.descending_by_default(),
1230            filter: "",
1231            show_kernel_threads: false,
1232            tree: false,
1233        }
1234    }
1235
1236    #[test]
1237    fn history_keeps_newest_samples() {
1238        let mut history = History::default();
1239        for i in 0..(HISTORY_LEN + 5) {
1240            history.push(i as f64);
1241        }
1242        assert_eq!(
1243            history.last(2),
1244            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1245        );
1246        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1247        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1248    }
1249
1250    #[test]
1251    fn graph_spans_spread_samples_over_the_width() {
1252        let second = Duration::from_secs(1);
1253        let mut history = History::default();
1254        for i in 0..120 {
1255            history.push(i as f64);
1256        }
1257        // Fit: one sample per column, the newest ones.
1258        assert_eq!(
1259            history.graph(3, GraphSpan::Fit, second),
1260            vec![117.0, 118.0, 119.0]
1261        );
1262        // A minute on 30 columns: two samples averaged per column.
1263        let minute = history.graph(30, GraphSpan::Secs(60), second);
1264        assert_eq!(minute.len(), 30);
1265        assert_eq!(minute[0], 60.5);
1266        assert_eq!(minute[29], 118.5);
1267        // Fewer samples than columns: interpolated between them.
1268        let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1269        assert_eq!(stretched.len(), 120);
1270        assert_eq!(&stretched[..2], &[90.0, 90.0]);
1271        assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1272        assert_eq!(stretched[119], 119.0);
1273        assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1274        // An hour with two minutes recorded fills the right 1/30.
1275        let hour = history.graph(300, GraphSpan::Secs(3600), second);
1276        assert_eq!(hour.len(), 10);
1277        assert_eq!(hour[0], 5.5);
1278        assert!(
1279            History::default()
1280                .graph(10, GraphSpan::Secs(60), second)
1281                .is_empty()
1282        );
1283    }
1284
1285    #[test]
1286    fn long_spans_draw_from_the_averages() {
1287        let second = Duration::from_secs(1);
1288        let mut history = History::default();
1289        // Two hours: the first at 0, the second at 100.
1290        for i in 0..7200 {
1291            history.push(if i < 3600 { 0.0 } else { 100.0 });
1292        }
1293        // 6 hours with 2 recorded: the right third, half low and half high.
1294        let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1295        assert_eq!(six.len(), 30);
1296        assert_eq!((six[0], six[29]), (0.0, 100.0));
1297        // An hour still comes from the samples themselves.
1298        let hour = history.graph(60, GraphSpan::Secs(3600), second);
1299        assert!(hour.iter().all(|v| *v == 100.0));
1300        // The averages stay bounded.
1301        for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1302            history.push(i as f64);
1303        }
1304        assert_eq!(history.coarse.len(), COARSE_LEN);
1305    }
1306
1307    #[test]
1308    fn graph_span_choices_cycle() {
1309        let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1310            .take(6)
1311            .map(GraphSpan::label)
1312            .collect();
1313        assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1314        assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1315        assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1316        assert_eq!(
1317            GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1318            GraphSpan::Secs(300)
1319        );
1320        assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1321        // A stored span that is not a choice starts over.
1322        assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1323    }
1324
1325    #[test]
1326    fn rows_filter_sort_and_join_gpu_usage() {
1327        let snapshot = Snapshot {
1328            processes: vec![
1329                process(10, "bash", 5.0, false),
1330                process(2, "kworker/0:1", 50.0, true),
1331                process(30, "python", 1.0, false),
1332            ],
1333            gpus: vec![GpuStats {
1334                processes: vec![GpuProcess {
1335                    pid: 30,
1336                    memory_bytes: Some(1 << 30),
1337                    sm_percent: Some(70.0),
1338                }],
1339                ..Default::default()
1340            }],
1341            ..Default::default()
1342        };
1343        let pids = |q: &ProcessQuery| {
1344            process_rows(&snapshot, q)
1345                .iter()
1346                .map(|r| r.info.pid)
1347                .collect::<Vec<_>>()
1348        };
1349        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1350        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1351        assert_eq!(
1352            pids(&ProcessQuery {
1353                show_kernel_threads: true,
1354                ..query(SortKey::Cpu)
1355            }),
1356            vec![2, 10, 30]
1357        );
1358        assert_eq!(
1359            pids(&ProcessQuery {
1360                filter: "PYT",
1361                ..query(SortKey::Pid)
1362            }),
1363            vec![30]
1364        );
1365        let rows = process_rows(&snapshot, &query(SortKey::Pid));
1366        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1367        assert_eq!(rows[1].gpu_percent, Some(70.0));
1368    }
1369
1370    #[test]
1371    fn since_notes_say_how_long_and_since_when() {
1372        let now = SystemTime::now();
1373        let ago = |secs: u64| now - Duration::from_secs(secs);
1374        assert!(since_note(ago(42), now).starts_with("42s (since "));
1375        assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1376        assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1377        assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1378        // A clock that moved backwards does not underflow.
1379        assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1380    }
1381
1382    #[test]
1383    fn tree_puts_children_under_parents() {
1384        let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1385            parent_pid: Some(parent),
1386            ..process(pid, name, cpu, false)
1387        };
1388        let snapshot = Snapshot {
1389            processes: vec![
1390                process(1, "init", 0.0, false),
1391                child(10, 1, "sshd", 1.0),
1392                child(11, 10, "bash", 2.0),
1393                child(12, 11, "python", 50.0),
1394                child(20, 1, "cron", 3.0),
1395                // Parent not listed (e.g. not readable): a root of its own.
1396                child(30, 999, "orphan", 4.0),
1397                // A parent loop must not hang or drop the processes.
1398                child(40, 41, "loop-a", 0.0),
1399                child(41, 40, "loop-b", 0.0),
1400            ],
1401            ..Default::default()
1402        };
1403        let tree = |filter| {
1404            process_rows(
1405                &snapshot,
1406                &ProcessQuery {
1407                    filter,
1408                    tree: true,
1409                    ..query(SortKey::Cpu)
1410                },
1411            )
1412            .iter()
1413            .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1414            .collect::<Vec<_>>()
1415        };
1416        // Roots and siblings by CPU, largest first (ties: higher pid first).
1417        assert_eq!(
1418            tree(""),
1419            vec![
1420                "orphan",
1421                "init",
1422                "├ cron",
1423                "└ sshd",
1424                "  └ bash",
1425                "    └ python",
1426                "loop-b",
1427                "└ loop-a",
1428            ]
1429        );
1430        // A filter keeps the ancestors of what it matches.
1431        assert_eq!(
1432            tree("pyth"),
1433            vec!["init", "└ sshd", "  └ bash", "    └ python"]
1434        );
1435    }
1436
1437    #[test]
1438    fn disk_column_sums_reads_and_writes() {
1439        let reader = ProcessInfo {
1440            read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1441            ..process(10, "reader", 0.0, false)
1442        };
1443        let writer = ProcessInfo {
1444            read_bytes_per_sec: 1024.0 * 1024.0,
1445            write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1446            ..process(20, "writer", 0.0, false)
1447        };
1448        let idle = process(30, "idle", 0.0, false);
1449        assert_eq!(disk_rate_text(&writer), "5.0M/s");
1450        assert_eq!(disk_rate_text(&idle), "");
1451        let snapshot = Snapshot {
1452            processes: vec![idle, reader, writer],
1453            ..Default::default()
1454        };
1455        let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1456            .iter()
1457            .map(|r| r.info.pid)
1458            .collect();
1459        assert_eq!(pids, vec![20, 10, 30]);
1460    }
1461
1462    #[test]
1463    fn metric_history_records_and_switches_interfaces() {
1464        let snapshot = Snapshot {
1465            networks: vec![
1466                NetworkStats {
1467                    name: "enp9s0".to_owned(),
1468                    rx_total_bytes: 10,
1469                    rx_bytes_per_sec: 100.0,
1470                    ..Default::default()
1471                },
1472                NetworkStats {
1473                    name: "wlp7s0".to_owned(),
1474                    tx_bytes_per_sec: 5.0,
1475                    ..Default::default()
1476                },
1477            ],
1478            gpus: vec![GpuStats {
1479                utilization_percent: Some(40.0),
1480                ..Default::default()
1481            }],
1482            ..Default::default()
1483        };
1484        let mut history = MetricHistory::default();
1485        history.record(&snapshot);
1486        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1487        assert_eq!(history.net_rx.last(1), vec![100.0]);
1488        assert_eq!(history.gpus[0].last(1), vec![40.0]);
1489        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1490        // Switching clears the samples of the previous interface.
1491        assert!(history.net_rx.last(10).is_empty());
1492        history.record(&snapshot);
1493        assert_eq!(history.net_tx.last(1), vec![5.0]);
1494    }
1495
1496    #[test]
1497    fn alerts_flag_storage_problems_first() {
1498        use crate::snapshot::{
1499            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1500            ZfsPool,
1501        };
1502        let snapshot = Snapshot {
1503            filesystems: vec![FilesystemUsage {
1504                mount_points: vec!["/data".to_owned()],
1505                total_bytes: 100,
1506                available_bytes: 8,
1507                ..Default::default()
1508            }],
1509            raid: RaidStats {
1510                btrfs: vec![BtrfsFilesystem {
1511                    mount_points: vec!["/".to_owned()],
1512                    device_stats: vec![BtrfsDeviceStats {
1513                        devid: 1,
1514                        corruption_errs: 2,
1515                        ..Default::default()
1516                    }],
1517                    ..Default::default()
1518                }],
1519                md: vec![MdArray {
1520                    name: "md0".to_owned(),
1521                    active: true,
1522                    level: "raid1".to_owned(),
1523                    expected_devices: Some(2),
1524                    working_devices: Some(1),
1525                    ..Default::default()
1526                }],
1527                zfs: vec![
1528                    ZfsPool {
1529                        name: "tank".to_owned(),
1530                        state: "DEGRADED".to_owned(),
1531                    },
1532                    ZfsPool {
1533                        name: "cold".to_owned(),
1534                        state: "OFFLINE".to_owned(),
1535                    },
1536                    ZfsPool {
1537                        name: "fast".to_owned(),
1538                        state: "ONLINE".to_owned(),
1539                    },
1540                ],
1541            },
1542            smart: vec![SmartDevice {
1543                device: "sda".to_owned(),
1544                passed: Some(true),
1545                ..Default::default()
1546            }],
1547            ..Default::default()
1548        };
1549        let alerts = alerts(&snapshot);
1550        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1551        assert_eq!(
1552            texts,
1553            vec![
1554                "btrfs /: 2 I/O errors (devid 1)",
1555                "md0 (raid1) degraded",
1556                "zfs tank DEGRADED",
1557                "zfs cold OFFLINE",
1558                "/data 92% full"
1559            ]
1560        );
1561        assert_eq!(alerts[0].severity, Severity::Critical);
1562        assert_eq!(alerts[2].severity, Severity::Critical);
1563        assert_eq!(alerts[3].severity, Severity::Warning);
1564        assert!(super::alerts(&Snapshot::default()).is_empty());
1565    }
1566
1567    #[test]
1568    fn event_times_are_as_short_as_is_clear() {
1569        let now = SystemTime::now();
1570        let ms = |t: SystemTime| {
1571            t.duration_since(SystemTime::UNIX_EPOCH)
1572                .unwrap()
1573                .as_millis() as u64
1574        };
1575        // "14:03:12" today; a weekday this week; a date before.
1576        assert_eq!(event_time(ms(now), now).len(), 8);
1577        let week_ago = now - Duration::from_secs(10 * 86_400);
1578        assert!(event_time(ms(week_ago), now).contains(' '));
1579        assert_ne!(event_time(ms(week_ago), now).len(), 8);
1580    }
1581
1582    #[test]
1583    fn alert_history_survives_a_restart() {
1584        let tmp = tempfile::tempdir().unwrap();
1585        let path = tmp.path().join("state/alerts-tui.jsonl");
1586        let degraded = Alert {
1587            severity: Severity::Critical,
1588            text: "md0 (raid1) degraded".to_owned(),
1589        };
1590        let full = Alert {
1591            severity: Severity::Warning,
1592            text: "/srv 92% full".to_owned(),
1593        };
1594        let mut log = AlertLog::persistent(path.clone());
1595        log.update("nas", &[degraded.clone(), full.clone()], 1_000);
1596        log.update("nas", std::slice::from_ref(&degraded), 2_000);
1597        assert_eq!(log.events().count(), 3);
1598
1599        // The next run sees the same events, and the alert still present
1600        // is not logged again.
1601        let mut log = AlertLog::persistent(path.clone());
1602        assert_eq!(log.events().count(), 3);
1603        assert_eq!(log.unread(), (0, None));
1604        assert!(
1605            log.update("nas", std::slice::from_ref(&degraded), 3_000)
1606                .is_empty()
1607        );
1608        assert_eq!(log.events().count(), 3);
1609        let changes = log.update("nas", &[], 4_000);
1610        assert_eq!(changes.len(), 1);
1611        assert_eq!(changes[0].alert, degraded);
1612        let last = log.events().last().unwrap();
1613        assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
1614        assert_eq!(AlertLog::persistent(path).events().count(), 4);
1615    }
1616
1617    #[test]
1618    fn failed_units_warn_unless_ignored() {
1619        let snapshot = Snapshot {
1620            failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
1621            ..Default::default()
1622        };
1623        let limits = AlertsConfig {
1624            ignored_units: vec!["wait-online.service".to_owned()],
1625            ..Default::default()
1626        };
1627        let alerts = alerts_with(&snapshot, &limits);
1628        assert_eq!(alerts.len(), 1);
1629        assert_eq!(alerts[0].text, "systemd: nginx.service failed");
1630        assert_eq!(alerts[0].severity, Severity::Warning);
1631    }
1632
1633    #[test]
1634    fn version_notes_only_for_other_versions() {
1635        let mut snapshot = Snapshot::default();
1636        assert_eq!(version_note(&snapshot), None);
1637        snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1638        assert_eq!(version_note(&snapshot), None);
1639        snapshot.host.rigtop_version = "0.1.0".to_owned();
1640        assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1641        assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1642    }
1643
1644    #[test]
1645    fn alert_log_records_alerts_appearing_and_clearing() {
1646        let alert = |severity, text: &str| Alert {
1647            severity,
1648            text: text.to_owned(),
1649        };
1650        let full = alert(Severity::Warning, "/data 91% full");
1651        let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1652        let mut log = AlertLog::default();
1653        log.update("nas", std::slice::from_ref(&full), 1_000);
1654        // Numbers changing is the same alert.
1655        log.update(
1656            "nas",
1657            &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1658            2_000,
1659        );
1660        log.update("zefat", &[], 2_000);
1661        assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1662        log.mark_read();
1663        log.update("nas", std::slice::from_ref(&errors), 3_000);
1664        assert_eq!(log.unread(), (0, None));
1665        let summary: Vec<(u64, AlertChange, &str)> = log
1666            .events()
1667            .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1668            .collect();
1669        assert_eq!(
1670            summary,
1671            [
1672                (1_000, AlertChange::Raised, "/data 91% full"),
1673                (
1674                    2_000,
1675                    AlertChange::Raised,
1676                    "btrfs /: 2 I/O errors (nvme1n1)"
1677                ),
1678                (3_000, AlertChange::Cleared, "/data 92% full"),
1679            ]
1680        );
1681        for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1682            log.update(
1683                "flappy",
1684                &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1685                i,
1686            );
1687        }
1688        assert_eq!(log.events().count(), ALERT_LOG_LEN);
1689    }
1690
1691    #[test]
1692    fn alert_thresholds_come_from_the_configuration() {
1693        use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1694        let temp = |label: &str, celsius| Temperature {
1695            label: label.to_owned(),
1696            celsius,
1697            ..Default::default()
1698        };
1699        let mut snapshot = Snapshot {
1700            filesystems: vec![FilesystemUsage {
1701                mount_points: vec!["/data".to_owned()],
1702                total_bytes: 100,
1703                available_bytes: 8,
1704                ..Default::default()
1705            }],
1706            gpus: vec![GpuStats {
1707                index: 0,
1708                temperature_c: Some(80.0),
1709                ..Default::default()
1710            }],
1711            smart: vec![SmartDevice {
1712                device: "nvme0".to_owned(),
1713                temperature_c: Some(72.0),
1714                ..Default::default()
1715            }],
1716            // nvme0 also has SMART (reported once); nvme1 only its sensor.
1717            sensors: vec![
1718                SensorChip {
1719                    key: "nvme:nvme0".to_owned(),
1720                    name: "nvme".to_owned(),
1721                    temperatures: vec![temp("Composite", 71.0)],
1722                    ..Default::default()
1723                },
1724                SensorChip {
1725                    key: "nvme:nvme1".to_owned(),
1726                    name: "nvme".to_owned(),
1727                    temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1728                    ..Default::default()
1729                },
1730            ],
1731            ..Default::default()
1732        };
1733        snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1734        let texts_of = |snapshot: &Snapshot| -> Vec<String> {
1735            alerts_with(snapshot, &AlertsConfig::default())
1736                .into_iter()
1737                .map(|a| a.text)
1738                .collect()
1739        };
1740        let texts = |limits: &AlertsConfig| -> Vec<String> {
1741            alerts_with(&snapshot, limits)
1742                .into_iter()
1743                .map(|a| a.text)
1744                .collect()
1745        };
1746        assert_eq!(
1747            texts(&AlertsConfig::default()),
1748            [
1749                "/data 92% full",
1750                "CPU at 97°C",
1751                "nvme0 at 72°C",
1752                "nvme1 at 74°C"
1753            ]
1754        );
1755        let custom = AlertsConfig {
1756            filesystem_warning_percent: 0.0,
1757            cpu_warning_celsius: 0.0,
1758            gpu_warning_celsius: 75.0,
1759            drive_warning_celsius: 73.0,
1760            ..Default::default()
1761        };
1762        assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1763
1764        // Hardware slowdowns warn; running at the power limit does not.
1765        let mut gpu = snapshot.gpus[0].clone();
1766        gpu.throttle_reasons = vec!["power cap".to_owned()];
1767        let mut capped = snapshot.clone();
1768        capped.gpus = vec![gpu.clone()];
1769        assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
1770        gpu.throttle_reasons.push("hw thermal".to_owned());
1771        gpu.ecc = Some(EccErrors {
1772            corrected: 5,
1773            uncorrected: 2,
1774        });
1775        capped.gpus = vec![gpu];
1776        let alerts = alerts_with(&capped, &AlertsConfig::default());
1777        assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
1778        assert_eq!(alerts[0].severity, Severity::Critical);
1779        assert!(
1780            alerts
1781                .iter()
1782                .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
1783        );
1784        let critical = AlertsConfig {
1785            filesystem_critical_percent: 90.0,
1786            ..custom
1787        };
1788        assert_eq!(
1789            alerts_with(&snapshot, &critical)[0],
1790            Alert {
1791                severity: Severity::Critical,
1792                text: "/data 92% full".to_owned()
1793            }
1794        );
1795    }
1796
1797    #[test]
1798    fn interface_helpers() {
1799        assert!(is_virtual_interface("veth5a74356"));
1800        assert!(!is_virtual_interface("enp9s0"));
1801        let snapshot = Snapshot {
1802            networks: vec![
1803                NetworkStats {
1804                    name: "docker0".to_owned(),
1805                    rx_total_bytes: 1 << 40,
1806                    ..Default::default()
1807                },
1808                NetworkStats {
1809                    name: "enp9s0".to_owned(),
1810                    rx_total_bytes: 1 << 30,
1811                    ..Default::default()
1812                },
1813            ],
1814            ..Default::default()
1815        };
1816        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1817        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1818    }
1819}