1use std::collections::{HashMap, HashSet, VecDeque};
7use std::fs::{self, OpenOptions};
8use std::io::Write;
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime};
11
12use chrono::{DateTime, Local};
13use serde::{Deserialize, Serialize};
14
15use crate::config::{AlertsConfig, IntegratedGpu};
16use crate::format;
17use crate::snapshot::{GpuStats, NetworkStats, OomKill, PcieLink, Pressure, ProcessInfo, Snapshot};
18use crate::source::ProcessSignal;
19
20pub const HISTORY_LEN: usize = 4096;
24
25const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
28
29pub const COARSE_FACTOR: usize = 60;
32pub const COARSE_LEN: usize = 1440;
34
35#[derive(Debug, Default, Clone)]
38pub struct History {
39 samples: VecDeque<f64>,
40 coarse: VecDeque<f64>,
41 pending: (f64, usize),
43}
44
45impl History {
46 pub fn push(&mut self, value: f64) {
48 if self.samples.len() == HISTORY_LEN {
49 self.samples.pop_front();
50 }
51 self.samples.push_back(value);
52 self.pending.0 += value;
53 self.pending.1 += 1;
54 if self.pending.1 == COARSE_FACTOR {
55 if self.coarse.len() == COARSE_LEN {
56 self.coarse.pop_front();
57 }
58 self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
59 self.pending = (0.0, 0);
60 }
61 }
62
63 pub fn last(&self, n: usize) -> Vec<f64> {
65 let skip = self.samples.len().saturating_sub(n);
66 self.samples.iter().skip(skip).copied().collect()
67 }
68
69 pub fn max_of_last(&self, n: usize) -> f64 {
71 self.last(n).into_iter().fold(0.0, f64::max)
72 }
73
74 pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
81 let GraphSpan::Secs(secs) = span else {
82 return self.last(columns);
83 };
84 let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
85 if wanted <= HISTORY_LEN {
86 spread(&self.last(wanted), wanted, columns)
87 } else {
88 let wanted = wanted.div_ceil(COARSE_FACTOR);
90 let skip = self.coarse.len().saturating_sub(wanted);
91 let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
92 spread(&points, wanted, columns)
93 }
94 }
95}
96
97fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
102 if columns == 0 || samples.is_empty() {
103 return Vec::new();
104 }
105 let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
107 let shown = shown.clamp(1, columns);
108 let per_column = samples.len() as f64 / shown as f64;
109 if per_column < 1.0 {
110 let last = samples.len() - 1;
112 return (0..shown)
113 .map(|i| {
114 let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
115 let (below, frac) = (at.floor() as usize, at.fract());
116 let above = (below + 1).min(last);
117 samples[below] * (1.0 - frac) + samples[above] * frac
118 })
119 .collect();
120 }
121 (0..shown)
122 .map(|i| {
123 let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
124 let start = ((i as f64 * per_column) as usize).min(end - 1);
125 let part = &samples[start..end];
126 part.iter().sum::<f64>() / part.len() as f64
127 })
128 .collect()
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum GraphSpan {
134 #[default]
137 Fit,
138 Secs(u64),
140}
141
142impl GraphSpan {
143 pub const CHOICES: [GraphSpan; 7] = [
145 GraphSpan::Fit,
146 GraphSpan::Secs(60),
147 GraphSpan::Secs(5 * 60),
148 GraphSpan::Secs(15 * 60),
149 GraphSpan::Secs(60 * 60),
150 GraphSpan::Secs(6 * 60 * 60),
151 GraphSpan::Secs(24 * 60 * 60),
152 ];
153
154 pub fn next(self) -> Self {
156 let index = Self::CHOICES
157 .iter()
158 .position(|c| *c == self)
159 .map_or(0, |i| i + 1);
160 Self::CHOICES[index % Self::CHOICES.len()]
161 }
162
163 pub fn label(self) -> String {
165 match self {
166 GraphSpan::Fit => "fit".to_owned(),
167 GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
168 GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
169 GraphSpan::Secs(secs) => format!("{secs}s"),
170 }
171 }
172
173 pub fn as_secs(self) -> u64 {
175 match self {
176 GraphSpan::Fit => 0,
177 GraphSpan::Secs(secs) => secs,
178 }
179 }
180
181 pub fn from_secs(secs: u64) -> Self {
183 if secs == 0 {
184 GraphSpan::Fit
185 } else {
186 GraphSpan::Secs(secs)
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum SortKey {
194 Pid,
195 Name,
196 Threads,
197 User,
198 Container,
200 Memory,
201 Cpu,
202 Disk,
204 Gpu,
205 Vram,
206}
207
208impl SortKey {
209 pub const ALL: [SortKey; 10] = [
211 SortKey::Pid,
212 SortKey::Name,
213 SortKey::Threads,
214 SortKey::User,
215 SortKey::Container,
216 SortKey::Memory,
217 SortKey::Cpu,
218 SortKey::Disk,
219 SortKey::Gpu,
220 SortKey::Vram,
221 ];
222
223 pub fn descending_by_default(self) -> bool {
225 !matches!(
226 self,
227 SortKey::Pid | SortKey::Name | SortKey::User | SortKey::Container
228 )
229 }
230}
231
232#[derive(Debug, Clone)]
234pub struct ProcessRow<'a> {
235 pub info: &'a ProcessInfo,
236 pub gpu_percent: Option<f32>,
238 pub vram_bytes: Option<u64>,
240 pub tree_prefix: String,
243}
244
245#[derive(Debug, Default, Clone)]
247pub struct MetricHistory {
248 pub cpu: History,
250 pub memory: History,
252 pub gpus: Vec<History>,
254 pub net_rx: History,
256 pub net_tx: History,
258 pub net_interface: Option<String>,
260}
261
262impl MetricHistory {
263 pub fn record(&mut self, snapshot: &Snapshot) {
265 self.cpu.push(snapshot.cpu.total_percent as f64);
266 let memory = &snapshot.memory;
267 if memory.total_bytes > 0 {
268 self.memory
269 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
270 }
271 self.gpus.resize_with(snapshot.gpus.len(), History::default);
272 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
273 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
274 }
275 if self.net_interface.is_none() {
276 self.net_interface = default_interface(snapshot);
277 }
278 let (rx, tx) = self
279 .net_interface
280 .as_deref()
281 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
282 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
283 .unwrap_or((0.0, 0.0));
284 self.net_rx.push(rx);
285 self.net_tx.push(tx);
286 }
287
288 pub fn select_interface(&mut self, name: &str) {
290 if self.net_interface.as_deref() != Some(name) {
291 self.net_interface = Some(name.to_owned());
292 self.net_rx = History::default();
294 self.net_tx = History::default();
295 }
296 }
297
298 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
301 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
302 if names.is_empty() {
303 return None;
304 }
305 let current = self
306 .net_interface
307 .as_deref()
308 .and_then(|name| names.iter().position(|n| *n == name));
309 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
310 self.select_interface(&next);
311 Some(next)
312 }
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum Severity {
319 Warning,
321 Critical,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct Alert {
328 pub severity: Severity,
329 pub text: String,
330}
331
332pub fn alert_key(alert: &Alert) -> String {
336 let mut key = format!("{:?}:", alert.severity);
337 let mut in_number = false;
338 for c in alert.text.chars() {
339 if c.is_ascii_digit() || (in_number && c == '.') {
340 if !in_number {
341 key.push('#');
342 }
343 in_number = true;
344 } else {
345 key.push(c);
346 in_number = false;
347 }
348 }
349 key
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "lowercase")]
356pub enum AlertChange {
357 Raised,
358 Cleared,
359 Notice,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct AlertEvent {
367 pub at_ms: u64,
369 pub host: String,
370 pub change: AlertChange,
371 pub alert: Alert,
373}
374
375const ALERT_LOG_LEN: usize = 500;
377
378#[derive(Debug, Default)]
381pub struct AlertLog {
382 events: VecDeque<AlertEvent>,
384 active: HashMap<String, Vec<Alert>>,
386 unread: (usize, Option<Severity>),
388 file: Option<PathBuf>,
390}
391
392pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
396 dirs::state_dir()
397 .or_else(dirs::data_local_dir)
398 .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
399}
400
401impl AlertLog {
402 pub fn persistent(path: PathBuf) -> Self {
407 let mut log = Self::default();
408 let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
409 .map(|text| {
410 text.lines()
411 .filter_map(|line| serde_json::from_str(line).ok())
412 .collect()
413 })
414 .unwrap_or_default();
415 let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
416 for event in loaded.iter().filter(|e| e.change != AlertChange::Notice) {
417 let active = log.active.entry(event.host.clone()).or_default();
418 let key = alert_key(&event.alert);
419 active.retain(|a| alert_key(a) != key);
420 if event.change == AlertChange::Raised {
421 active.push(event.alert.clone());
422 }
423 }
424 log.events = loaded.into_iter().skip(keep).collect();
425 if keep > ALERT_LOG_LEN {
427 let text: String = log
428 .events
429 .iter()
430 .filter_map(|e| serde_json::to_string(e).ok())
431 .map(|line| line + "\n")
432 .collect();
433 let tmp = path.with_extension("jsonl.tmp");
434 let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
435 }
436 log.file = Some(path);
437 log
438 }
439
440 fn save(&self, event: &AlertEvent) {
442 let Some(path) = &self.file else {
443 return;
444 };
445 let Ok(line) = serde_json::to_string(event) else {
446 return;
447 };
448 if let Some(dir) = path.parent() {
449 let _ = fs::create_dir_all(dir);
450 }
451 let _ = OpenOptions::new()
453 .create(true)
454 .append(true)
455 .open(path)
456 .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
457 }
458
459 pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) -> Vec<AlertEvent> {
462 let previous = self.active.remove(host).unwrap_or_default();
463 let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
464 let (before, now) = (keys(&previous), keys(alerts));
465 let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
466 let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
467 let mut changes: Vec<(AlertChange, &Alert)> = raised
468 .map(|a| (AlertChange::Raised, a))
469 .chain(cleared.map(|a| (AlertChange::Cleared, a)))
470 .collect();
471 changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
473 let mut new_events = Vec::with_capacity(changes.len());
474 for (change, alert) in changes {
475 new_events.push(self.push(AlertEvent {
476 at_ms,
477 host: host.to_owned(),
478 change,
479 alert: alert.clone(),
480 }));
481 }
482 self.active.insert(host.to_owned(), alerts.to_vec());
483 new_events
484 }
485
486 pub fn update_snoozed(
491 &mut self,
492 host: &str,
493 alerts: &[Alert],
494 snoozed: &[Alert],
495 at_ms: u64,
496 ) -> Vec<AlertEvent> {
497 if let Some(previous) = self.active.get_mut(host) {
498 let quiet: HashSet<String> = snoozed.iter().map(alert_key).collect();
499 previous.retain(|a| !quiet.contains(&alert_key(a)));
500 }
501 self.update(host, alerts, at_ms)
502 }
503
504 pub fn record(&mut self, host: &str, alert: Alert, at_ms: u64) -> AlertEvent {
507 self.push(AlertEvent {
508 at_ms,
509 host: host.to_owned(),
510 change: AlertChange::Notice,
511 alert,
512 })
513 }
514
515 fn push(&mut self, event: AlertEvent) -> AlertEvent {
518 if event.change != AlertChange::Cleared {
519 self.unread.0 += 1;
520 self.unread.1 = self.unread.1.max(Some(event.alert.severity));
521 }
522 if self.events.len() == ALERT_LOG_LEN {
523 self.events.pop_front();
524 }
525 self.save(&event);
526 self.events.push_back(event.clone());
527 event
528 }
529
530 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
532 self.events.iter()
533 }
534
535 pub fn unread(&self) -> (usize, Option<Severity>) {
537 self.unread
538 }
539
540 pub fn mark_read(&mut self) {
542 self.unread = (0, None);
543 }
544}
545
546pub fn event_time(at_ms: u64, now: SystemTime) -> String {
549 let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
550 let today: DateTime<Local> = now.into();
551 let days = (today.date_naive() - at.date_naive()).num_days();
552 if days == 0 {
553 at.format("%H:%M:%S").to_string()
554 } else if (1..6).contains(&days) {
555 at.format("%a %H:%M").to_string()
556 } else {
557 at.format("%b %d %H:%M").to_string()
558 }
559}
560
561pub fn process_matches(process: &ProcessInfo, pattern: &str) -> bool {
564 let pattern = pattern.trim();
565 !pattern.is_empty()
566 && (process.name == pattern
567 || process
568 .command
569 .split_whitespace()
570 .any(|word| word == pattern || word.rsplit(['/', '\\']).next() == Some(pattern)))
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct WatchedProcess {
576 pub pid: u32,
577 pub name: String,
580 pub label: Option<String>,
582 pub started_ms: u64,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum WatchState {
590 Running,
591 Exited,
592 Unknown,
595}
596
597#[derive(Debug, Clone, Default)]
602pub struct ExitWatch {
603 processes: Vec<WatchedProcess>,
604}
605
606impl ExitWatch {
607 pub fn contains(&self, pid: u32) -> bool {
609 self.processes.iter().any(|w| w.pid == pid)
610 }
611
612 pub fn toggle(&mut self, process: &ProcessInfo, now_ms: u64) -> bool {
614 if self.contains(process.pid) {
615 self.processes.retain(|w| w.pid != process.pid);
616 return false;
617 }
618 self.watch_as(process, None, now_ms);
619 true
620 }
621
622 pub fn watch_as(&mut self, process: &ProcessInfo, label: Option<&str>, now_ms: u64) -> bool {
625 if self.contains(process.pid) {
626 return false;
627 }
628 self.processes.push(WatchedProcess {
629 pid: process.pid,
630 name: process.name.clone(),
631 label: label.map(str::to_owned),
632 started_ms: now_ms.saturating_sub(process.run_time_secs * 1000),
633 });
634 true
635 }
636
637 pub fn pids(&self) -> Vec<u32> {
639 self.processes.iter().map(|w| w.pid).collect()
640 }
641
642 pub fn state(&self, snapshot: &Snapshot, pid: u32) -> WatchState {
644 match self.processes.iter().find(|w| w.pid == pid) {
645 Some(watched) => watch_state(snapshot, watched),
646 None => WatchState::Unknown,
647 }
648 }
649
650 pub fn check(&mut self, snapshot: &Snapshot, now_ms: u64) -> Vec<Alert> {
653 let mut exited = Vec::new();
654 self.processes.retain(|watched| {
655 if watch_state(snapshot, watched) != WatchState::Exited {
656 return true;
657 }
658 let ran = now_ms.saturating_sub(watched.started_ms) / 1000;
659 exited.push(Alert {
660 severity: Severity::Warning,
661 text: format!(
662 "{} (pid {}) exited after {}",
663 watched.label.as_deref().unwrap_or(&watched.name),
664 watched.pid,
665 format::duration(ran)
666 ),
667 });
668 false
669 });
670 exited
671 }
672}
673
674fn watch_state(snapshot: &Snapshot, watched: &WatchedProcess) -> WatchState {
677 let reported = snapshot.watched_pids.contains(&watched.pid);
678 match snapshot.processes.iter().find(|p| p.pid == watched.pid) {
679 Some(process) if process.name != watched.name => WatchState::Exited,
681 Some(_) if reported => WatchState::Running,
682 None if reported => WatchState::Exited,
683 _ => WatchState::Unknown,
684 }
685}
686
687pub fn process_details(
691 snapshot: &Snapshot,
692 pid: u32,
693 now: SystemTime,
694) -> Option<Vec<(&'static str, String)>> {
695 let by_pid: HashMap<u32, &ProcessInfo> =
696 snapshot.processes.iter().map(|p| (p.pid, p)).collect();
697 let process = by_pid.get(&pid)?;
698 let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
699
700 let mut chain = Vec::new();
703 let mut parent = process.parent_pid;
704 while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
705 match by_pid.get(&ppid) {
706 Some(p) => {
707 chain.push(format!("{} ({})", p.name, p.pid));
708 parent = p.parent_pid.filter(|&next| next != p.pid);
709 }
710 None => {
711 chain.push(ppid.to_string());
712 break;
713 }
714 }
715 }
716 if !chain.is_empty() {
717 chain.reverse();
718 rows.push(("Parents", chain.join(" › ")));
719 }
720 let children = snapshot
721 .processes
722 .iter()
723 .filter(|p| p.parent_pid == Some(pid))
724 .count();
725 if children > 0 {
726 rows.push(("Children", children.to_string()));
727 }
728 rows.push(("User", process.user.clone()));
729 if let Some(container) = &process.container {
730 rows.push(("Container", container.clone()));
731 }
732 if !process.state.is_empty() {
733 rows.push(("State", process.state.clone()));
734 }
735 if let Some(nice) = process.nice {
736 rows.push(("Nice", nice.to_string()));
737 }
738 let started = now - Duration::from_secs(process.run_time_secs);
739 rows.push(("Running", since_note(started, now)));
740 rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
741 rows.push(("Memory", format::bytes(process.memory_bytes)));
742 if let Some(threads) = process.threads {
743 rows.push(("Threads", threads.to_string()));
744 }
745 rows.push((
746 "Disk",
747 format!(
748 "read {}, write {}",
749 format::rate(process.read_bytes_per_sec),
750 format::rate(process.write_bytes_per_sec)
751 ),
752 ));
753 for gpu in &snapshot.gpus {
754 if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
755 let mut text = format!("GPU {}", gpu.index);
756 if let Some(sm) = usage.sm_percent {
757 text.push_str(&format!(" {sm:.0}%"));
758 }
759 if let Some(memory) = usage.memory_bytes {
760 text.push_str(&format!(", {} VRAM", format::bytes(memory)));
761 }
762 rows.push(("GPU", text));
763 }
764 }
765 let command = if process.command.is_empty() {
766 format!("[{}]", process.name)
767 } else {
768 process.command.clone()
769 };
770 rows.push(("Command", command));
771 Some(rows)
772}
773
774pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
778 let worst = alerts.iter().map(|a| a.severity).max();
779 if unreachable || worst == Some(Severity::Critical) {
780 0
781 } else if stale || worst == Some(Severity::Warning) {
782 1
783 } else {
784 2
785 }
786}
787
788pub fn address_text(net: Option<&NetworkStats>, public_ip: Option<&str>) -> Option<String> {
794 let addresses = net.map_or(&[][..], |n| n.addresses.as_slice());
795 let (v6, v4): (Vec<&String>, Vec<&String>) = addresses.iter().partition(|a| a.contains(':'));
796 let mut parts: Vec<String> = v4.into_iter().cloned().collect();
797 if let Some(public) = public_ip {
798 parts.push(format!("public {public}"));
799 }
800 if let Some(first) = v6.first() {
801 parts.push(match v6.len() - 1 {
802 0 => (*first).clone(),
803 more => format!("{first} +{more}"),
804 });
805 }
806 (!parts.is_empty()).then(|| parts.join(" · "))
807}
808
809pub fn is_secondary_gpu(snapshot: &Snapshot, gpu: &GpuStats) -> bool {
813 gpu.integrated && snapshot.gpus.iter().any(|g| !g.integrated)
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub enum GpuRow {
819 Full,
821 Compact,
823}
824
825pub fn panel_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> Vec<(usize, &GpuStats, GpuRow)> {
829 snapshot
830 .gpus
831 .iter()
832 .enumerate()
833 .filter_map(|(i, gpu)| {
834 let row = match (is_secondary_gpu(snapshot, gpu), mode) {
835 (false, _) | (true, IntegratedGpu::Full) => GpuRow::Full,
836 (true, IntegratedGpu::Compact) => GpuRow::Compact,
837 (true, IntegratedGpu::Hide) => return None,
838 };
839 Some((i, gpu, row))
840 })
841 .collect()
842}
843
844pub fn card_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> impl Iterator<Item = &GpuStats> {
849 snapshot
850 .gpus
851 .iter()
852 .filter(|g| g.utilization_percent.is_some() || g.memory_used_bytes.is_some())
853 .filter(move |g| mode == IntegratedGpu::Full || !is_secondary_gpu(snapshot, g))
854}
855
856pub fn gpu_detail(gpu: &GpuStats) -> String {
859 let mut text = gpu.name.clone();
860 match (gpu.power_w, gpu.power_limit_w) {
861 (Some(w), Some(limit)) => text.push_str(&format!(" · {w:.0}/{limit:.0}W")),
862 (Some(w), None) => text.push_str(&format!(" · {w:.0}W")),
863 _ => {}
864 }
865 text
866}
867
868pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
872 let mut notes = Vec::new();
873 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
874 if !slowdown.is_empty() {
875 notes.push((
876 format!("slowed: {}", slowdown.join(", ")),
877 Some(Severity::Warning),
878 ));
879 } else if !gpu.throttle_reasons.is_empty() {
880 notes.push(("power cap".to_owned(), None));
881 }
882 if let Some(ecc) = gpu.ecc {
883 if ecc.uncorrected > 0 {
884 notes.push((
885 format!("ECC {} uncorrected", ecc.uncorrected),
886 Some(Severity::Critical),
887 ));
888 } else if ecc.corrected > 0 {
889 notes.push((format!("ECC {} corrected", ecc.corrected), None));
890 }
891 }
892 notes
893}
894
895pub fn pcie_text(link: &PcieLink) -> String {
897 let now = format!("PCIe gen {} x{}", link.generation, link.width);
898 if (link.generation, link.width) == (link.max_generation, link.max_width) {
899 now
900 } else {
901 format!(
902 "{now} (up to gen {} x{}; the link slows down when idle)",
903 link.max_generation, link.max_width
904 )
905 }
906}
907
908pub fn since_note(since: SystemTime, now: SystemTime) -> String {
911 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
912 let span = match secs {
913 0..60 => format!("{secs}s"),
914 60..3_600 => format!("{}m", secs / 60),
915 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
916 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
917 };
918 let at: DateTime<Local> = since.into();
919 let today: DateTime<Local> = now.into();
920 let clock = if at.date_naive() == today.date_naive() {
921 at.format("%H:%M")
922 } else if secs < 6 * 86_400 {
923 at.format("%a %H:%M")
924 } else {
925 at.format("%b %d %H:%M")
926 };
927 format!("{span} (since {clock})")
928}
929
930pub fn version_note(snapshot: &Snapshot) -> Option<String> {
933 let version = &snapshot.host.rigtop_version;
934 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
935 .then(|| format!("agent {version}"))
936}
937
938pub fn version_hint(snapshot: &Snapshot) -> String {
940 format!(
941 "this host runs rigtop {}, this machine {}: install the same version on both \
942 for every reading",
943 snapshot.host.rigtop_version,
944 env!("CARGO_PKG_VERSION")
945 )
946}
947
948pub const REBOOT_NOTE: &str = "reboot needed";
951
952pub fn reboot_hint(reason: &str) -> String {
954 let mut hint = "a reboot is needed to finish an update".to_owned();
955 if !reason.is_empty() {
956 hint.push_str(&format!(" ({reason})"));
957 }
958 hint
959}
960
961pub fn latency_severity(ms: f32) -> Option<Severity> {
964 if ms >= 500.0 {
965 Some(Severity::Critical)
966 } else if ms >= 100.0 {
967 Some(Severity::Warning)
968 } else {
969 None
970 }
971}
972
973pub const SLOW_LINK_MS: f32 = 500.0;
975
976pub fn link_rtt_text(snapshot: &Snapshot) -> Option<(String, bool)> {
979 let ms = snapshot.link_rtt_ms?;
980 Some((format::latency(ms), ms >= SLOW_LINK_MS))
981}
982
983pub const LINK_RTT_HINT: &str = "round trip to the agent over SSH (every 5 s)";
985
986fn scan_percent(scan: &str) -> Option<String> {
988 let done = scan.find("% done")?;
989 let start = scan[..done].rfind(' ').map_or(0, |i| i + 1);
990 let value: f32 = scan[start..done].parse().ok()?;
991 Some(format!("{value:.1}%"))
992}
993
994pub fn zfs_scan_summary(scan: &str) -> (String, Option<Severity>) {
998 let date = scan.rsplit_once(" on ").map(|(_, when)| {
1000 let words: Vec<&str> = when.split_whitespace().collect();
1001 match words.as_slice() {
1002 [_, month, day, ..] => format!("{month} {day}"),
1003 _ => when.to_owned(),
1004 }
1005 });
1006 let errors = scan
1007 .split_once(" with ")
1008 .and_then(|(_, rest)| rest.split_whitespace().next()?.parse::<u64>().ok())
1009 .unwrap_or(0);
1010 let repaired = scan
1011 .split_once("repaired ")
1012 .and_then(|(_, rest)| rest.split_whitespace().next())
1013 .filter(|amount| *amount != "0B");
1014 if scan.starts_with("none requested") {
1015 return ("no scrub yet".to_owned(), Some(Severity::Warning));
1016 }
1017 if scan.contains("in progress") || scan.starts_with("scrub paused") {
1018 let what = scan.split_whitespace().next().unwrap_or("scan");
1019 let state = if scan.starts_with("scrub paused") {
1020 "paused"
1021 } else {
1022 "running"
1023 };
1024 let done = scan_percent(scan).map_or(String::new(), |p| format!(" {p}"));
1025 let severity = (what == "resilver").then_some(Severity::Warning);
1026 return (format!("{what} {state}{done}"), severity);
1027 }
1028 let what = if scan.starts_with("resilvered") {
1029 "resilver"
1030 } else {
1031 "scrub"
1032 };
1033 let mut text = match &date {
1034 Some(date) => format!("{what} {date}"),
1035 None => what.to_owned(),
1036 };
1037 let severity = if errors > 0 {
1038 text.push_str(&format!(", {errors} errors"));
1039 Some(Severity::Critical)
1040 } else if let (Some(amount), "scrub") = (repaired, what) {
1041 text.push_str(&format!(", repaired {amount}"));
1042 Some(Severity::Warning)
1043 } else {
1044 text.push_str(", ok");
1045 None
1046 };
1047 (text, severity)
1048}
1049
1050pub const PRESSURE_LABEL: &str = "Stall";
1052
1053#[derive(Debug, Clone, PartialEq)]
1056pub struct PressureItem {
1057 pub name: &'static str,
1059 pub value: String,
1061 pub warn: bool,
1063}
1064
1065pub fn pressure_items(pressure: &Pressure, limits: &AlertsConfig) -> Vec<PressureItem> {
1068 let over = |value: f32, limit: f64| limit > 0.0 && value as f64 >= limit;
1069 let item = |name, value: f32, warn| PressureItem {
1070 name,
1071 value: format!("{value:.0}%"),
1072 warn,
1073 };
1074 let items = [
1075 item("cpu", pressure.cpu.some_avg10, false),
1076 item(
1077 "mem",
1078 pressure.memory.some_avg10,
1079 over(
1080 pressure.memory.some_avg60,
1081 limits.memory_pressure_warning_percent,
1082 ),
1083 ),
1084 item(
1085 "io",
1086 pressure.io.some_avg10,
1087 over(pressure.io.full_avg60, limits.io_pressure_warning_percent),
1088 ),
1089 ];
1090 items
1091 .into_iter()
1092 .filter(|item| item.warn || item.value != "0%")
1093 .collect()
1094}
1095
1096pub fn pressure_details(pressure: &Pressure) -> String {
1099 let line = |name: &str, levels: &crate::snapshot::PressureLevels| {
1100 format!(
1101 "{name}: some {:.0}% / {:.0}%, full {:.0}% / {:.0}%",
1102 levels.some_avg10, levels.some_avg60, levels.full_avg10, levels.full_avg60
1103 )
1104 };
1105 [
1106 "Share of time tasks waited (10 s / 60 s averages); some: at least one \
1107 task, full: all of them"
1108 .to_owned(),
1109 line("cpu", &pressure.cpu),
1110 line("memory", &pressure.memory),
1111 line("io", &pressure.io),
1112 ]
1113 .join("\n")
1114}
1115
1116pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
1118 alerts_with(snapshot, &AlertsConfig::default())
1119}
1120
1121pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
1126 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
1128 let mut alerts = Vec::new();
1129 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
1130 for fs in &snapshot.raid.btrfs {
1131 let name = fs
1132 .mount_points
1133 .first()
1134 .map_or(fs.fsid.as_str(), String::as_str);
1135 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
1136 if missing > 0 {
1137 add(
1138 Severity::Critical,
1139 format!("btrfs {name}: {missing} device(s) missing"),
1140 );
1141 }
1142 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
1143 if errors > 0 {
1144 let devices: Vec<String> = fs
1146 .device_stats
1147 .iter()
1148 .filter(|d| d.total_errors() > 0)
1149 .map(|d| d.label())
1150 .collect();
1151 add(
1152 Severity::Critical,
1153 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
1154 );
1155 }
1156 if let Some(scrub) = &fs.scrub {
1157 if scrub.uncorrectable > 0 {
1158 add(
1159 Severity::Critical,
1160 format!(
1161 "btrfs {name}: scrub found {} uncorrectable errors",
1162 scrub.uncorrectable
1163 ),
1164 );
1165 } else if scrub.errors > 0 {
1166 add(
1167 Severity::Warning,
1168 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
1169 );
1170 }
1171 }
1172 }
1173 for md in &snapshot.raid.md {
1174 if md.active && md.is_degraded() {
1175 add(
1176 Severity::Critical,
1177 format!("{} ({}) degraded", md.name, md.level),
1178 );
1179 } else if !md.active {
1180 add(Severity::Warning, format!("{} inactive", md.name));
1181 }
1182 if let Some(sync) = &md.sync {
1183 add(
1184 Severity::Warning,
1185 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
1186 );
1187 }
1188 }
1189 for pool in &snapshot.raid.zfs {
1190 if pool.is_failing() {
1191 add(
1192 Severity::Critical,
1193 format!("zfs {} {}", pool.name, pool.state),
1194 );
1195 } else if !pool.is_online() {
1196 add(
1197 Severity::Warning,
1198 format!("zfs {} {}", pool.name, pool.state),
1199 );
1200 }
1201 if let Some(errors) = pool.data_errors.filter(|e| *e > 0) {
1202 add(
1203 Severity::Critical,
1204 format!("zfs {}: {errors} data errors", pool.name),
1205 );
1206 }
1207 for device in pool.troubled_devices() {
1208 let text = if device.is_unhealthy() {
1209 format!("zfs {}: {} {}", pool.name, device.name, device.state)
1210 } else {
1211 format!(
1212 "zfs {}: {} has {} errors",
1213 pool.name,
1214 device.name,
1215 device.errors()
1216 )
1217 };
1218 add(Severity::Warning, text);
1219 }
1220 if let Some(scan) = &pool.scan
1221 && scan.starts_with("resilver in progress")
1222 {
1223 let done = scan_percent(scan).unwrap_or_default();
1224 add(
1225 Severity::Warning,
1226 format!("zfs {} resilver {done}", pool.name),
1227 );
1228 }
1229 }
1230 for unit in &snapshot.failed_units {
1231 if !limits.ignored_units.contains(unit) {
1232 add(Severity::Warning, format!("systemd: {unit} failed"));
1233 }
1234 }
1235 for smart in &snapshot.smart {
1236 if let Some(error) = &smart.error {
1237 add(
1238 Severity::Warning,
1239 format!("{}: SMART unreadable ({error})", smart.device),
1240 );
1241 } else if smart.passed == Some(false) {
1242 add(
1243 Severity::Critical,
1244 format!("{}: SMART failed", smart.device),
1245 );
1246 } else if smart.needs_attention() {
1247 add(
1248 Severity::Critical,
1249 format!("{}: SMART warning", smart.device),
1250 );
1251 }
1252 }
1253 for fs in &snapshot.filesystems {
1254 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
1255 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
1256 let name = fs
1257 .mount_points
1258 .first()
1259 .map_or(fs.device.as_str(), String::as_str);
1260 if reached(percent, limits.filesystem_critical_percent) {
1261 add(Severity::Critical, format!("{name} {percent:.0}% full"));
1262 } else if reached(percent, limits.filesystem_warning_percent) {
1263 add(Severity::Warning, format!("{name} {percent:.0}% full"));
1264 }
1265 let soon = limits.filesystem_full_warning_hours * 3600.0;
1266 if let Some(secs) = fs.full_in_secs
1267 && soon > 0.0
1268 && secs as f64 <= soon
1269 {
1270 add(
1271 Severity::Warning,
1272 format!("{name} full in ~{}", format::duration(secs)),
1273 );
1274 }
1275 }
1276 if let Some(text) = oom_alert(&snapshot.memory.oom_kills) {
1277 add(Severity::Critical, text);
1278 }
1279 let memory = &snapshot.memory;
1280 let percent = memory.used_bytes as f64 / memory.total_bytes.max(1) as f64 * 100.0;
1281 if memory.total_bytes > 0 && reached(percent, limits.memory_warning_percent) {
1282 add(Severity::Warning, format!("memory {percent:.0}% used"));
1283 }
1284 if let Some(pressure) = &snapshot.pressure {
1285 let memory = pressure.memory.some_avg60 as f64;
1286 if reached(memory, limits.memory_pressure_warning_percent) {
1287 add(Severity::Warning, format!("memory pressure {memory:.0}%"));
1288 }
1289 let io = pressure.io.full_avg60 as f64;
1290 if reached(io, limits.io_pressure_warning_percent) {
1291 add(Severity::Warning, format!("I/O pressure {io:.0}%"));
1292 }
1293 }
1294 let hottest_cpu = snapshot
1295 .cpu
1296 .temperatures
1297 .iter()
1298 .map(|t| t.celsius)
1299 .chain(snapshot.cpu.per_core_celsius.iter().flatten().copied())
1300 .fold(f32::NAN, f32::max);
1301 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
1302 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
1303 }
1304 for gpu in &snapshot.gpus {
1305 if let Some(t) = gpu
1306 .temperature_c
1307 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
1308 {
1309 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
1310 }
1311 if let (Some(used), Some(total)) = (gpu.memory_used_bytes, gpu.memory_total_bytes)
1312 && total > 0
1313 {
1314 let percent = used as f64 / total as f64 * 100.0;
1315 if reached(percent, limits.gpu_memory_warning_percent) {
1316 add(
1317 Severity::Warning,
1318 format!("GPU {} memory {percent:.0}% used", gpu.index),
1319 );
1320 }
1321 }
1322 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
1323 if !slowdown.is_empty() {
1324 add(
1325 Severity::Warning,
1326 format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
1327 );
1328 }
1329 if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
1330 add(
1331 Severity::Critical,
1332 format!(
1333 "GPU {}: {} uncorrected ECC errors",
1334 gpu.index, ecc.uncorrected
1335 ),
1336 );
1337 }
1338 }
1339 for (device, celsius) in drive_temperatures(snapshot) {
1340 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
1341 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
1342 }
1343 }
1344 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
1346 alerts
1347}
1348
1349fn oom_alert(kills: &[OomKill]) -> Option<String> {
1353 let mut names: Vec<&str> = Vec::new();
1354 for name in kills
1355 .iter()
1356 .rev()
1357 .filter_map(|k| k.likely_process.as_deref())
1358 {
1359 if !names.contains(&name) {
1360 names.push(name);
1361 }
1362 }
1363 let likely = if names.is_empty() {
1364 String::new()
1365 } else {
1366 format!(" (likely {})", names.join(", "))
1367 };
1368 match kills.len() {
1369 0 => None,
1370 1 => Some(match names.first() {
1371 Some(name) => format!("OOM killer stopped {name} (likely)"),
1372 None => "OOM killer stopped a process".to_owned(),
1373 }),
1374 n => Some(format!("OOM killer stopped {n} processes{likely}")),
1375 }
1376}
1377
1378fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
1381 let mut drives: Vec<(String, f32)> = snapshot
1382 .smart
1383 .iter()
1384 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
1385 .collect();
1386 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
1387 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
1389 if drives.iter().any(|(d, _)| d == device) {
1390 continue;
1391 }
1392 let composite = chip
1393 .temperatures
1394 .iter()
1395 .find(|t| t.label == "Composite")
1396 .or(chip.temperatures.first());
1397 if let Some(t) = composite {
1398 drives.push((device.to_owned(), t.celsius));
1399 }
1400 }
1401 drives
1402}
1403
1404fn gpu_usage_by_pid(snapshot: &Snapshot) -> HashMap<u32, (Option<f32>, Option<u64>)> {
1406 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
1407 for gpu in &snapshot.gpus {
1408 for p in &gpu.processes {
1409 let entry = gpu_usage.entry(p.pid).or_default();
1410 if let Some(sm) = p.sm_percent {
1411 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
1412 }
1413 if let Some(mem) = p.memory_bytes {
1414 entry.1 = Some(entry.1.unwrap_or(0) + mem);
1415 }
1416 }
1417 }
1418 gpu_usage
1419}
1420
1421#[derive(Debug, Clone, PartialEq)]
1423pub struct ContainerUsage {
1424 pub name: String,
1425 pub processes: usize,
1426 pub cpu_percent: f32,
1427 pub memory_bytes: u64,
1428 pub gpu_percent: Option<f32>,
1430 pub vram_bytes: Option<u64>,
1431}
1432
1433pub fn container_usage(snapshot: &Snapshot) -> Vec<ContainerUsage> {
1436 let gpu_usage = gpu_usage_by_pid(snapshot);
1437 let mut by_name: HashMap<&str, ContainerUsage> = HashMap::new();
1438 for process in &snapshot.processes {
1439 let Some(name) = process.container.as_deref() else {
1440 continue;
1441 };
1442 let usage = by_name.entry(name).or_insert_with(|| ContainerUsage {
1443 name: name.to_owned(),
1444 processes: 0,
1445 cpu_percent: 0.0,
1446 memory_bytes: 0,
1447 gpu_percent: None,
1448 vram_bytes: None,
1449 });
1450 usage.processes += 1;
1451 usage.cpu_percent += process.cpu_percent;
1452 usage.memory_bytes += process.memory_bytes;
1453 if let Some((gpu, vram)) = gpu_usage.get(&process.pid) {
1454 if let Some(gpu) = gpu {
1455 usage.gpu_percent = Some(usage.gpu_percent.unwrap_or(0.0) + gpu);
1456 }
1457 if let Some(vram) = vram {
1458 usage.vram_bytes = Some(usage.vram_bytes.unwrap_or(0) + vram);
1459 }
1460 }
1461 }
1462 let mut list: Vec<ContainerUsage> = by_name.into_values().collect();
1463 list.sort_by(|a, b| {
1464 b.cpu_percent
1465 .total_cmp(&a.cpu_percent)
1466 .then(b.memory_bytes.cmp(&a.memory_bytes))
1467 .then(a.name.cmp(&b.name))
1468 });
1469 list
1470}
1471
1472#[derive(Debug, Clone, Copy)]
1474pub struct ProcessQuery<'q> {
1475 pub sort_key: SortKey,
1477 pub descending: bool,
1479 pub filter: &'q str,
1481 pub show_kernel_threads: bool,
1483 pub tree: bool,
1486}
1487
1488pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
1490 let gpu_usage = gpu_usage_by_pid(snapshot);
1491
1492 let filter = query.filter.to_lowercase();
1493 let matches = |p: &ProcessInfo| {
1494 filter.is_empty()
1495 || p.name.to_lowercase().contains(&filter)
1496 || p.command.to_lowercase().contains(&filter)
1497 || p.user.to_lowercase().contains(&filter)
1498 || p.container
1499 .as_ref()
1500 .is_some_and(|c| c.to_lowercase().contains(&filter))
1501 || p.pid.to_string() == filter
1502 };
1503 let candidates = snapshot
1504 .processes
1505 .iter()
1506 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
1507 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
1508 with_ancestors(candidates.collect(), matches)
1509 } else {
1510 candidates.filter(|p| matches(p)).collect()
1511 };
1512 let mut rows: Vec<ProcessRow<'a>> = shown
1513 .into_iter()
1514 .map(|info| {
1515 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
1516 ProcessRow {
1517 info,
1518 gpu_percent,
1519 vram_bytes,
1520 tree_prefix: String::new(),
1521 }
1522 })
1523 .collect();
1524
1525 let key = query.sort_key;
1526 rows.sort_by(|a, b| {
1527 let ordering = match key {
1528 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
1529 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
1530 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
1531 SortKey::User => a.info.user.cmp(&b.info.user),
1532 SortKey::Container => match (&a.info.container, &b.info.container) {
1533 (Some(a), Some(b)) => a.cmp(b),
1534 (Some(_), None) => std::cmp::Ordering::Less,
1535 (None, Some(_)) => std::cmp::Ordering::Greater,
1536 (None, None) => std::cmp::Ordering::Equal,
1537 },
1538 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
1539 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
1540 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
1541 SortKey::Gpu => a
1542 .gpu_percent
1543 .unwrap_or(-1.0)
1544 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
1545 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
1546 }
1547 .then(a.info.pid.cmp(&b.info.pid));
1549 if query.descending {
1550 ordering.reverse()
1551 } else {
1552 ordering
1553 }
1554 });
1555 if query.tree { into_tree(rows) } else { rows }
1556}
1557
1558fn with_ancestors(
1561 processes: Vec<&ProcessInfo>,
1562 matches: impl Fn(&ProcessInfo) -> bool,
1563) -> Vec<&ProcessInfo> {
1564 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
1565 let mut keep: HashSet<u32> = HashSet::new();
1566 for process in processes.iter().filter(|p| matches(p)) {
1567 let mut current = Some(*process);
1568 while let Some(p) = current
1570 && keep.insert(p.pid)
1571 {
1572 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1573 }
1574 }
1575 processes
1576 .into_iter()
1577 .filter(|p| keep.contains(&p.pid))
1578 .collect()
1579}
1580
1581fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1585 let index: HashMap<u32, usize> = rows
1586 .iter()
1587 .enumerate()
1588 .map(|(i, row)| (row.info.pid, i))
1589 .collect();
1590 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1591 let mut roots = Vec::new();
1592 for (i, row) in rows.iter().enumerate() {
1593 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1594 Some(&parent) if parent != i => children[parent].push(i),
1595 _ => roots.push(i),
1596 }
1597 }
1598 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1599 let mut visited = vec![false; rows.len()];
1600 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1602 for start in starts {
1603 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1605 while let Some((i, lead, last)) = stack.pop() {
1606 if std::mem::replace(&mut visited[i], true) {
1607 continue;
1608 }
1609 let (prefix, child_lead) = match last {
1610 None => (String::new(), String::new()),
1611 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
1612 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1613 };
1614 order.push((i, prefix));
1615 let kids = &children[i];
1616 for (k, &child) in kids.iter().enumerate().rev() {
1617 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1618 }
1619 }
1620 }
1621 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1622 order
1623 .into_iter()
1624 .filter_map(|(i, prefix)| {
1625 let mut row = rows[i].take()?;
1626 row.tree_prefix = prefix;
1627 Some(row)
1628 })
1629 .collect()
1630}
1631
1632pub fn disk_rate(process: &ProcessInfo) -> f64 {
1634 process.read_bytes_per_sec + process.write_bytes_per_sec
1635}
1636
1637pub fn disk_rate_text(process: &ProcessInfo) -> String {
1640 let rate = disk_rate(process);
1641 if rate > 0.0 {
1642 format::rate(rate)
1643 } else {
1644 String::new()
1645 }
1646}
1647
1648pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1651
1652pub fn lhm_help(error: &str) -> String {
1654 format!(
1655 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1656 Start it and turn on Options → Remote Web Server → Run.\n\
1657 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1658 {error}"
1659 )
1660}
1661
1662pub fn signal_name(signal: ProcessSignal) -> &'static str {
1665 match signal {
1666 ProcessSignal::Terminate => "SIGTERM",
1667 ProcessSignal::Kill => "SIGKILL",
1668 ProcessSignal::Stop => "SIGSTOP",
1669 ProcessSignal::Continue => "SIGCONT",
1670 ProcessSignal::Nice(_) => "renice",
1671 }
1672}
1673
1674pub fn action_text(signal: ProcessSignal) -> String {
1676 match signal {
1677 ProcessSignal::Nice(nice) => format!("nice {nice}"),
1678 other => signal_name(other).to_owned(),
1679 }
1680}
1681
1682pub const NICE_STEP: i32 = 5;
1684
1685pub fn nice_step(process: &ProcessInfo, later: bool) -> Option<i32> {
1688 let current = process.nice.unwrap_or(0);
1689 let next = if later {
1690 (current + NICE_STEP).min(19)
1691 } else {
1692 (current - NICE_STEP).max(-20)
1693 };
1694 (next != current).then_some(next)
1695}
1696
1697pub fn is_stopped(process: &ProcessInfo) -> bool {
1699 process.state == "Stopped" || process.state == "Tracing"
1700}
1701
1702pub fn is_virtual_interface(name: &str) -> bool {
1705 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1706}
1707
1708pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1710 snapshot
1711 .networks
1712 .iter()
1713 .filter(|n| !is_virtual_interface(&n.name))
1714 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1715 .or_else(|| snapshot.networks.first())
1716 .map(|n| n.name.clone())
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721 use super::*;
1722 use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1723
1724 #[test]
1725 fn process_details_rows() {
1726 let mut snapshot = Snapshot::default();
1727 let mut shell = process(20, "zsh", 0.0, false);
1728 shell.parent_pid = Some(1);
1729 let mut python = process(30, "python", 99.5, false);
1730 python.parent_pid = Some(20);
1731 python.command = "python train.py --epochs 10".to_owned();
1732 python.container = Some("trainer".to_owned());
1733 python.run_time_secs = 3_660;
1734 snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1735 snapshot.gpus = vec![GpuStats {
1736 index: 0,
1737 processes: vec![GpuProcess {
1738 pid: 30,
1739 memory_bytes: Some(2 << 30),
1740 sm_percent: Some(97.0),
1741 }],
1742 ..Default::default()
1743 }];
1744 let now = SystemTime::now();
1745 let rows = process_details(&snapshot, 30, now).unwrap();
1746 let get = |label: &str| {
1747 rows.iter()
1748 .find(|(l, _)| *l == label)
1749 .map(|(_, v)| v.as_str())
1750 };
1751 assert_eq!(get("Program"), Some("python (pid 30)"));
1752 assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1753 assert_eq!(get("Children"), None);
1754 assert_eq!(get("Container"), Some("trainer"));
1755 assert!(get("Running").unwrap().starts_with("1h01m (since "));
1756 assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1757 assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1758 let shell = process_details(&snapshot, 20, now).unwrap();
1759 assert!(shell.contains(&("Children", "1".to_owned())));
1760 assert!(process_details(&snapshot, 99, now).is_none());
1761 }
1762
1763 #[test]
1764 fn attention_ranks() {
1765 let alert = |severity| Alert {
1766 severity,
1767 text: String::new(),
1768 };
1769 assert_eq!(attention_rank(&[], false, false), 2);
1770 assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1771 assert_eq!(attention_rank(&[], false, true), 1);
1772 assert_eq!(
1773 attention_rank(
1774 &[alert(Severity::Warning), alert(Severity::Critical)],
1775 false,
1776 false
1777 ),
1778 0
1779 );
1780 assert_eq!(attention_rank(&[], true, false), 0);
1781 }
1782
1783 #[test]
1784 fn gpu_notes_and_pcie() {
1785 let mut gpu = GpuStats {
1786 throttle_reasons: vec!["power cap".to_owned()],
1787 ..Default::default()
1788 };
1789 assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1790 gpu.throttle_reasons.push("thermal".to_owned());
1791 gpu.ecc = Some(EccErrors {
1792 corrected: 3,
1793 uncorrected: 0,
1794 });
1795 assert_eq!(
1796 gpu_notes(&gpu),
1797 [
1798 ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1799 ("ECC 3 corrected".to_owned(), None)
1800 ]
1801 );
1802 let full = PcieLink {
1803 generation: 4,
1804 width: 16,
1805 max_generation: 4,
1806 max_width: 16,
1807 };
1808 assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1809 let idle = PcieLink {
1810 generation: 1,
1811 width: 8,
1812 ..full
1813 };
1814 assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1815 }
1816
1817 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1818 ProcessInfo {
1819 pid,
1820 name: name.to_owned(),
1821 cpu_percent: cpu,
1822 kernel_thread: kernel,
1823 ..Default::default()
1824 }
1825 }
1826
1827 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1828 ProcessQuery {
1829 sort_key,
1830 descending: sort_key.descending_by_default(),
1831 filter: "",
1832 show_kernel_threads: false,
1833 tree: false,
1834 }
1835 }
1836
1837 #[test]
1838 fn history_keeps_newest_samples() {
1839 let mut history = History::default();
1840 for i in 0..(HISTORY_LEN + 5) {
1841 history.push(i as f64);
1842 }
1843 assert_eq!(
1844 history.last(2),
1845 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1846 );
1847 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1848 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1849 }
1850
1851 #[test]
1852 fn graph_spans_spread_samples_over_the_width() {
1853 let second = Duration::from_secs(1);
1854 let mut history = History::default();
1855 for i in 0..120 {
1856 history.push(i as f64);
1857 }
1858 assert_eq!(
1860 history.graph(3, GraphSpan::Fit, second),
1861 vec![117.0, 118.0, 119.0]
1862 );
1863 let minute = history.graph(30, GraphSpan::Secs(60), second);
1865 assert_eq!(minute.len(), 30);
1866 assert_eq!(minute[0], 60.5);
1867 assert_eq!(minute[29], 118.5);
1868 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1870 assert_eq!(stretched.len(), 120);
1871 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1872 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1873 assert_eq!(stretched[119], 119.0);
1874 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1875 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1877 assert_eq!(hour.len(), 10);
1878 assert_eq!(hour[0], 5.5);
1879 assert!(
1880 History::default()
1881 .graph(10, GraphSpan::Secs(60), second)
1882 .is_empty()
1883 );
1884 }
1885
1886 #[test]
1887 fn long_spans_draw_from_the_averages() {
1888 let second = Duration::from_secs(1);
1889 let mut history = History::default();
1890 for i in 0..7200 {
1892 history.push(if i < 3600 { 0.0 } else { 100.0 });
1893 }
1894 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1896 assert_eq!(six.len(), 30);
1897 assert_eq!((six[0], six[29]), (0.0, 100.0));
1898 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1900 assert!(hour.iter().all(|v| *v == 100.0));
1901 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1903 history.push(i as f64);
1904 }
1905 assert_eq!(history.coarse.len(), COARSE_LEN);
1906 }
1907
1908 #[test]
1909 fn graph_span_choices_cycle() {
1910 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1911 .take(6)
1912 .map(GraphSpan::label)
1913 .collect();
1914 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1915 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1916 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1917 assert_eq!(
1918 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1919 GraphSpan::Secs(300)
1920 );
1921 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1922 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1924 }
1925
1926 #[test]
1927 fn rows_filter_sort_and_join_gpu_usage() {
1928 let snapshot = Snapshot {
1929 processes: vec![
1930 process(10, "bash", 5.0, false),
1931 process(2, "kworker/0:1", 50.0, true),
1932 process(30, "python", 1.0, false),
1933 ],
1934 gpus: vec![GpuStats {
1935 processes: vec![GpuProcess {
1936 pid: 30,
1937 memory_bytes: Some(1 << 30),
1938 sm_percent: Some(70.0),
1939 }],
1940 ..Default::default()
1941 }],
1942 ..Default::default()
1943 };
1944 let pids = |q: &ProcessQuery| {
1945 process_rows(&snapshot, q)
1946 .iter()
1947 .map(|r| r.info.pid)
1948 .collect::<Vec<_>>()
1949 };
1950 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1951 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1952 assert_eq!(
1953 pids(&ProcessQuery {
1954 show_kernel_threads: true,
1955 ..query(SortKey::Cpu)
1956 }),
1957 vec![2, 10, 30]
1958 );
1959 assert_eq!(
1960 pids(&ProcessQuery {
1961 filter: "PYT",
1962 ..query(SortKey::Pid)
1963 }),
1964 vec![30]
1965 );
1966 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1967 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1968 assert_eq!(rows[1].gpu_percent, Some(70.0));
1969 }
1970
1971 #[test]
1972 fn since_notes_say_how_long_and_since_when() {
1973 let now = SystemTime::now();
1974 let ago = |secs: u64| now - Duration::from_secs(secs);
1975 assert!(since_note(ago(42), now).starts_with("42s (since "));
1976 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1977 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1978 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1979 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1981 }
1982
1983 #[test]
1984 fn tree_puts_children_under_parents() {
1985 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1986 parent_pid: Some(parent),
1987 ..process(pid, name, cpu, false)
1988 };
1989 let snapshot = Snapshot {
1990 processes: vec![
1991 process(1, "init", 0.0, false),
1992 child(10, 1, "sshd", 1.0),
1993 child(11, 10, "bash", 2.0),
1994 child(12, 11, "python", 50.0),
1995 child(20, 1, "cron", 3.0),
1996 child(30, 999, "orphan", 4.0),
1998 child(40, 41, "loop-a", 0.0),
2000 child(41, 40, "loop-b", 0.0),
2001 ],
2002 ..Default::default()
2003 };
2004 let tree = |filter| {
2005 process_rows(
2006 &snapshot,
2007 &ProcessQuery {
2008 filter,
2009 tree: true,
2010 ..query(SortKey::Cpu)
2011 },
2012 )
2013 .iter()
2014 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
2015 .collect::<Vec<_>>()
2016 };
2017 assert_eq!(
2019 tree(""),
2020 vec![
2021 "orphan",
2022 "init",
2023 "├ cron",
2024 "└ sshd",
2025 " └ bash",
2026 " └ python",
2027 "loop-b",
2028 "└ loop-a",
2029 ]
2030 );
2031 assert_eq!(
2033 tree("pyth"),
2034 vec!["init", "└ sshd", " └ bash", " └ python"]
2035 );
2036 }
2037
2038 #[test]
2039 fn disk_column_sums_reads_and_writes() {
2040 let reader = ProcessInfo {
2041 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
2042 ..process(10, "reader", 0.0, false)
2043 };
2044 let writer = ProcessInfo {
2045 read_bytes_per_sec: 1024.0 * 1024.0,
2046 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
2047 ..process(20, "writer", 0.0, false)
2048 };
2049 let idle = process(30, "idle", 0.0, false);
2050 assert_eq!(disk_rate_text(&writer), "5.0M/s");
2051 assert_eq!(disk_rate_text(&idle), "");
2052 let snapshot = Snapshot {
2053 processes: vec![idle, reader, writer],
2054 ..Default::default()
2055 };
2056 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
2057 .iter()
2058 .map(|r| r.info.pid)
2059 .collect();
2060 assert_eq!(pids, vec![20, 10, 30]);
2061 }
2062
2063 #[test]
2064 fn metric_history_records_and_switches_interfaces() {
2065 let snapshot = Snapshot {
2066 networks: vec![
2067 NetworkStats {
2068 name: "enp9s0".to_owned(),
2069 rx_total_bytes: 10,
2070 rx_bytes_per_sec: 100.0,
2071 ..Default::default()
2072 },
2073 NetworkStats {
2074 name: "wlp7s0".to_owned(),
2075 tx_bytes_per_sec: 5.0,
2076 ..Default::default()
2077 },
2078 ],
2079 gpus: vec![GpuStats {
2080 utilization_percent: Some(40.0),
2081 ..Default::default()
2082 }],
2083 ..Default::default()
2084 };
2085 let mut history = MetricHistory::default();
2086 history.record(&snapshot);
2087 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
2088 assert_eq!(history.net_rx.last(1), vec![100.0]);
2089 assert_eq!(history.gpus[0].last(1), vec![40.0]);
2090 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
2091 assert!(history.net_rx.last(10).is_empty());
2093 history.record(&snapshot);
2094 assert_eq!(history.net_tx.last(1), vec![5.0]);
2095 }
2096
2097 #[test]
2098 fn alerts_flag_storage_problems_first() {
2099 use crate::snapshot::{
2100 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
2101 ZfsPool,
2102 };
2103 let snapshot = Snapshot {
2104 filesystems: vec![FilesystemUsage {
2105 mount_points: vec!["/data".to_owned()],
2106 total_bytes: 100,
2107 available_bytes: 8,
2108 ..Default::default()
2109 }],
2110 raid: RaidStats {
2111 btrfs: vec![BtrfsFilesystem {
2112 mount_points: vec!["/".to_owned()],
2113 device_stats: vec![BtrfsDeviceStats {
2114 devid: 1,
2115 corruption_errs: 2,
2116 ..Default::default()
2117 }],
2118 ..Default::default()
2119 }],
2120 md: vec![MdArray {
2121 name: "md0".to_owned(),
2122 active: true,
2123 level: "raid1".to_owned(),
2124 expected_devices: Some(2),
2125 working_devices: Some(1),
2126 ..Default::default()
2127 }],
2128 zfs: vec![
2129 ZfsPool {
2130 name: "tank".to_owned(),
2131 state: "DEGRADED".to_owned(),
2132 ..Default::default()
2133 },
2134 ZfsPool {
2135 name: "cold".to_owned(),
2136 state: "OFFLINE".to_owned(),
2137 ..Default::default()
2138 },
2139 ZfsPool {
2140 name: "fast".to_owned(),
2141 state: "ONLINE".to_owned(),
2142 ..Default::default()
2143 },
2144 ],
2145 },
2146 smart: vec![SmartDevice {
2147 device: "sda".to_owned(),
2148 passed: Some(true),
2149 ..Default::default()
2150 }],
2151 ..Default::default()
2152 };
2153 let alerts = alerts(&snapshot);
2154 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
2155 assert_eq!(
2156 texts,
2157 vec![
2158 "btrfs /: 2 I/O errors (devid 1)",
2159 "md0 (raid1) degraded",
2160 "zfs tank DEGRADED",
2161 "zfs cold OFFLINE",
2162 "/data 92% full"
2163 ]
2164 );
2165 assert_eq!(alerts[0].severity, Severity::Critical);
2166 assert_eq!(alerts[2].severity, Severity::Critical);
2167 assert_eq!(alerts[3].severity, Severity::Warning);
2168 assert!(super::alerts(&Snapshot::default()).is_empty());
2169 }
2170
2171 #[test]
2172 fn event_times_are_as_short_as_is_clear() {
2173 let now = SystemTime::now();
2174 let ms = |t: SystemTime| {
2175 t.duration_since(SystemTime::UNIX_EPOCH)
2176 .unwrap()
2177 .as_millis() as u64
2178 };
2179 assert_eq!(event_time(ms(now), now).len(), 8);
2181 let week_ago = now - Duration::from_secs(10 * 86_400);
2182 assert!(event_time(ms(week_ago), now).contains(' '));
2183 assert_ne!(event_time(ms(week_ago), now).len(), 8);
2184 }
2185
2186 #[test]
2187 fn snoozing_a_raised_alert_logs_no_clearing() {
2188 let disk = Alert {
2189 severity: Severity::Warning,
2190 text: "sdc: SMART warning".to_owned(),
2191 };
2192 let mut log = AlertLog::default();
2193 assert_eq!(log.update("nas", std::slice::from_ref(&disk), 1).len(), 1);
2194 assert!(
2196 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 2)
2197 .is_empty()
2198 );
2199 assert!(
2200 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 3)
2201 .is_empty()
2202 );
2203 let events = log.update_snoozed("nas", std::slice::from_ref(&disk), &[], 4);
2205 assert_eq!(events.len(), 1);
2206 assert_eq!(events[0].change, AlertChange::Raised);
2207 assert_eq!(
2209 log.update_snoozed("nas", &[], &[], 5)[0].change,
2210 AlertChange::Cleared
2211 );
2212 }
2213
2214 #[test]
2215 fn alert_history_survives_a_restart() {
2216 let tmp = tempfile::tempdir().unwrap();
2217 let path = tmp.path().join("state/alerts-tui.jsonl");
2218 let degraded = Alert {
2219 severity: Severity::Critical,
2220 text: "md0 (raid1) degraded".to_owned(),
2221 };
2222 let full = Alert {
2223 severity: Severity::Warning,
2224 text: "/srv 92% full".to_owned(),
2225 };
2226 let mut log = AlertLog::persistent(path.clone());
2227 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
2228 log.update("nas", std::slice::from_ref(°raded), 2_000);
2229 assert_eq!(log.events().count(), 3);
2230
2231 let mut log = AlertLog::persistent(path.clone());
2234 assert_eq!(log.events().count(), 3);
2235 assert_eq!(log.unread(), (0, None));
2236 assert!(
2237 log.update("nas", std::slice::from_ref(°raded), 3_000)
2238 .is_empty()
2239 );
2240 assert_eq!(log.events().count(), 3);
2241 let changes = log.update("nas", &[], 4_000);
2242 assert_eq!(changes.len(), 1);
2243 assert_eq!(changes[0].alert, degraded);
2244 let last = log.events().last().unwrap();
2245 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
2246 assert_eq!(AlertLog::persistent(path.clone()).events().count(), 4);
2247
2248 let exited = Alert {
2251 severity: Severity::Warning,
2252 text: "train.py (pid 7) exited after 3h".to_owned(),
2253 };
2254 let mut log = AlertLog::persistent(path.clone());
2255 log.record("nas", exited.clone(), 5_000);
2256 assert_eq!(log.unread(), (1, Some(Severity::Warning)));
2257 let mut log = AlertLog::persistent(path);
2258 assert!(log.update("nas", &[], 6_000).is_empty());
2259 let last = log.events().last().unwrap();
2260 assert_eq!((last.change, &last.alert), (AlertChange::Notice, &exited));
2261 }
2262
2263 #[test]
2264 fn zfs_devices_data_errors_and_scans() {
2265 use crate::snapshot::{RaidStats, ZfsDevice, ZfsPool};
2266 let device = |name: &str, depth, state: &str, checksum| ZfsDevice {
2267 name: name.to_owned(),
2268 depth,
2269 state: state.to_owned(),
2270 checksum_errors: checksum,
2271 ..Default::default()
2272 };
2273 let snapshot = Snapshot {
2274 raid: RaidStats {
2275 zfs: vec![ZfsPool {
2276 name: "tank".to_owned(),
2277 state: "DEGRADED".to_owned(),
2278 devices: vec![
2279 device("raidz1-0", 0, "DEGRADED", 0),
2280 device("sda", 1, "ONLINE", 491),
2281 device("sdb", 1, "OFFLINE", 0),
2282 device("sdc", 1, "ONLINE", 0),
2283 ],
2284 scan: Some(
2285 "resilver in progress since Sat Oct 31 19:33:41 2026 · 42.12% done"
2286 .to_owned(),
2287 ),
2288 data_errors: Some(3),
2289 ..Default::default()
2290 }],
2291 ..Default::default()
2292 },
2293 ..Default::default()
2294 };
2295 let texts: Vec<String> = alerts(&snapshot).into_iter().map(|a| a.text).collect();
2296 for expected in [
2297 "zfs tank DEGRADED",
2298 "zfs tank: 3 data errors",
2299 "zfs tank: sda has 491 errors",
2300 "zfs tank: sdb OFFLINE",
2301 "zfs tank resilver 42.1%",
2302 ] {
2303 assert!(
2304 texts.iter().any(|t| t == expected),
2305 "{expected} in {texts:?}"
2306 );
2307 }
2308
2309 let summary = |s| zfs_scan_summary(s);
2310 assert_eq!(
2311 summary("scrub repaired 0B in 00:00:00 with 0 errors on Sat Oct 31 19:33:21 2026"),
2312 ("scrub Oct 31, ok".to_owned(), None)
2313 );
2314 assert_eq!(
2315 summary("scrub repaired 27.7M in 00:00:00 with 0 errors on Sat Oct 31 19:33:41 2026"),
2316 (
2317 "scrub Oct 31, repaired 27.7M".to_owned(),
2318 Some(Severity::Warning)
2319 )
2320 );
2321 assert_eq!(
2322 summary("scrub repaired 22K in 00:00:00 with 442 errors on Sat Oct 31 19:33:54 2026").1,
2323 Some(Severity::Critical)
2324 );
2325 assert_eq!(
2326 summary("resilvered 36K in 00:00:00 with 0 errors on Sat Oct 31 19:33:54 2026").0,
2327 "resilver Oct 31, ok"
2328 );
2329 assert_eq!(
2330 summary("scrub paused since Sat Oct 31 19:34:09 2026 · 0B repaired, 75.76% done").0,
2331 "scrub paused 75.8%"
2332 );
2333 assert_eq!(summary("none requested").0, "no scrub yet");
2334 }
2335
2336 #[test]
2337 fn cards_skip_gpus_without_readings() {
2338 let snapshot = Snapshot {
2339 gpus: vec![
2340 GpuStats {
2341 index: 0,
2342 name: "Intel UHD Graphics".to_owned(),
2343 temperature_c: Some(50.0),
2344 ..Default::default()
2345 },
2346 GpuStats {
2347 index: 1,
2348 utilization_percent: Some(3.0),
2349 ..Default::default()
2350 },
2351 ],
2352 ..Default::default()
2353 };
2354 let shown: Vec<u32> = card_gpus(&snapshot, IntegratedGpu::Compact)
2355 .map(|g| g.index)
2356 .collect();
2357 assert_eq!(shown, [1]);
2358 }
2359
2360 #[test]
2361 fn address_line_lists_the_interface_then_the_public_address() {
2362 let net = NetworkStats {
2363 addresses: vec!["192.0.2.10/24".to_owned(), "2001:db8::10/64".to_owned()],
2364 ..Default::default()
2365 };
2366 assert_eq!(
2367 address_text(Some(&net), Some("203.0.113.7")).as_deref(),
2368 Some("192.0.2.10/24 · public 203.0.113.7 · 2001:db8::10/64")
2369 );
2370 let many = NetworkStats {
2371 addresses: vec![
2372 "192.0.2.10/24".to_owned(),
2373 "2001:db8::10/64".to_owned(),
2374 "2001:db8::a/64".to_owned(),
2375 "2001:db8::b/64".to_owned(),
2376 ],
2377 ..Default::default()
2378 };
2379 assert_eq!(
2380 address_text(Some(&many), None).as_deref(),
2381 Some("192.0.2.10/24 · 2001:db8::10/64 +2")
2382 );
2383 assert_eq!(
2384 address_text(Some(&net), None).as_deref(),
2385 Some("192.0.2.10/24 · 2001:db8::10/64")
2386 );
2387 assert_eq!(address_text(None, None), None);
2388 assert_eq!(
2389 address_text(Some(&NetworkStats::default()), None),
2390 None,
2391 "an interface without addresses"
2392 );
2393 }
2394
2395 #[test]
2396 fn integrated_gpus_step_aside_for_a_discrete_one() {
2397 let gpu = |index, integrated| GpuStats {
2398 index,
2399 integrated,
2400 utilization_percent: Some(1.0),
2401 memory_used_bytes: Some(1 << 30),
2402 ..Default::default()
2403 };
2404 let desktop = Snapshot {
2405 gpus: vec![gpu(0, false), gpu(1, false), gpu(2, true)],
2406 ..Default::default()
2407 };
2408 let rows = |snapshot, mode| -> Vec<(usize, GpuRow)> {
2409 panel_gpus(snapshot, mode)
2410 .into_iter()
2411 .map(|(i, _, row)| (i, row))
2412 .collect()
2413 };
2414 assert_eq!(
2415 rows(&desktop, IntegratedGpu::Compact),
2416 [(0, GpuRow::Full), (1, GpuRow::Full), (2, GpuRow::Compact)]
2417 );
2418 assert_eq!(
2419 rows(&desktop, IntegratedGpu::Hide),
2420 [(0, GpuRow::Full), (1, GpuRow::Full)]
2421 );
2422 assert_eq!(rows(&desktop, IntegratedGpu::Full).len(), 3);
2423 let cards =
2424 |snapshot, mode| -> Vec<u32> { card_gpus(snapshot, mode).map(|g| g.index).collect() };
2425 assert_eq!(cards(&desktop, IntegratedGpu::Compact), [0, 1]);
2426 assert_eq!(cards(&desktop, IntegratedGpu::Full), [0, 1, 2]);
2427
2428 let laptop = Snapshot {
2430 gpus: vec![gpu(0, true)],
2431 ..Default::default()
2432 };
2433 assert_eq!(rows(&laptop, IntegratedGpu::Hide), [(0, GpuRow::Full)]);
2434 assert_eq!(cards(&laptop, IntegratedGpu::Compact), [0]);
2435 }
2436
2437 #[test]
2438 fn containers_add_up_their_processes() {
2439 use crate::snapshot::GpuProcess;
2440 let process = |pid, container: Option<&str>, cpu, mem| ProcessInfo {
2441 pid,
2442 container: container.map(str::to_owned),
2443 cpu_percent: cpu,
2444 memory_bytes: mem,
2445 ..Default::default()
2446 };
2447 let snapshot = Snapshot {
2448 processes: vec![
2449 process(1, None, 50.0, 100),
2450 process(2, Some("db"), 3.0, 500),
2451 process(3, Some("web"), 10.0, 200),
2452 process(4, Some("web"), 5.0, 300),
2453 ],
2454 gpus: vec![GpuStats {
2455 processes: vec![GpuProcess {
2456 pid: 3,
2457 sm_percent: Some(40.0),
2458 memory_bytes: Some(1 << 30),
2459 }],
2460 ..Default::default()
2461 }],
2462 ..Default::default()
2463 };
2464 let usage = container_usage(&snapshot);
2465 assert_eq!(usage.len(), 2, "processes outside containers are left out");
2466 assert_eq!(usage[0].name, "web");
2467 assert_eq!(usage[0].processes, 2);
2468 assert_eq!(usage[0].cpu_percent, 15.0);
2469 assert_eq!(usage[0].memory_bytes, 500);
2470 assert_eq!(usage[0].gpu_percent, Some(40.0));
2471 assert_eq!(usage[0].vram_bytes, Some(1 << 30));
2472 assert_eq!(usage[1].gpu_percent, None);
2473 assert!(container_usage(&Snapshot::default()).is_empty());
2474 }
2475
2476 #[test]
2477 fn processes_match_by_name_or_file() {
2478 let process = ProcessInfo {
2479 name: "python3".to_owned(),
2480 command: "/usr/bin/python3 /home/me/jobs/train.py --epochs 3".to_owned(),
2481 ..Default::default()
2482 };
2483 assert!(process_matches(&process, "python3"));
2484 assert!(process_matches(&process, "train.py"));
2485 assert!(process_matches(&process, " train.py "));
2486 assert!(process_matches(&process, "/home/me/jobs/train.py"));
2487 assert!(!process_matches(&process, "train"));
2488 assert!(!process_matches(&process, "epochs"));
2489 assert!(!process_matches(&process, ""));
2490 }
2491
2492 #[test]
2493 fn exit_watch_reports_exits_it_can_tell() {
2494 let process = |pid: u32, name: &str| ProcessInfo {
2495 pid,
2496 name: name.to_owned(),
2497 run_time_secs: 3_600,
2498 ..Default::default()
2499 };
2500 let mut watch = ExitWatch::default();
2501 assert!(watch.toggle(&process(7, "train.py"), 10_000_000));
2502 assert!(watch.toggle(&process(8, "make"), 10_000_000));
2503 assert!(!watch.toggle(&process(8, "make"), 10_000_000));
2504 assert_eq!(watch.pids(), [7]);
2505
2506 let mut snapshot = Snapshot::default();
2509 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2510 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2511 snapshot.processes = vec![process(7, "train.py")];
2513 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2514
2515 snapshot.watched_pids = vec![7];
2516 snapshot.processes = vec![process(7, "train.py")];
2517 assert_eq!(watch.state(&snapshot, 7), WatchState::Running);
2518 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2519
2520 snapshot.processes.clear();
2522 assert_eq!(watch.state(&snapshot, 7), WatchState::Exited);
2523 let alerts = watch.check(&snapshot, 10_000_000 + 1_800_000);
2524 assert_eq!(alerts.len(), 1);
2525 assert_eq!(alerts[0].text, "train.py (pid 7) exited after 1h30m");
2526 assert_eq!(watch.pids(), Vec::<u32>::new(), "reported once");
2527
2528 watch.toggle(&process(9, "rsync"), 0);
2530 snapshot.processes = vec![process(9, "bash")];
2531 assert_eq!(watch.check(&snapshot, 0).len(), 1);
2532
2533 let python = process(12, "python3");
2535 assert!(watch.watch_as(&python, Some("train.py"), 0));
2536 assert!(!watch.watch_as(&python, Some("train.py"), 0), "once");
2537 snapshot.processes.clear();
2538 snapshot.watched_pids = vec![12];
2539 assert_eq!(
2540 watch.check(&snapshot, 60_000)[0].text,
2541 "train.py (pid 12) exited after 01:00"
2542 );
2543 }
2544
2545 #[test]
2546 fn failed_units_warn_unless_ignored() {
2547 let snapshot = Snapshot {
2548 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
2549 ..Default::default()
2550 };
2551 let limits = AlertsConfig {
2552 ignored_units: vec!["wait-online.service".to_owned()],
2553 ..Default::default()
2554 };
2555 let alerts = alerts_with(&snapshot, &limits);
2556 assert_eq!(alerts.len(), 1);
2557 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
2558 assert_eq!(alerts[0].severity, Severity::Warning);
2559 }
2560
2561 #[test]
2562 fn version_notes_only_for_other_versions() {
2563 let mut snapshot = Snapshot::default();
2564 assert_eq!(version_note(&snapshot), None);
2565 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
2566 assert_eq!(version_note(&snapshot), None);
2567 snapshot.host.rigtop_version = "0.1.0".to_owned();
2568 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
2569 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
2570 }
2571
2572 #[test]
2573 fn alert_log_records_alerts_appearing_and_clearing() {
2574 let alert = |severity, text: &str| Alert {
2575 severity,
2576 text: text.to_owned(),
2577 };
2578 let full = alert(Severity::Warning, "/data 91% full");
2579 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
2580 let mut log = AlertLog::default();
2581 log.update("nas", std::slice::from_ref(&full), 1_000);
2582 log.update(
2584 "nas",
2585 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
2586 2_000,
2587 );
2588 log.update("zefat", &[], 2_000);
2589 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
2590 log.mark_read();
2591 log.update("nas", std::slice::from_ref(&errors), 3_000);
2592 assert_eq!(log.unread(), (0, None));
2593 let summary: Vec<(u64, AlertChange, &str)> = log
2594 .events()
2595 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
2596 .collect();
2597 assert_eq!(
2598 summary,
2599 [
2600 (1_000, AlertChange::Raised, "/data 91% full"),
2601 (
2602 2_000,
2603 AlertChange::Raised,
2604 "btrfs /: 2 I/O errors (nvme1n1)"
2605 ),
2606 (3_000, AlertChange::Cleared, "/data 92% full"),
2607 ]
2608 );
2609 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
2610 log.update(
2611 "flappy",
2612 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
2613 i,
2614 );
2615 }
2616 assert_eq!(log.events().count(), ALERT_LOG_LEN);
2617 }
2618
2619 #[test]
2620 fn filesystems_filling_up_warn() {
2621 use crate::snapshot::FilesystemUsage;
2622 let filling = |full_in_secs| Snapshot {
2623 filesystems: vec![FilesystemUsage {
2624 mount_points: vec!["/srv".to_owned()],
2625 total_bytes: 100,
2626 available_bytes: 50,
2627 full_in_secs,
2628 ..Default::default()
2629 }],
2630 ..Default::default()
2631 };
2632 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<String> {
2633 alerts_with(snapshot, limits)
2634 .into_iter()
2635 .map(|a| a.text)
2636 .collect()
2637 };
2638 let defaults = AlertsConfig::default();
2639 assert_eq!(
2640 texts(&filling(Some(5 * 3600)), &defaults),
2641 ["/srv full in ~5h00m"]
2642 );
2643 assert!(texts(&filling(Some(30 * 3600)), &defaults).is_empty());
2644 assert!(texts(&filling(None), &defaults).is_empty());
2645 let off = AlertsConfig {
2646 filesystem_full_warning_hours: 0.0,
2647 ..Default::default()
2648 };
2649 assert!(texts(&filling(Some(60)), &off).is_empty());
2650 }
2651
2652 #[test]
2653 fn alert_thresholds_come_from_the_configuration() {
2654 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
2655 let temp = |label: &str, celsius| Temperature {
2656 label: label.to_owned(),
2657 celsius,
2658 ..Default::default()
2659 };
2660 let mut snapshot = Snapshot {
2661 filesystems: vec![FilesystemUsage {
2662 mount_points: vec!["/data".to_owned()],
2663 total_bytes: 100,
2664 available_bytes: 8,
2665 ..Default::default()
2666 }],
2667 gpus: vec![GpuStats {
2668 index: 0,
2669 temperature_c: Some(80.0),
2670 ..Default::default()
2671 }],
2672 smart: vec![SmartDevice {
2673 device: "nvme0".to_owned(),
2674 temperature_c: Some(72.0),
2675 ..Default::default()
2676 }],
2677 sensors: vec![
2679 SensorChip {
2680 key: "nvme:nvme0".to_owned(),
2681 name: "nvme".to_owned(),
2682 temperatures: vec![temp("Composite", 71.0)],
2683 ..Default::default()
2684 },
2685 SensorChip {
2686 key: "nvme:nvme1".to_owned(),
2687 name: "nvme".to_owned(),
2688 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
2689 ..Default::default()
2690 },
2691 ],
2692 ..Default::default()
2693 };
2694 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
2695 let texts_of = |snapshot: &Snapshot| -> Vec<String> {
2696 alerts_with(snapshot, &AlertsConfig::default())
2697 .into_iter()
2698 .map(|a| a.text)
2699 .collect()
2700 };
2701 let texts = |limits: &AlertsConfig| -> Vec<String> {
2702 alerts_with(&snapshot, limits)
2703 .into_iter()
2704 .map(|a| a.text)
2705 .collect()
2706 };
2707 assert_eq!(
2708 texts(&AlertsConfig::default()),
2709 [
2710 "/data 92% full",
2711 "CPU at 97°C",
2712 "nvme0 at 72°C",
2713 "nvme1 at 74°C"
2714 ]
2715 );
2716 let custom = AlertsConfig {
2717 filesystem_warning_percent: 0.0,
2718 cpu_warning_celsius: 0.0,
2719 gpu_warning_celsius: 75.0,
2720 drive_warning_celsius: 73.0,
2721 ..Default::default()
2722 };
2723 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
2724
2725 let mut gpu = snapshot.gpus[0].clone();
2727 gpu.throttle_reasons = vec!["power cap".to_owned()];
2728 let mut capped = snapshot.clone();
2729 capped.gpus = vec![gpu.clone()];
2730 assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
2731 gpu.throttle_reasons.push("hw thermal".to_owned());
2732 gpu.ecc = Some(EccErrors {
2733 corrected: 5,
2734 uncorrected: 2,
2735 });
2736 capped.gpus = vec![gpu];
2737 let alerts = alerts_with(&capped, &AlertsConfig::default());
2738 assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
2739 assert_eq!(alerts[0].severity, Severity::Critical);
2740 assert!(
2741 alerts
2742 .iter()
2743 .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
2744 );
2745 let critical = AlertsConfig {
2746 filesystem_critical_percent: 90.0,
2747 ..custom
2748 };
2749 assert_eq!(
2750 alerts_with(&snapshot, &critical)[0],
2751 Alert {
2752 severity: Severity::Critical,
2753 text: "/data 92% full".to_owned()
2754 }
2755 );
2756 }
2757
2758 #[test]
2759 fn pressure_alerts_and_readings() {
2760 use crate::snapshot::PressureLevels;
2761 let levels = |some: f32, full: f32| PressureLevels {
2762 some_avg10: some,
2763 some_avg60: some,
2764 full_avg10: full,
2765 full_avg60: full,
2766 };
2767 let mut snapshot = Snapshot {
2768 pressure: Some(Pressure {
2769 cpu: levels(40.0, 0.0),
2770 memory: levels(0.2, 0.1),
2771 io: levels(80.0, 12.0),
2772 }),
2773 ..Default::default()
2774 };
2775 let limits = AlertsConfig::default();
2776 assert!(alerts_with(&snapshot, &limits).is_empty());
2778 let names = |snapshot: &Snapshot| -> Vec<(&'static str, String, bool)> {
2779 pressure_items(snapshot.pressure.as_ref().unwrap(), &limits)
2780 .into_iter()
2781 .map(|i| (i.name, i.value, i.warn))
2782 .collect()
2783 };
2784 assert_eq!(
2786 names(&snapshot),
2787 [
2788 ("cpu", "40%".to_owned(), false),
2789 ("io", "80%".to_owned(), false)
2790 ]
2791 );
2792
2793 snapshot.pressure = Some(Pressure {
2794 cpu: levels(0.0, 0.0),
2795 memory: levels(25.0, 4.0),
2796 io: levels(90.0, 45.0),
2797 });
2798 let texts: Vec<String> = alerts_with(&snapshot, &limits)
2799 .into_iter()
2800 .map(|a| a.text)
2801 .collect();
2802 assert_eq!(texts, ["memory pressure 25%", "I/O pressure 45%"]);
2803 assert_eq!(
2804 names(&snapshot),
2805 [
2806 ("mem", "25%".to_owned(), true),
2807 ("io", "90%".to_owned(), true)
2808 ]
2809 );
2810 assert!(
2811 pressure_details(snapshot.pressure.as_ref().unwrap())
2812 .contains("io: some 90% / 90%, full 45% / 45%")
2813 );
2814 }
2815
2816 #[test]
2817 fn memory_alerts() {
2818 use crate::snapshot::{MemoryStats, OomKill};
2819 let kill = |name: Option<&str>| OomKill {
2820 at_ms: 1,
2821 likely_process: name.map(str::to_owned),
2822 };
2823 let mut snapshot = Snapshot {
2824 memory: MemoryStats {
2825 total_bytes: 100,
2826 used_bytes: 93,
2827 oom_kills: vec![kill(Some("python3"))],
2828 ..Default::default()
2829 },
2830 gpus: vec![GpuStats {
2831 index: 1,
2832 memory_used_bytes: Some(99),
2833 memory_total_bytes: Some(100),
2834 ..Default::default()
2835 }],
2836 ..Default::default()
2837 };
2838 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<(Severity, String)> {
2839 alerts_with(snapshot, limits)
2840 .into_iter()
2841 .map(|a| (a.severity, a.text))
2842 .collect()
2843 };
2844 assert_eq!(
2846 texts(&snapshot, &AlertsConfig::default()),
2847 [
2848 (
2849 Severity::Critical,
2850 "OOM killer stopped python3 (likely)".to_owned()
2851 ),
2852 (Severity::Warning, "memory 93% used".to_owned()),
2853 ]
2854 );
2855 let vram = AlertsConfig {
2856 memory_warning_percent: 0.0,
2857 gpu_memory_warning_percent: 98.0,
2858 ..Default::default()
2859 };
2860 snapshot.memory.oom_kills = vec![kill(Some("bash")), kill(None), kill(Some("python3"))];
2861 assert_eq!(
2862 texts(&snapshot, &vram),
2863 [
2864 (
2865 Severity::Critical,
2866 "OOM killer stopped 3 processes (likely python3, bash)".to_owned()
2867 ),
2868 (Severity::Warning, "GPU 1 memory 99% used".to_owned()),
2869 ]
2870 );
2871 snapshot.memory.oom_kills = vec![kill(None)];
2872 assert_eq!(texts(&snapshot, &vram)[0].1, "OOM killer stopped a process");
2873 }
2874
2875 #[test]
2876 fn interface_helpers() {
2877 assert!(is_virtual_interface("veth5a74356"));
2878 assert!(!is_virtual_interface("enp9s0"));
2879 let snapshot = Snapshot {
2880 networks: vec![
2881 NetworkStats {
2882 name: "docker0".to_owned(),
2883 rx_total_bytes: 1 << 40,
2884 ..Default::default()
2885 },
2886 NetworkStats {
2887 name: "enp9s0".to_owned(),
2888 rx_total_bytes: 1 << 30,
2889 ..Default::default()
2890 },
2891 ],
2892 ..Default::default()
2893 };
2894 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
2895 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
2896 }
2897}