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::time::Duration;
8
9use crate::config::AlertsConfig;
10use crate::format;
11use crate::snapshot::{ProcessInfo, Snapshot};
12use crate::source::ProcessSignal;
13
14/// Number of samples kept for graphs: over an hour at the default interval
15/// of 1 s, and wider than any realistic terminal in braille dots (2 samples
16/// per cell).
17pub const HISTORY_LEN: usize = 4096;
18
19/// Interface name prefixes skipped when choosing the default network
20/// interface (container bridges and virtual links).
21const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
22
23/// Samples averaged into one point of the coarse history (a minute at the
24/// default interval of 1 s).
25pub const COARSE_FACTOR: usize = 60;
26/// Points kept in the coarse history (a day at the default interval).
27pub const COARSE_LEN: usize = 1440;
28
29/// Fixed-size history of samples, newest last, plus a coarse history of
30/// averages for long time spans.
31#[derive(Debug, Default, Clone)]
32pub struct History {
33    samples: VecDeque<f64>,
34    coarse: VecDeque<f64>,
35    /// Sum and count of the samples not yet averaged into `coarse`.
36    pending: (f64, usize),
37}
38
39impl History {
40    /// Appends a sample, dropping the oldest when full.
41    pub fn push(&mut self, value: f64) {
42        if self.samples.len() == HISTORY_LEN {
43            self.samples.pop_front();
44        }
45        self.samples.push_back(value);
46        self.pending.0 += value;
47        self.pending.1 += 1;
48        if self.pending.1 == COARSE_FACTOR {
49            if self.coarse.len() == COARSE_LEN {
50                self.coarse.pop_front();
51            }
52            self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
53            self.pending = (0.0, 0);
54        }
55    }
56
57    /// The newest `n` samples (or fewer), oldest first.
58    pub fn last(&self, n: usize) -> Vec<f64> {
59        let skip = self.samples.len().saturating_sub(n);
60        self.samples.iter().skip(skip).copied().collect()
61    }
62
63    /// Largest of the newest `n` samples.
64    pub fn max_of_last(&self, n: usize) -> f64 {
65        self.last(n).into_iter().fold(0.0, f64::max)
66    }
67
68    /// Values for a graph `columns` wide, oldest first. [`GraphSpan::Fit`]
69    /// gives the newest sample per column; a time span gives the samples of
70    /// that span (one every `interval`) spread over the columns, averaged
71    /// where several share a column and interpolated where there are fewer
72    /// samples than columns. While the history is shorter than the span,
73    /// fewer values come back, so the graph fills up from the right.
74    pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
75        let GraphSpan::Secs(secs) = span else {
76            return self.last(columns);
77        };
78        let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
79        if wanted <= HISTORY_LEN {
80            spread(&self.last(wanted), wanted, columns)
81        } else {
82            // Longer than the samples kept: from the averages.
83            let wanted = wanted.div_ceil(COARSE_FACTOR);
84            let skip = self.coarse.len().saturating_sub(wanted);
85            let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
86            spread(&points, wanted, columns)
87        }
88    }
89}
90
91/// `samples` (the newest of `wanted`) spread over `columns`: averaged where
92/// several share a column, interpolated where there are fewer samples than
93/// columns, and over a proportional share of the columns at the right while
94/// fewer than `wanted` exist.
95fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
96    if columns == 0 || samples.is_empty() {
97        return Vec::new();
98    }
99    // The share of the columns that the available samples cover.
100    let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
101    let shown = shown.clamp(1, columns);
102    let per_column = samples.len() as f64 / shown as f64;
103    if per_column < 1.0 {
104        // Stretched: a straight line between neighbouring samples.
105        let last = samples.len() - 1;
106        return (0..shown)
107            .map(|i| {
108                let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
109                let (below, frac) = (at.floor() as usize, at.fract());
110                let above = (below + 1).min(last);
111                samples[below] * (1.0 - frac) + samples[above] * frac
112            })
113            .collect();
114    }
115    (0..shown)
116        .map(|i| {
117            let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
118            let start = ((i as f64 * per_column) as usize).min(end - 1);
119            let part = &samples[start..end];
120            part.iter().sum::<f64>() / part.len() as f64
121        })
122        .collect()
123}
124
125/// How much time the graphs cover.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum GraphSpan {
128    /// One sample per graph column, so the time covered grows with the
129    /// width and the interval.
130    #[default]
131    Fit,
132    /// The last this many seconds, spread over the width.
133    Secs(u64),
134}
135
136impl GraphSpan {
137    /// The choices [`GraphSpan::next`] steps through.
138    pub const CHOICES: [GraphSpan; 7] = [
139        GraphSpan::Fit,
140        GraphSpan::Secs(60),
141        GraphSpan::Secs(5 * 60),
142        GraphSpan::Secs(15 * 60),
143        GraphSpan::Secs(60 * 60),
144        GraphSpan::Secs(6 * 60 * 60),
145        GraphSpan::Secs(24 * 60 * 60),
146    ];
147
148    /// The choice after this one, back to the first after the last.
149    pub fn next(self) -> Self {
150        let index = Self::CHOICES
151            .iter()
152            .position(|c| *c == self)
153            .map_or(0, |i| i + 1);
154        Self::CHOICES[index % Self::CHOICES.len()]
155    }
156
157    /// Short name: "fit", "1m", "1h".
158    pub fn label(self) -> String {
159        match self {
160            GraphSpan::Fit => "fit".to_owned(),
161            GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
162            GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
163            GraphSpan::Secs(secs) => format!("{secs}s"),
164        }
165    }
166
167    /// Seconds covered, 0 for [`GraphSpan::Fit`] (for storing the choice).
168    pub fn as_secs(self) -> u64 {
169        match self {
170            GraphSpan::Fit => 0,
171            GraphSpan::Secs(secs) => secs,
172        }
173    }
174
175    /// The inverse of [`GraphSpan::as_secs`].
176    pub fn from_secs(secs: u64) -> Self {
177        if secs == 0 {
178            GraphSpan::Fit
179        } else {
180            GraphSpan::Secs(secs)
181        }
182    }
183}
184
185/// Columns the process table can be sorted by.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum SortKey {
188    Pid,
189    Name,
190    Threads,
191    User,
192    Memory,
193    Cpu,
194    /// Disk reads plus writes.
195    Disk,
196    Gpu,
197    Vram,
198}
199
200impl SortKey {
201    /// Sort keys in column order, used to step left/right.
202    pub const ALL: [SortKey; 9] = [
203        SortKey::Pid,
204        SortKey::Name,
205        SortKey::Threads,
206        SortKey::User,
207        SortKey::Memory,
208        SortKey::Cpu,
209        SortKey::Disk,
210        SortKey::Gpu,
211        SortKey::Vram,
212    ];
213
214    /// Whether "descending" is the natural order (largest consumers first).
215    pub fn descending_by_default(self) -> bool {
216        !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
217    }
218}
219
220/// One row of the process table: the process plus its GPU usage.
221#[derive(Debug, Clone)]
222pub struct ProcessRow<'a> {
223    pub info: &'a ProcessInfo,
224    /// Highest per-GPU SM utilization of the process.
225    pub gpu_percent: Option<f32>,
226    /// VRAM used by the process across all GPUs.
227    pub vram_bytes: Option<u64>,
228    /// In the tree view, the lines leading to the process ("│ ├ "), to be
229    /// put before its name; empty otherwise.
230    pub tree_prefix: String,
231}
232
233/// Graph histories of one host, updated from each snapshot.
234#[derive(Debug, Default, Clone)]
235pub struct MetricHistory {
236    /// Total CPU usage (%).
237    pub cpu: History,
238    /// Used memory (% of total).
239    pub memory: History,
240    /// Utilization (%) per GPU index.
241    pub gpus: Vec<History>,
242    /// Receive rate of `net_interface` (bytes/s).
243    pub net_rx: History,
244    /// Transmit rate of `net_interface` (bytes/s).
245    pub net_tx: History,
246    /// Interface whose traffic is recorded (`None`: chosen on the next snapshot).
247    pub net_interface: Option<String>,
248}
249
250impl MetricHistory {
251    /// Appends the values of a new snapshot.
252    pub fn record(&mut self, snapshot: &Snapshot) {
253        self.cpu.push(snapshot.cpu.total_percent as f64);
254        let memory = &snapshot.memory;
255        if memory.total_bytes > 0 {
256            self.memory
257                .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
258        }
259        self.gpus.resize_with(snapshot.gpus.len(), History::default);
260        for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
261            history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
262        }
263        if self.net_interface.is_none() {
264            self.net_interface = default_interface(snapshot);
265        }
266        let (rx, tx) = self
267            .net_interface
268            .as_deref()
269            .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
270            .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
271            .unwrap_or((0.0, 0.0));
272        self.net_rx.push(rx);
273        self.net_tx.push(tx);
274    }
275
276    /// Switches the recorded network interface, clearing its history.
277    pub fn select_interface(&mut self, name: &str) {
278        if self.net_interface.as_deref() != Some(name) {
279            self.net_interface = Some(name.to_owned());
280            // The old samples belong to another interface.
281            self.net_rx = History::default();
282            self.net_tx = History::default();
283        }
284    }
285
286    /// Switches to the interface after the current one in `snapshot` and
287    /// returns its name.
288    pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
289        let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
290        if names.is_empty() {
291            return None;
292        }
293        let current = self
294            .net_interface
295            .as_deref()
296            .and_then(|name| names.iter().position(|n| *n == name));
297        let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
298        self.select_interface(&next);
299        Some(next)
300    }
301}
302
303/// How urgent an [`Alert`] is.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
305pub enum Severity {
306    /// Worth a look (e.g. a filesystem filling up, a RAID resync).
307    Warning,
308    /// Data at risk (errors, a missing device, a failing drive).
309    Critical,
310}
311
312/// A condition on a host that needs attention, for overviews.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct Alert {
315    pub severity: Severity,
316    pub text: String,
317}
318
319/// Identifies an alert across samples: its severity and text with numbers
320/// masked, so "3 I/O errors" becoming "4 I/O errors" or a temperature
321/// changing does not count as a new alert.
322pub fn alert_key(alert: &Alert) -> String {
323    let mut key = format!("{:?}:", alert.severity);
324    let mut in_number = false;
325    for c in alert.text.chars() {
326        if c.is_ascii_digit() || (in_number && c == '.') {
327            if !in_number {
328                key.push('#');
329            }
330            in_number = true;
331        } else {
332            key.push(c);
333            in_number = false;
334        }
335    }
336    key
337}
338
339/// Whether an [`AlertEvent`] is an alert appearing or going away.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum AlertChange {
342    Raised,
343    Cleared,
344}
345
346/// One entry of the [`AlertLog`].
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct AlertEvent {
349    /// When the change was seen, in milliseconds since the Unix epoch.
350    pub at_ms: u64,
351    pub host: String,
352    pub change: AlertChange,
353    /// The alert as raised, or as last seen before it cleared.
354    pub alert: Alert,
355}
356
357/// Events kept by an [`AlertLog`].
358const ALERT_LOG_LEN: usize = 500;
359
360/// The alerts of every host as they appear and clear, so that what
361/// happened while nobody watched can be looked up later.
362#[derive(Debug, Default)]
363pub struct AlertLog {
364    /// Newest last.
365    events: VecDeque<AlertEvent>,
366    /// Each host's alerts at its previous update.
367    active: HashMap<String, Vec<Alert>>,
368    /// Alerts raised since [`AlertLog::mark_read`], and the worst of them.
369    unread: (usize, Option<Severity>),
370}
371
372impl AlertLog {
373    /// Records how the alerts of `host` changed since its previous update.
374    pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) {
375        let previous = self.active.remove(host).unwrap_or_default();
376        let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
377        let (before, now) = (keys(&previous), keys(alerts));
378        let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
379        let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
380        let mut changes: Vec<(AlertChange, &Alert)> = raised
381            .map(|a| (AlertChange::Raised, a))
382            .chain(cleared.map(|a| (AlertChange::Cleared, a)))
383            .collect();
384        // Cleared first, so that a changed alert reads "went away, came back".
385        changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
386        for (change, alert) in changes {
387            if change == AlertChange::Raised {
388                self.unread.0 += 1;
389                self.unread.1 = self.unread.1.max(Some(alert.severity));
390            }
391            if self.events.len() == ALERT_LOG_LEN {
392                self.events.pop_front();
393            }
394            self.events.push_back(AlertEvent {
395                at_ms,
396                host: host.to_owned(),
397                change,
398                alert: alert.clone(),
399            });
400        }
401        self.active.insert(host.to_owned(), alerts.to_vec());
402    }
403
404    /// All events, oldest first.
405    pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
406        self.events.iter()
407    }
408
409    /// Alerts raised since the log was last read, and the most severe one.
410    pub fn unread(&self) -> (usize, Option<Severity>) {
411        self.unread
412    }
413
414    /// Marks every event as read.
415    pub fn mark_read(&mut self) {
416        self.unread = (0, None);
417    }
418}
419
420/// "agent 0.8.2" when `snapshot` comes from a rigtop of another version
421/// than this one (an agent not updated yet), else `None`.
422pub fn version_note(snapshot: &Snapshot) -> Option<String> {
423    let version = &snapshot.host.rigtop_version;
424    (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
425        .then(|| format!("agent {version}"))
426}
427
428/// Hover text explaining a [`version_note`].
429pub fn version_hint(snapshot: &Snapshot) -> String {
430    format!(
431        "this host runs rigtop {}, this machine {}: install the same version on both \
432         for every reading",
433        snapshot.host.rigtop_version,
434        env!("CARGO_PKG_VERSION")
435    )
436}
437
438/// [`alerts_with`] the default thresholds.
439pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
440    alerts_with(snapshot, &AlertsConfig::default())
441}
442
443/// Conditions in `snapshot` that need attention, most severe first:
444/// storage errors and failing drives, degraded RAID, full filesystems, hot
445/// CPUs, GPUs and drives, judged by `limits` (0 turns a threshold off).
446pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
447    // A threshold of 0 (or less) is turned off.
448    let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
449    let mut alerts = Vec::new();
450    let mut add = |severity, text: String| alerts.push(Alert { severity, text });
451    for fs in &snapshot.raid.btrfs {
452        let name = fs
453            .mount_points
454            .first()
455            .map_or(fs.fsid.as_str(), String::as_str);
456        let missing = fs.device_stats.iter().filter(|d| d.missing).count();
457        if missing > 0 {
458            add(
459                Severity::Critical,
460                format!("btrfs {name}: {missing} device(s) missing"),
461            );
462        }
463        let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
464        if errors > 0 {
465            // Which drives: the one to replace.
466            let devices: Vec<String> = fs
467                .device_stats
468                .iter()
469                .filter(|d| d.total_errors() > 0)
470                .map(|d| d.label())
471                .collect();
472            add(
473                Severity::Critical,
474                format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
475            );
476        }
477        if let Some(scrub) = &fs.scrub {
478            if scrub.uncorrectable > 0 {
479                add(
480                    Severity::Critical,
481                    format!(
482                        "btrfs {name}: scrub found {} uncorrectable errors",
483                        scrub.uncorrectable
484                    ),
485                );
486            } else if scrub.errors > 0 {
487                add(
488                    Severity::Warning,
489                    format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
490                );
491            }
492        }
493    }
494    for md in &snapshot.raid.md {
495        if md.active && md.is_degraded() {
496            add(
497                Severity::Critical,
498                format!("{} ({}) degraded", md.name, md.level),
499            );
500        } else if !md.active {
501            add(Severity::Warning, format!("{} inactive", md.name));
502        }
503        if let Some(sync) = &md.sync {
504            add(
505                Severity::Warning,
506                format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
507            );
508        }
509    }
510    for smart in &snapshot.smart {
511        if let Some(error) = &smart.error {
512            add(
513                Severity::Warning,
514                format!("{}: SMART unreadable ({error})", smart.device),
515            );
516        } else if smart.passed == Some(false) {
517            add(
518                Severity::Critical,
519                format!("{}: SMART failed", smart.device),
520            );
521        } else if smart.needs_attention() {
522            add(
523                Severity::Critical,
524                format!("{}: SMART warning", smart.device),
525            );
526        }
527    }
528    for fs in &snapshot.filesystems {
529        let used = fs.total_bytes.saturating_sub(fs.available_bytes);
530        let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
531        let name = fs
532            .mount_points
533            .first()
534            .map_or(fs.device.as_str(), String::as_str);
535        if reached(percent, limits.filesystem_critical_percent) {
536            add(Severity::Critical, format!("{name} {percent:.0}% full"));
537        } else if reached(percent, limits.filesystem_warning_percent) {
538            add(Severity::Warning, format!("{name} {percent:.0}% full"));
539        }
540    }
541    let hottest_cpu = snapshot
542        .cpu
543        .temperatures
544        .iter()
545        .map(|t| t.celsius)
546        .fold(f32::NAN, f32::max);
547    if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
548        add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
549    }
550    for gpu in &snapshot.gpus {
551        if let Some(t) = gpu
552            .temperature_c
553            .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
554        {
555            add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
556        }
557    }
558    for (device, celsius) in drive_temperatures(snapshot) {
559        if reached(celsius as f64, limits.drive_warning_celsius as f64) {
560            add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
561        }
562    }
563    // Stable sort keeps the order above within each severity.
564    alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
565    alerts
566}
567
568/// (device, °C) of each drive: SMART results, then NVMe drives without
569/// them from their hwmon sensor ("Composite", the drive's own summary).
570fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
571    let mut drives: Vec<(String, f32)> = snapshot
572        .smart
573        .iter()
574        .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
575        .collect();
576    for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
577        // The chip key is "nvme:<device>".
578        let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
579        if drives.iter().any(|(d, _)| d == device) {
580            continue;
581        }
582        let composite = chip
583            .temperatures
584            .iter()
585            .find(|t| t.label == "Composite")
586            .or(chip.temperatures.first());
587        if let Some(t) = composite {
588            drives.push((device.to_owned(), t.celsius));
589        }
590    }
591    drives
592}
593
594/// How the process table is filtered and sorted.
595#[derive(Debug, Clone, Copy)]
596pub struct ProcessQuery<'q> {
597    /// Column to sort by.
598    pub sort_key: SortKey,
599    /// Largest first when true.
600    pub descending: bool,
601    /// Case-insensitive substring of name, command or user, or an exact pid.
602    pub filter: &'q str,
603    /// Include kernel threads (`kworker` and friends).
604    pub show_kernel_threads: bool,
605    /// Children under their parents, siblings sorted by `sort_key`. A
606    /// filter keeps the ancestors of what it matches.
607    pub tree: bool,
608}
609
610/// Processes of `snapshot` after filtering and sorting, joined with GPU usage.
611pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
612    // pid -> (max SM %, total VRAM) across GPUs.
613    let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
614    for gpu in &snapshot.gpus {
615        for p in &gpu.processes {
616            let entry = gpu_usage.entry(p.pid).or_default();
617            if let Some(sm) = p.sm_percent {
618                entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
619            }
620            if let Some(mem) = p.memory_bytes {
621                entry.1 = Some(entry.1.unwrap_or(0) + mem);
622            }
623        }
624    }
625
626    let filter = query.filter.to_lowercase();
627    let matches = |p: &ProcessInfo| {
628        filter.is_empty()
629            || p.name.to_lowercase().contains(&filter)
630            || p.command.to_lowercase().contains(&filter)
631            || p.user.to_lowercase().contains(&filter)
632            || p.pid.to_string() == filter
633    };
634    let candidates = snapshot
635        .processes
636        .iter()
637        .filter(|p| query.show_kernel_threads || !p.kernel_thread);
638    let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
639        with_ancestors(candidates.collect(), matches)
640    } else {
641        candidates.filter(|p| matches(p)).collect()
642    };
643    let mut rows: Vec<ProcessRow<'a>> = shown
644        .into_iter()
645        .map(|info| {
646            let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
647            ProcessRow {
648                info,
649                gpu_percent,
650                vram_bytes,
651                tree_prefix: String::new(),
652            }
653        })
654        .collect();
655
656    let key = query.sort_key;
657    rows.sort_by(|a, b| {
658        let ordering = match key {
659            SortKey::Pid => a.info.pid.cmp(&b.info.pid),
660            SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
661            SortKey::Threads => a.info.threads.cmp(&b.info.threads),
662            SortKey::User => a.info.user.cmp(&b.info.user),
663            SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
664            SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
665            SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
666            SortKey::Gpu => a
667                .gpu_percent
668                .unwrap_or(-1.0)
669                .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
670            SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
671        }
672        // Stable tie-breaker so rows do not jump between frames.
673        .then(a.info.pid.cmp(&b.info.pid));
674        if query.descending {
675            ordering.reverse()
676        } else {
677            ordering
678        }
679    });
680    if query.tree { into_tree(rows) } else { rows }
681}
682
683/// The processes `matches` accepts, plus their ancestors among `processes`
684/// (so a filtered tree keeps its shape), in the order of `processes`.
685fn with_ancestors(
686    processes: Vec<&ProcessInfo>,
687    matches: impl Fn(&ProcessInfo) -> bool,
688) -> Vec<&ProcessInfo> {
689    let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
690    let mut keep: HashSet<u32> = HashSet::new();
691    for process in processes.iter().filter(|p| matches(p)) {
692        let mut current = Some(*process);
693        // Stops at a process already kept, which also ends parent loops.
694        while let Some(p) = current
695            && keep.insert(p.pid)
696        {
697            current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
698        }
699    }
700    processes
701        .into_iter()
702        .filter(|p| keep.contains(&p.pid))
703        .collect()
704}
705
706/// Reorders sorted `rows` depth-first under their parents, keeping the
707/// sorted order among siblings, and fills in [`ProcessRow::tree_prefix`].
708/// Processes whose parent is not among the rows are roots.
709fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
710    let index: HashMap<u32, usize> = rows
711        .iter()
712        .enumerate()
713        .map(|(i, row)| (row.info.pid, i))
714        .collect();
715    let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
716    let mut roots = Vec::new();
717    for (i, row) in rows.iter().enumerate() {
718        match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
719            Some(&parent) if parent != i => children[parent].push(i),
720            _ => roots.push(i),
721        }
722    }
723    let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
724    let mut visited = vec![false; rows.len()];
725    // Processes in a parent loop have no root; they start trees of their own.
726    let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
727    for start in starts {
728        // (row, lines of the ancestors, whether it is its parent's last child)
729        let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
730        while let Some((i, lead, last)) = stack.pop() {
731            if std::mem::replace(&mut visited[i], true) {
732                continue;
733            }
734            let (prefix, child_lead) = match last {
735                None => (String::new(), String::new()),
736                Some(true) => (format!("{lead}└ "), format!("{lead}  ")),
737                Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
738            };
739            order.push((i, prefix));
740            let kids = &children[i];
741            for (k, &child) in kids.iter().enumerate().rev() {
742                stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
743            }
744        }
745    }
746    let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
747    order
748        .into_iter()
749        .filter_map(|(i, prefix)| {
750            let mut row = rows[i].take()?;
751            row.tree_prefix = prefix;
752            Some(row)
753        })
754        .collect()
755}
756
757/// Disk reads plus writes of a process (bytes/s), the Disk column.
758pub fn disk_rate(process: &ProcessInfo) -> f64 {
759    process.read_bytes_per_sec + process.write_bytes_per_sec
760}
761
762/// The Disk column's text: the total rate, blank when idle (also where the
763/// counters cannot be read, e.g. other users' processes on Linux).
764pub fn disk_rate_text(process: &ProcessInfo) -> String {
765    let rate = disk_rate(process);
766    if rate > 0.0 {
767        format::rate(rate)
768    } else {
769        String::new()
770    }
771}
772
773/// Shown where the CPU temperatures go when LibreHardwareMonitor cannot be
774/// read (Windows; see [`Snapshot::lhm_error`]).
775pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
776
777/// Explanation behind [`LHM_HINT`], given the reason it could not be read.
778pub fn lhm_help(error: &str) -> String {
779    format!(
780        "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
781         Start it and turn on Options → Remote Web Server → Run.\n\
782         ([lhm] url = \"\" in the configuration hides this note.)\n\n\
783         {error}"
784    )
785}
786
787/// Name of a signal as shown to the user.
788pub fn signal_name(signal: ProcessSignal) -> &'static str {
789    match signal {
790        ProcessSignal::Terminate => "SIGTERM",
791        ProcessSignal::Kill => "SIGKILL",
792    }
793}
794
795/// Returns true for container bridges, veth pairs and loopback, which are
796/// skipped when choosing and listing interfaces.
797pub fn is_virtual_interface(name: &str) -> bool {
798    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
799}
800
801/// Picks the physical interface with the most traffic so far.
802pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
803    snapshot
804        .networks
805        .iter()
806        .filter(|n| !is_virtual_interface(&n.name))
807        .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
808        .or_else(|| snapshot.networks.first())
809        .map(|n| n.name.clone())
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
816
817    fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
818        ProcessInfo {
819            pid,
820            name: name.to_owned(),
821            cpu_percent: cpu,
822            kernel_thread: kernel,
823            ..Default::default()
824        }
825    }
826
827    fn query(sort_key: SortKey) -> ProcessQuery<'static> {
828        ProcessQuery {
829            sort_key,
830            descending: sort_key.descending_by_default(),
831            filter: "",
832            show_kernel_threads: false,
833            tree: false,
834        }
835    }
836
837    #[test]
838    fn history_keeps_newest_samples() {
839        let mut history = History::default();
840        for i in 0..(HISTORY_LEN + 5) {
841            history.push(i as f64);
842        }
843        assert_eq!(
844            history.last(2),
845            vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
846        );
847        assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
848        assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
849    }
850
851    #[test]
852    fn graph_spans_spread_samples_over_the_width() {
853        let second = Duration::from_secs(1);
854        let mut history = History::default();
855        for i in 0..120 {
856            history.push(i as f64);
857        }
858        // Fit: one sample per column, the newest ones.
859        assert_eq!(
860            history.graph(3, GraphSpan::Fit, second),
861            vec![117.0, 118.0, 119.0]
862        );
863        // A minute on 30 columns: two samples averaged per column.
864        let minute = history.graph(30, GraphSpan::Secs(60), second);
865        assert_eq!(minute.len(), 30);
866        assert_eq!(minute[0], 60.5);
867        assert_eq!(minute[29], 118.5);
868        // Fewer samples than columns: interpolated between them.
869        let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
870        assert_eq!(stretched.len(), 120);
871        assert_eq!(&stretched[..2], &[90.0, 90.0]);
872        assert_eq!(&stretched[2..4], &[90.125, 90.375]);
873        assert_eq!(stretched[119], 119.0);
874        assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
875        // An hour with two minutes recorded fills the right 1/30.
876        let hour = history.graph(300, GraphSpan::Secs(3600), second);
877        assert_eq!(hour.len(), 10);
878        assert_eq!(hour[0], 5.5);
879        assert!(
880            History::default()
881                .graph(10, GraphSpan::Secs(60), second)
882                .is_empty()
883        );
884    }
885
886    #[test]
887    fn long_spans_draw_from_the_averages() {
888        let second = Duration::from_secs(1);
889        let mut history = History::default();
890        // Two hours: the first at 0, the second at 100.
891        for i in 0..7200 {
892            history.push(if i < 3600 { 0.0 } else { 100.0 });
893        }
894        // 6 hours with 2 recorded: the right third, half low and half high.
895        let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
896        assert_eq!(six.len(), 30);
897        assert_eq!((six[0], six[29]), (0.0, 100.0));
898        // An hour still comes from the samples themselves.
899        let hour = history.graph(60, GraphSpan::Secs(3600), second);
900        assert!(hour.iter().all(|v| *v == 100.0));
901        // The averages stay bounded.
902        for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
903            history.push(i as f64);
904        }
905        assert_eq!(history.coarse.len(), COARSE_LEN);
906    }
907
908    #[test]
909    fn graph_span_choices_cycle() {
910        let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
911            .take(6)
912            .map(GraphSpan::label)
913            .collect();
914        assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
915        assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
916        assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
917        assert_eq!(
918            GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
919            GraphSpan::Secs(300)
920        );
921        assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
922        // A stored span that is not a choice starts over.
923        assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
924    }
925
926    #[test]
927    fn rows_filter_sort_and_join_gpu_usage() {
928        let snapshot = Snapshot {
929            processes: vec![
930                process(10, "bash", 5.0, false),
931                process(2, "kworker/0:1", 50.0, true),
932                process(30, "python", 1.0, false),
933            ],
934            gpus: vec![GpuStats {
935                processes: vec![GpuProcess {
936                    pid: 30,
937                    memory_bytes: Some(1 << 30),
938                    sm_percent: Some(70.0),
939                }],
940                ..Default::default()
941            }],
942            ..Default::default()
943        };
944        let pids = |q: &ProcessQuery| {
945            process_rows(&snapshot, q)
946                .iter()
947                .map(|r| r.info.pid)
948                .collect::<Vec<_>>()
949        };
950        assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
951        assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
952        assert_eq!(
953            pids(&ProcessQuery {
954                show_kernel_threads: true,
955                ..query(SortKey::Cpu)
956            }),
957            vec![2, 10, 30]
958        );
959        assert_eq!(
960            pids(&ProcessQuery {
961                filter: "PYT",
962                ..query(SortKey::Pid)
963            }),
964            vec![30]
965        );
966        let rows = process_rows(&snapshot, &query(SortKey::Pid));
967        assert_eq!(rows[1].vram_bytes, Some(1 << 30));
968        assert_eq!(rows[1].gpu_percent, Some(70.0));
969    }
970
971    #[test]
972    fn tree_puts_children_under_parents() {
973        let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
974            parent_pid: Some(parent),
975            ..process(pid, name, cpu, false)
976        };
977        let snapshot = Snapshot {
978            processes: vec![
979                process(1, "init", 0.0, false),
980                child(10, 1, "sshd", 1.0),
981                child(11, 10, "bash", 2.0),
982                child(12, 11, "python", 50.0),
983                child(20, 1, "cron", 3.0),
984                // Parent not listed (e.g. not readable): a root of its own.
985                child(30, 999, "orphan", 4.0),
986                // A parent loop must not hang or drop the processes.
987                child(40, 41, "loop-a", 0.0),
988                child(41, 40, "loop-b", 0.0),
989            ],
990            ..Default::default()
991        };
992        let tree = |filter| {
993            process_rows(
994                &snapshot,
995                &ProcessQuery {
996                    filter,
997                    tree: true,
998                    ..query(SortKey::Cpu)
999                },
1000            )
1001            .iter()
1002            .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1003            .collect::<Vec<_>>()
1004        };
1005        // Roots and siblings by CPU, largest first (ties: higher pid first).
1006        assert_eq!(
1007            tree(""),
1008            vec![
1009                "orphan",
1010                "init",
1011                "├ cron",
1012                "└ sshd",
1013                "  └ bash",
1014                "    └ python",
1015                "loop-b",
1016                "└ loop-a",
1017            ]
1018        );
1019        // A filter keeps the ancestors of what it matches.
1020        assert_eq!(
1021            tree("pyth"),
1022            vec!["init", "└ sshd", "  └ bash", "    └ python"]
1023        );
1024    }
1025
1026    #[test]
1027    fn disk_column_sums_reads_and_writes() {
1028        let reader = ProcessInfo {
1029            read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1030            ..process(10, "reader", 0.0, false)
1031        };
1032        let writer = ProcessInfo {
1033            read_bytes_per_sec: 1024.0 * 1024.0,
1034            write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1035            ..process(20, "writer", 0.0, false)
1036        };
1037        let idle = process(30, "idle", 0.0, false);
1038        assert_eq!(disk_rate_text(&writer), "5.0M/s");
1039        assert_eq!(disk_rate_text(&idle), "");
1040        let snapshot = Snapshot {
1041            processes: vec![idle, reader, writer],
1042            ..Default::default()
1043        };
1044        let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1045            .iter()
1046            .map(|r| r.info.pid)
1047            .collect();
1048        assert_eq!(pids, vec![20, 10, 30]);
1049    }
1050
1051    #[test]
1052    fn metric_history_records_and_switches_interfaces() {
1053        let snapshot = Snapshot {
1054            networks: vec![
1055                NetworkStats {
1056                    name: "enp9s0".to_owned(),
1057                    rx_total_bytes: 10,
1058                    rx_bytes_per_sec: 100.0,
1059                    ..Default::default()
1060                },
1061                NetworkStats {
1062                    name: "wlp7s0".to_owned(),
1063                    tx_bytes_per_sec: 5.0,
1064                    ..Default::default()
1065                },
1066            ],
1067            gpus: vec![GpuStats {
1068                utilization_percent: Some(40.0),
1069                ..Default::default()
1070            }],
1071            ..Default::default()
1072        };
1073        let mut history = MetricHistory::default();
1074        history.record(&snapshot);
1075        assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1076        assert_eq!(history.net_rx.last(1), vec![100.0]);
1077        assert_eq!(history.gpus[0].last(1), vec![40.0]);
1078        assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1079        // Switching clears the samples of the previous interface.
1080        assert!(history.net_rx.last(10).is_empty());
1081        history.record(&snapshot);
1082        assert_eq!(history.net_tx.last(1), vec![5.0]);
1083    }
1084
1085    #[test]
1086    fn alerts_flag_storage_problems_first() {
1087        use crate::snapshot::{
1088            BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1089        };
1090        let snapshot = Snapshot {
1091            filesystems: vec![FilesystemUsage {
1092                mount_points: vec!["/data".to_owned()],
1093                total_bytes: 100,
1094                available_bytes: 8,
1095                ..Default::default()
1096            }],
1097            raid: RaidStats {
1098                btrfs: vec![BtrfsFilesystem {
1099                    mount_points: vec!["/".to_owned()],
1100                    device_stats: vec![BtrfsDeviceStats {
1101                        devid: 1,
1102                        corruption_errs: 2,
1103                        ..Default::default()
1104                    }],
1105                    ..Default::default()
1106                }],
1107                md: vec![MdArray {
1108                    name: "md0".to_owned(),
1109                    active: true,
1110                    level: "raid1".to_owned(),
1111                    expected_devices: Some(2),
1112                    working_devices: Some(1),
1113                    ..Default::default()
1114                }],
1115            },
1116            smart: vec![SmartDevice {
1117                device: "sda".to_owned(),
1118                passed: Some(true),
1119                ..Default::default()
1120            }],
1121            ..Default::default()
1122        };
1123        let alerts = alerts(&snapshot);
1124        let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1125        assert_eq!(
1126            texts,
1127            vec![
1128                "btrfs /: 2 I/O errors (devid 1)",
1129                "md0 (raid1) degraded",
1130                "/data 92% full"
1131            ]
1132        );
1133        assert_eq!(alerts[0].severity, Severity::Critical);
1134        assert_eq!(alerts[2].severity, Severity::Warning);
1135        assert!(super::alerts(&Snapshot::default()).is_empty());
1136    }
1137
1138    #[test]
1139    fn version_notes_only_for_other_versions() {
1140        let mut snapshot = Snapshot::default();
1141        assert_eq!(version_note(&snapshot), None);
1142        snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1143        assert_eq!(version_note(&snapshot), None);
1144        snapshot.host.rigtop_version = "0.1.0".to_owned();
1145        assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1146        assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1147    }
1148
1149    #[test]
1150    fn alert_log_records_alerts_appearing_and_clearing() {
1151        let alert = |severity, text: &str| Alert {
1152            severity,
1153            text: text.to_owned(),
1154        };
1155        let full = alert(Severity::Warning, "/data 91% full");
1156        let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1157        let mut log = AlertLog::default();
1158        log.update("nas", std::slice::from_ref(&full), 1_000);
1159        // Numbers changing is the same alert.
1160        log.update(
1161            "nas",
1162            &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1163            2_000,
1164        );
1165        log.update("zefat", &[], 2_000);
1166        assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1167        log.mark_read();
1168        log.update("nas", std::slice::from_ref(&errors), 3_000);
1169        assert_eq!(log.unread(), (0, None));
1170        let summary: Vec<(u64, AlertChange, &str)> = log
1171            .events()
1172            .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1173            .collect();
1174        assert_eq!(
1175            summary,
1176            [
1177                (1_000, AlertChange::Raised, "/data 91% full"),
1178                (
1179                    2_000,
1180                    AlertChange::Raised,
1181                    "btrfs /: 2 I/O errors (nvme1n1)"
1182                ),
1183                (3_000, AlertChange::Cleared, "/data 92% full"),
1184            ]
1185        );
1186        for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1187            log.update(
1188                "flappy",
1189                &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1190                i,
1191            );
1192        }
1193        assert_eq!(log.events().count(), ALERT_LOG_LEN);
1194    }
1195
1196    #[test]
1197    fn alert_thresholds_come_from_the_configuration() {
1198        use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1199        let temp = |label: &str, celsius| Temperature {
1200            label: label.to_owned(),
1201            celsius,
1202            ..Default::default()
1203        };
1204        let mut snapshot = Snapshot {
1205            filesystems: vec![FilesystemUsage {
1206                mount_points: vec!["/data".to_owned()],
1207                total_bytes: 100,
1208                available_bytes: 8,
1209                ..Default::default()
1210            }],
1211            gpus: vec![GpuStats {
1212                index: 0,
1213                temperature_c: Some(80.0),
1214                ..Default::default()
1215            }],
1216            smart: vec![SmartDevice {
1217                device: "nvme0".to_owned(),
1218                temperature_c: Some(72.0),
1219                ..Default::default()
1220            }],
1221            // nvme0 also has SMART (reported once); nvme1 only its sensor.
1222            sensors: vec![
1223                SensorChip {
1224                    key: "nvme:nvme0".to_owned(),
1225                    name: "nvme".to_owned(),
1226                    temperatures: vec![temp("Composite", 71.0)],
1227                    ..Default::default()
1228                },
1229                SensorChip {
1230                    key: "nvme:nvme1".to_owned(),
1231                    name: "nvme".to_owned(),
1232                    temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1233                    ..Default::default()
1234                },
1235            ],
1236            ..Default::default()
1237        };
1238        snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1239        let texts = |limits: &AlertsConfig| -> Vec<String> {
1240            alerts_with(&snapshot, limits)
1241                .into_iter()
1242                .map(|a| a.text)
1243                .collect()
1244        };
1245        assert_eq!(
1246            texts(&AlertsConfig::default()),
1247            [
1248                "/data 92% full",
1249                "CPU at 97°C",
1250                "nvme0 at 72°C",
1251                "nvme1 at 74°C"
1252            ]
1253        );
1254        let custom = AlertsConfig {
1255            filesystem_warning_percent: 0.0,
1256            cpu_warning_celsius: 0.0,
1257            gpu_warning_celsius: 75.0,
1258            drive_warning_celsius: 73.0,
1259            ..Default::default()
1260        };
1261        assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1262        let critical = AlertsConfig {
1263            filesystem_critical_percent: 90.0,
1264            ..custom
1265        };
1266        assert_eq!(
1267            alerts_with(&snapshot, &critical)[0],
1268            Alert {
1269                severity: Severity::Critical,
1270                text: "/data 92% full".to_owned()
1271            }
1272        );
1273    }
1274
1275    #[test]
1276    fn interface_helpers() {
1277        assert!(is_virtual_interface("veth5a74356"));
1278        assert!(!is_virtual_interface("enp9s0"));
1279        let snapshot = Snapshot {
1280            networks: vec![
1281                NetworkStats {
1282                    name: "docker0".to_owned(),
1283                    rx_total_bytes: 1 << 40,
1284                    ..Default::default()
1285                },
1286                NetworkStats {
1287                    name: "enp9s0".to_owned(),
1288                    rx_total_bytes: 1 << 30,
1289                    ..Default::default()
1290                },
1291            ],
1292            ..Default::default()
1293        };
1294        assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1295        assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1296    }
1297}