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