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