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, VecDeque};
7
8use crate::snapshot::{ProcessInfo, Snapshot};
9use crate::source::ProcessSignal;
10
11/// Number of samples kept for graphs (wider than any realistic terminal in
12/// braille dots: 2 samples per cell).
13pub const HISTORY_LEN: usize = 1024;
14
15/// Interface name prefixes skipped when choosing the default network
16/// interface (container bridges and virtual links).
17const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
18
19/// Fixed-size history of samples, newest last.
20#[derive(Debug, Default, Clone)]
21pub struct History {
22    samples: VecDeque<f64>,
23}
24
25impl History {
26    /// Appends a sample, dropping the oldest when full.
27    pub fn push(&mut self, value: f64) {
28        if self.samples.len() == HISTORY_LEN {
29            self.samples.pop_front();
30        }
31        self.samples.push_back(value);
32    }
33
34    /// The newest `n` samples (or fewer), oldest first.
35    pub fn last(&self, n: usize) -> Vec<f64> {
36        let skip = self.samples.len().saturating_sub(n);
37        self.samples.iter().skip(skip).copied().collect()
38    }
39
40    /// Largest of the newest `n` samples.
41    pub fn max_of_last(&self, n: usize) -> f64 {
42        self.last(n).into_iter().fold(0.0, f64::max)
43    }
44}
45
46/// Columns the process table can be sorted by.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SortKey {
49    Pid,
50    Name,
51    Threads,
52    User,
53    Memory,
54    Cpu,
55    Gpu,
56    Vram,
57}
58
59impl SortKey {
60    /// Sort keys in column order, used to step left/right.
61    pub const ALL: [SortKey; 8] = [
62        SortKey::Pid,
63        SortKey::Name,
64        SortKey::Threads,
65        SortKey::User,
66        SortKey::Memory,
67        SortKey::Cpu,
68        SortKey::Gpu,
69        SortKey::Vram,
70    ];
71
72    /// Whether "descending" is the natural order (largest consumers first).
73    pub fn descending_by_default(self) -> bool {
74        !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
75    }
76}
77
78/// One row of the process table: the process plus its GPU usage.
79#[derive(Debug, Clone)]
80pub struct ProcessRow<'a> {
81    pub info: &'a ProcessInfo,
82    /// Highest per-GPU SM utilization of the process.
83    pub gpu_percent: Option<f32>,
84    /// VRAM used by the process across all GPUs.
85    pub vram_bytes: Option<u64>,
86}
87
88/// Graph histories of one host, updated from each snapshot.
89#[derive(Debug, Default, Clone)]
90pub struct MetricHistory {
91    /// Total CPU usage (%).
92    pub cpu: History,
93    /// Used memory (% of total).
94    pub memory: History,
95    /// Utilization (%) per GPU index.
96    pub gpus: Vec<History>,
97    /// Receive rate of `net_interface` (bytes/s).
98    pub net_rx: History,
99    /// Transmit rate of `net_interface` (bytes/s).
100    pub net_tx: History,
101    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
102    pub net_interface: Option<String>,
103}
104
105impl MetricHistory {
106    /// Appends the values of a new snapshot.
107    pub fn record(&mut self, snapshot: &Snapshot) {
108        self.cpu.push(snapshot.cpu.total_percent as f64);
109        let memory = &snapshot.memory;
110        if memory.total_bytes > 0 {
111            self.memory
112                .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
113        }
114        self.gpus.resize_with(snapshot.gpus.len(), History::default);
115        for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
116            history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
117        }
118        if self.net_interface.is_none() {
119            self.net_interface = default_interface(snapshot);
120        }
121        let (rx, tx) = self
122            .net_interface
123            .as_deref()
124            .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
125            .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
126            .unwrap_or((0.0, 0.0));
127        self.net_rx.push(rx);
128        self.net_tx.push(tx);
129    }
130
131    /// Switches the recorded network interface, clearing its history.
132    pub fn select_interface(&mut self, name: &str) {
133        if self.net_interface.as_deref() != Some(name) {
134            self.net_interface = Some(name.to_owned());
135            // The old samples belong to another interface.
136            self.net_rx = History::default();
137            self.net_tx = History::default();
138        }
139    }
140
141    /// Switches to the interface after the current one in `snapshot` and
142    /// returns its name.
143    pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
144        let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
145        if names.is_empty() {
146            return None;
147        }
148        let current = self
149            .net_interface
150            .as_deref()
151            .and_then(|name| names.iter().position(|n| *n == name));
152        let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
153        self.select_interface(&next);
154        Some(next)
155    }
156}
157
158/// How urgent an [`Alert`] is.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
160pub enum Severity {
161    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
162    Warning,
163    /// Data at risk (errors, a missing device, a failing drive).
164    Critical,
165}
166
167/// A condition on a host that needs attention, for overviews.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Alert {
170    pub severity: Severity,
171    pub text: String,
172}
173
174/// Filesystem usage that triggers a warning, and a critical alert.
175const FS_WARN_PERCENT: f64 = 90.0;
176const FS_CRITICAL_PERCENT: f64 = 97.0;
177/// GPU temperature that triggers a warning.
178const GPU_HOT_CELSIUS: f32 = 85.0;
179
180/// Conditions in `snapshot` that need attention, most severe first:
181/// storage errors and failing drives, degraded RAID, full filesystems, hot GPUs.
182pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
183    let mut alerts = Vec::new();
184    let mut add = |severity, text: String| alerts.push(Alert { severity, text });
185    for fs in &snapshot.raid.btrfs {
186        let name = fs
187            .mount_points
188            .first()
189            .map_or(fs.fsid.as_str(), String::as_str);
190        let missing = fs.device_stats.iter().filter(|d| d.missing).count();
191        if missing > 0 {
192            add(
193                Severity::Critical,
194                format!("btrfs {name}: {missing} device(s) missing"),
195            );
196        }
197        let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
198        if errors > 0 {
199            add(
200                Severity::Critical,
201                format!("btrfs {name}: {errors} I/O errors"),
202            );
203        }
204        if let Some(scrub) = &fs.scrub {
205            if scrub.uncorrectable > 0 {
206                add(
207                    Severity::Critical,
208                    format!(
209                        "btrfs {name}: scrub found {} uncorrectable errors",
210                        scrub.uncorrectable
211                    ),
212                );
213            } else if scrub.errors > 0 {
214                add(
215                    Severity::Warning,
216                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
217                );
218            }
219        }
220    }
221    for md in &snapshot.raid.md {
222        if md.active && md.is_degraded() {
223            add(
224                Severity::Critical,
225                format!("{} ({}) degraded", md.name, md.level),
226            );
227        } else if !md.active {
228            add(Severity::Warning, format!("{} inactive", md.name));
229        }
230        if let Some(sync) = &md.sync {
231            add(
232                Severity::Warning,
233                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
234            );
235        }
236    }
237    for smart in &snapshot.smart {
238        if let Some(error) = &smart.error {
239            add(
240                Severity::Warning,
241                format!("{}: SMART unreadable ({error})", smart.device),
242            );
243        } else if smart.passed == Some(false) {
244            add(
245                Severity::Critical,
246                format!("{}: SMART failed", smart.device),
247            );
248        } else if smart.needs_attention() {
249            add(
250                Severity::Critical,
251                format!("{}: SMART warning", smart.device),
252            );
253        }
254    }
255    for fs in &snapshot.filesystems {
256        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
257        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
258        let name = fs
259            .mount_points
260            .first()
261            .map_or(fs.device.as_str(), String::as_str);
262        if percent >= FS_CRITICAL_PERCENT {
263            add(Severity::Critical, format!("{name} {percent:.0}% full"));
264        } else if percent >= FS_WARN_PERCENT {
265            add(Severity::Warning, format!("{name} {percent:.0}% full"));
266        }
267    }
268    for gpu in &snapshot.gpus {
269        if let Some(t) = gpu.temperature_c.filter(|t| *t >= GPU_HOT_CELSIUS) {
270            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
271        }
272    }
273    // Stable sort keeps the order above within each severity.
274    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
275    alerts
276}
277
278/// How the process table is filtered and sorted.
279#[derive(Debug, Clone, Copy)]
280pub struct ProcessQuery<'q> {
281    /// Column to sort by.
282    pub sort_key: SortKey,
283    /// Largest first when true.
284    pub descending: bool,
285    /// Case-insensitive substring of name, command or user, or an exact pid.
286    pub filter: &'q str,
287    /// Include kernel threads (`kworker` and friends).
288    pub show_kernel_threads: bool,
289}
290
291/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
292pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
293    // pid -> (max SM %, total VRAM) across GPUs.
294    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
295    for gpu in &snapshot.gpus {
296        for p in &gpu.processes {
297            let entry = gpu_usage.entry(p.pid).or_default();
298            if let Some(sm) = p.sm_percent {
299                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
300            }
301            if let Some(mem) = p.memory_bytes {
302                entry.1 = Some(entry.1.unwrap_or(0) + mem);
303            }
304        }
305    }
306
307    let filter = query.filter.to_lowercase();
308    let mut rows: Vec<ProcessRow<'a>> = snapshot
309        .processes
310        .iter()
311        .filter(|p| query.show_kernel_threads || !p.kernel_thread)
312        .filter(|p| {
313            filter.is_empty()
314                || p.name.to_lowercase().contains(&filter)
315                || p.command.to_lowercase().contains(&filter)
316                || p.user.to_lowercase().contains(&filter)
317                || p.pid.to_string() == filter
318        })
319        .map(|info| {
320            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
321            ProcessRow {
322                info,
323                gpu_percent,
324                vram_bytes,
325            }
326        })
327        .collect();
328
329    let key = query.sort_key;
330    rows.sort_by(|a, b| {
331        let ordering = match key {
332            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
333            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
334            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
335            SortKey::User => a.info.user.cmp(&b.info.user),
336            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
337            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
338            SortKey::Gpu => a
339                .gpu_percent
340                .unwrap_or(-1.0)
341                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
342            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
343        }
344        // Stable tie-breaker so rows do not jump between frames.
345        .then(a.info.pid.cmp(&b.info.pid));
346        if query.descending {
347            ordering.reverse()
348        } else {
349            ordering
350        }
351    });
352    rows
353}
354
355/// Name of a signal as shown to the user.
356pub fn signal_name(signal: ProcessSignal) -> &'static str {
357    match signal {
358        ProcessSignal::Terminate => "SIGTERM",
359        ProcessSignal::Kill => "SIGKILL",
360    }
361}
362
363/// Returns true for container bridges, veth pairs and loopback, which are
364/// skipped when choosing and listing interfaces.
365pub fn is_virtual_interface(name: &str) -> bool {
366    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
367}
368
369/// Picks the physical interface with the most traffic so far.
370pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
371    snapshot
372        .networks
373        .iter()
374        .filter(|n| !is_virtual_interface(&n.name))
375        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
376        .or_else(|| snapshot.networks.first())
377        .map(|n| n.name.clone())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
384
385    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
386        ProcessInfo {
387            pid,
388            name: name.to_owned(),
389            cpu_percent: cpu,
390            kernel_thread: kernel,
391            ..Default::default()
392        }
393    }
394
395    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
396        ProcessQuery {
397            sort_key,
398            descending: sort_key.descending_by_default(),
399            filter: "",
400            show_kernel_threads: false,
401        }
402    }
403
404    #[test]
405    fn history_keeps_newest_samples() {
406        let mut history = History::default();
407        for i in 0..(HISTORY_LEN + 5) {
408            history.push(i as f64);
409        }
410        assert_eq!(
411            history.last(2),
412            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
413        );
414        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
415        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
416    }
417
418    #[test]
419    fn rows_filter_sort_and_join_gpu_usage() {
420        let snapshot = Snapshot {
421            processes: vec![
422                process(10, "bash", 5.0, false),
423                process(2, "kworker/0:1", 50.0, true),
424                process(30, "python", 1.0, false),
425            ],
426            gpus: vec![GpuStats {
427                processes: vec![GpuProcess {
428                    pid: 30,
429                    memory_bytes: Some(1 << 30),
430                    sm_percent: Some(70.0),
431                }],
432                ..Default::default()
433            }],
434            ..Default::default()
435        };
436        let pids = |q: &ProcessQuery| {
437            process_rows(&snapshot, q)
438                .iter()
439                .map(|r| r.info.pid)
440                .collect::<Vec<_>>()
441        };
442        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
443        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
444        assert_eq!(
445            pids(&ProcessQuery {
446                show_kernel_threads: true,
447                ..query(SortKey::Cpu)
448            }),
449            vec![2, 10, 30]
450        );
451        assert_eq!(
452            pids(&ProcessQuery {
453                filter: "PYT",
454                ..query(SortKey::Pid)
455            }),
456            vec![30]
457        );
458        let rows = process_rows(&snapshot, &query(SortKey::Pid));
459        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
460        assert_eq!(rows[1].gpu_percent, Some(70.0));
461    }
462
463    #[test]
464    fn metric_history_records_and_switches_interfaces() {
465        let snapshot = Snapshot {
466            networks: vec![
467                NetworkStats {
468                    name: "enp9s0".to_owned(),
469                    rx_total_bytes: 10,
470                    rx_bytes_per_sec: 100.0,
471                    ..Default::default()
472                },
473                NetworkStats {
474                    name: "wlp7s0".to_owned(),
475                    tx_bytes_per_sec: 5.0,
476                    ..Default::default()
477                },
478            ],
479            gpus: vec![GpuStats {
480                utilization_percent: Some(40.0),
481                ..Default::default()
482            }],
483            ..Default::default()
484        };
485        let mut history = MetricHistory::default();
486        history.record(&snapshot);
487        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
488        assert_eq!(history.net_rx.last(1), vec![100.0]);
489        assert_eq!(history.gpus[0].last(1), vec![40.0]);
490        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
491        // Switching clears the samples of the previous interface.
492        assert!(history.net_rx.last(10).is_empty());
493        history.record(&snapshot);
494        assert_eq!(history.net_tx.last(1), vec![5.0]);
495    }
496
497    #[test]
498    fn alerts_flag_storage_problems_first() {
499        use crate::snapshot::{
500            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
501        };
502        let snapshot = Snapshot {
503            filesystems: vec![FilesystemUsage {
504                mount_points: vec!["/data".to_owned()],
505                total_bytes: 100,
506                available_bytes: 8,
507                ..Default::default()
508            }],
509            raid: RaidStats {
510                btrfs: vec![BtrfsFilesystem {
511                    mount_points: vec!["/".to_owned()],
512                    device_stats: vec![BtrfsDeviceStats {
513                        devid: 1,
514                        corruption_errs: 2,
515                        ..Default::default()
516                    }],
517                    ..Default::default()
518                }],
519                md: vec![MdArray {
520                    name: "md0".to_owned(),
521                    active: true,
522                    level: "raid1".to_owned(),
523                    expected_devices: Some(2),
524                    working_devices: Some(1),
525                    ..Default::default()
526                }],
527            },
528            smart: vec![SmartDevice {
529                device: "sda".to_owned(),
530                passed: Some(true),
531                ..Default::default()
532            }],
533            ..Default::default()
534        };
535        let alerts = alerts(&snapshot);
536        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
537        assert_eq!(
538            texts,
539            vec![
540                "btrfs /: 2 I/O errors",
541                "md0 (raid1) degraded",
542                "/data 92% full"
543            ]
544        );
545        assert_eq!(alerts[0].severity, Severity::Critical);
546        assert_eq!(alerts[2].severity, Severity::Warning);
547        assert!(super::alerts(&Snapshot::default()).is_empty());
548    }
549
550    #[test]
551    fn interface_helpers() {
552        assert!(is_virtual_interface("veth5a74356"));
553        assert!(!is_virtual_interface("enp9s0"));
554        let snapshot = Snapshot {
555            networks: vec![
556                NetworkStats {
557                    name: "docker0".to_owned(),
558                    rx_total_bytes: 1 << 40,
559                    ..Default::default()
560                },
561                NetworkStats {
562                    name: "enp9s0".to_owned(),
563                    rx_total_bytes: 1 << 30,
564                    ..Default::default()
565                },
566            ],
567            ..Default::default()
568        };
569        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
570        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
571    }
572}