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