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            // Which drives: the one to replace.
200            let devices: Vec<String> = fs
201                .device_stats
202                .iter()
203                .filter(|d| d.total_errors() > 0)
204                .map(|d| d.label())
205                .collect();
206            add(
207                Severity::Critical,
208                format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
209            );
210        }
211        if let Some(scrub) = &fs.scrub {
212            if scrub.uncorrectable > 0 {
213                add(
214                    Severity::Critical,
215                    format!(
216                        "btrfs {name}: scrub found {} uncorrectable errors",
217                        scrub.uncorrectable
218                    ),
219                );
220            } else if scrub.errors > 0 {
221                add(
222                    Severity::Warning,
223                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
224                );
225            }
226        }
227    }
228    for md in &snapshot.raid.md {
229        if md.active && md.is_degraded() {
230            add(
231                Severity::Critical,
232                format!("{} ({}) degraded", md.name, md.level),
233            );
234        } else if !md.active {
235            add(Severity::Warning, format!("{} inactive", md.name));
236        }
237        if let Some(sync) = &md.sync {
238            add(
239                Severity::Warning,
240                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
241            );
242        }
243    }
244    for smart in &snapshot.smart {
245        if let Some(error) = &smart.error {
246            add(
247                Severity::Warning,
248                format!("{}: SMART unreadable ({error})", smart.device),
249            );
250        } else if smart.passed == Some(false) {
251            add(
252                Severity::Critical,
253                format!("{}: SMART failed", smart.device),
254            );
255        } else if smart.needs_attention() {
256            add(
257                Severity::Critical,
258                format!("{}: SMART warning", smart.device),
259            );
260        }
261    }
262    for fs in &snapshot.filesystems {
263        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
264        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
265        let name = fs
266            .mount_points
267            .first()
268            .map_or(fs.device.as_str(), String::as_str);
269        if percent >= FS_CRITICAL_PERCENT {
270            add(Severity::Critical, format!("{name} {percent:.0}% full"));
271        } else if percent >= FS_WARN_PERCENT {
272            add(Severity::Warning, format!("{name} {percent:.0}% full"));
273        }
274    }
275    for gpu in &snapshot.gpus {
276        if let Some(t) = gpu.temperature_c.filter(|t| *t >= GPU_HOT_CELSIUS) {
277            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
278        }
279    }
280    // Stable sort keeps the order above within each severity.
281    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
282    alerts
283}
284
285/// How the process table is filtered and sorted.
286#[derive(Debug, Clone, Copy)]
287pub struct ProcessQuery<'q> {
288    /// Column to sort by.
289    pub sort_key: SortKey,
290    /// Largest first when true.
291    pub descending: bool,
292    /// Case-insensitive substring of name, command or user, or an exact pid.
293    pub filter: &'q str,
294    /// Include kernel threads (`kworker` and friends).
295    pub show_kernel_threads: bool,
296}
297
298/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
299pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
300    // pid -> (max SM %, total VRAM) across GPUs.
301    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
302    for gpu in &snapshot.gpus {
303        for p in &gpu.processes {
304            let entry = gpu_usage.entry(p.pid).or_default();
305            if let Some(sm) = p.sm_percent {
306                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
307            }
308            if let Some(mem) = p.memory_bytes {
309                entry.1 = Some(entry.1.unwrap_or(0) + mem);
310            }
311        }
312    }
313
314    let filter = query.filter.to_lowercase();
315    let mut rows: Vec<ProcessRow<'a>> = snapshot
316        .processes
317        .iter()
318        .filter(|p| query.show_kernel_threads || !p.kernel_thread)
319        .filter(|p| {
320            filter.is_empty()
321                || p.name.to_lowercase().contains(&filter)
322                || p.command.to_lowercase().contains(&filter)
323                || p.user.to_lowercase().contains(&filter)
324                || p.pid.to_string() == filter
325        })
326        .map(|info| {
327            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
328            ProcessRow {
329                info,
330                gpu_percent,
331                vram_bytes,
332            }
333        })
334        .collect();
335
336    let key = query.sort_key;
337    rows.sort_by(|a, b| {
338        let ordering = match key {
339            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
340            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
341            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
342            SortKey::User => a.info.user.cmp(&b.info.user),
343            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
344            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
345            SortKey::Gpu => a
346                .gpu_percent
347                .unwrap_or(-1.0)
348                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
349            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
350        }
351        // Stable tie-breaker so rows do not jump between frames.
352        .then(a.info.pid.cmp(&b.info.pid));
353        if query.descending {
354            ordering.reverse()
355        } else {
356            ordering
357        }
358    });
359    rows
360}
361
362/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
363/// read (Windows; see [`Snapshot::lhm_error`]).
364pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
365
366/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
367pub fn lhm_help(error: &str) -> String {
368    format!(
369        "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
370         Start it and turn on Options → Remote Web Server → Run.\n\
371         ([lhm] url = \"\" in the configuration hides this note.)\n\n\
372         {error}"
373    )
374}
375
376/// Name of a signal as shown to the user.
377pub fn signal_name(signal: ProcessSignal) -> &'static str {
378    match signal {
379        ProcessSignal::Terminate => "SIGTERM",
380        ProcessSignal::Kill => "SIGKILL",
381    }
382}
383
384/// Returns true for container bridges, veth pairs and loopback, which are
385/// skipped when choosing and listing interfaces.
386pub fn is_virtual_interface(name: &str) -> bool {
387    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
388}
389
390/// Picks the physical interface with the most traffic so far.
391pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
392    snapshot
393        .networks
394        .iter()
395        .filter(|n| !is_virtual_interface(&n.name))
396        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
397        .or_else(|| snapshot.networks.first())
398        .map(|n| n.name.clone())
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
405
406    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
407        ProcessInfo {
408            pid,
409            name: name.to_owned(),
410            cpu_percent: cpu,
411            kernel_thread: kernel,
412            ..Default::default()
413        }
414    }
415
416    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
417        ProcessQuery {
418            sort_key,
419            descending: sort_key.descending_by_default(),
420            filter: "",
421            show_kernel_threads: false,
422        }
423    }
424
425    #[test]
426    fn history_keeps_newest_samples() {
427        let mut history = History::default();
428        for i in 0..(HISTORY_LEN + 5) {
429            history.push(i as f64);
430        }
431        assert_eq!(
432            history.last(2),
433            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
434        );
435        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
436        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
437    }
438
439    #[test]
440    fn rows_filter_sort_and_join_gpu_usage() {
441        let snapshot = Snapshot {
442            processes: vec![
443                process(10, "bash", 5.0, false),
444                process(2, "kworker/0:1", 50.0, true),
445                process(30, "python", 1.0, false),
446            ],
447            gpus: vec![GpuStats {
448                processes: vec![GpuProcess {
449                    pid: 30,
450                    memory_bytes: Some(1 << 30),
451                    sm_percent: Some(70.0),
452                }],
453                ..Default::default()
454            }],
455            ..Default::default()
456        };
457        let pids = |q: &ProcessQuery| {
458            process_rows(&snapshot, q)
459                .iter()
460                .map(|r| r.info.pid)
461                .collect::<Vec<_>>()
462        };
463        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
464        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
465        assert_eq!(
466            pids(&ProcessQuery {
467                show_kernel_threads: true,
468                ..query(SortKey::Cpu)
469            }),
470            vec![2, 10, 30]
471        );
472        assert_eq!(
473            pids(&ProcessQuery {
474                filter: "PYT",
475                ..query(SortKey::Pid)
476            }),
477            vec![30]
478        );
479        let rows = process_rows(&snapshot, &query(SortKey::Pid));
480        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
481        assert_eq!(rows[1].gpu_percent, Some(70.0));
482    }
483
484    #[test]
485    fn metric_history_records_and_switches_interfaces() {
486        let snapshot = Snapshot {
487            networks: vec![
488                NetworkStats {
489                    name: "enp9s0".to_owned(),
490                    rx_total_bytes: 10,
491                    rx_bytes_per_sec: 100.0,
492                    ..Default::default()
493                },
494                NetworkStats {
495                    name: "wlp7s0".to_owned(),
496                    tx_bytes_per_sec: 5.0,
497                    ..Default::default()
498                },
499            ],
500            gpus: vec![GpuStats {
501                utilization_percent: Some(40.0),
502                ..Default::default()
503            }],
504            ..Default::default()
505        };
506        let mut history = MetricHistory::default();
507        history.record(&snapshot);
508        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
509        assert_eq!(history.net_rx.last(1), vec![100.0]);
510        assert_eq!(history.gpus[0].last(1), vec![40.0]);
511        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
512        // Switching clears the samples of the previous interface.
513        assert!(history.net_rx.last(10).is_empty());
514        history.record(&snapshot);
515        assert_eq!(history.net_tx.last(1), vec![5.0]);
516    }
517
518    #[test]
519    fn alerts_flag_storage_problems_first() {
520        use crate::snapshot::{
521            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
522        };
523        let snapshot = Snapshot {
524            filesystems: vec![FilesystemUsage {
525                mount_points: vec!["/data".to_owned()],
526                total_bytes: 100,
527                available_bytes: 8,
528                ..Default::default()
529            }],
530            raid: RaidStats {
531                btrfs: vec![BtrfsFilesystem {
532                    mount_points: vec!["/".to_owned()],
533                    device_stats: vec![BtrfsDeviceStats {
534                        devid: 1,
535                        corruption_errs: 2,
536                        ..Default::default()
537                    }],
538                    ..Default::default()
539                }],
540                md: vec![MdArray {
541                    name: "md0".to_owned(),
542                    active: true,
543                    level: "raid1".to_owned(),
544                    expected_devices: Some(2),
545                    working_devices: Some(1),
546                    ..Default::default()
547                }],
548            },
549            smart: vec![SmartDevice {
550                device: "sda".to_owned(),
551                passed: Some(true),
552                ..Default::default()
553            }],
554            ..Default::default()
555        };
556        let alerts = alerts(&snapshot);
557        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
558        assert_eq!(
559            texts,
560            vec![
561                "btrfs /: 2 I/O errors (devid 1)",
562                "md0 (raid1) degraded",
563                "/data 92% full"
564            ]
565        );
566        assert_eq!(alerts[0].severity, Severity::Critical);
567        assert_eq!(alerts[2].severity, Severity::Warning);
568        assert!(super::alerts(&Snapshot::default()).is_empty());
569    }
570
571    #[test]
572    fn interface_helpers() {
573        assert!(is_virtual_interface("veth5a74356"));
574        assert!(!is_virtual_interface("enp9s0"));
575        let snapshot = Snapshot {
576            networks: vec![
577                NetworkStats {
578                    name: "docker0".to_owned(),
579                    rx_total_bytes: 1 << 40,
580                    ..Default::default()
581                },
582                NetworkStats {
583                    name: "enp9s0".to_owned(),
584                    rx_total_bytes: 1 << 30,
585                    ..Default::default()
586                },
587            ],
588            ..Default::default()
589        };
590        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
591        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
592    }
593}