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