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