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
928pub const PRESSURE_LABEL: &str = "Stall";
930
931#[derive(Debug, Clone, PartialEq)]
934pub struct PressureItem {
935 pub name: &'static str,
937 pub value: String,
939 pub warn: bool,
941}
942
943pub fn pressure_items(pressure: &Pressure, limits: &AlertsConfig) -> Vec<PressureItem> {
946 let over = |value: f32, limit: f64| limit > 0.0 && value as f64 >= limit;
947 let item = |name, value: f32, warn| PressureItem {
948 name,
949 value: format!("{value:.0}%"),
950 warn,
951 };
952 let items = [
953 item("cpu", pressure.cpu.some_avg10, false),
954 item(
955 "mem",
956 pressure.memory.some_avg10,
957 over(
958 pressure.memory.some_avg60,
959 limits.memory_pressure_warning_percent,
960 ),
961 ),
962 item(
963 "io",
964 pressure.io.some_avg10,
965 over(pressure.io.full_avg60, limits.io_pressure_warning_percent),
966 ),
967 ];
968 items
969 .into_iter()
970 .filter(|item| item.warn || item.value != "0%")
971 .collect()
972}
973
974pub fn pressure_details(pressure: &Pressure) -> String {
977 let line = |name: &str, levels: &crate::snapshot::PressureLevels| {
978 format!(
979 "{name}: some {:.0}% / {:.0}%, full {:.0}% / {:.0}%",
980 levels.some_avg10, levels.some_avg60, levels.full_avg10, levels.full_avg60
981 )
982 };
983 [
984 "Share of time tasks waited (10 s / 60 s averages); some: at least one \
985 task, full: all of them"
986 .to_owned(),
987 line("cpu", &pressure.cpu),
988 line("memory", &pressure.memory),
989 line("io", &pressure.io),
990 ]
991 .join("\n")
992}
993
994pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
996 alerts_with(snapshot, &AlertsConfig::default())
997}
998
999pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
1004 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
1006 let mut alerts = Vec::new();
1007 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
1008 for fs in &snapshot.raid.btrfs {
1009 let name = fs
1010 .mount_points
1011 .first()
1012 .map_or(fs.fsid.as_str(), String::as_str);
1013 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
1014 if missing > 0 {
1015 add(
1016 Severity::Critical,
1017 format!("btrfs {name}: {missing} device(s) missing"),
1018 );
1019 }
1020 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
1021 if errors > 0 {
1022 let devices: Vec<String> = fs
1024 .device_stats
1025 .iter()
1026 .filter(|d| d.total_errors() > 0)
1027 .map(|d| d.label())
1028 .collect();
1029 add(
1030 Severity::Critical,
1031 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
1032 );
1033 }
1034 if let Some(scrub) = &fs.scrub {
1035 if scrub.uncorrectable > 0 {
1036 add(
1037 Severity::Critical,
1038 format!(
1039 "btrfs {name}: scrub found {} uncorrectable errors",
1040 scrub.uncorrectable
1041 ),
1042 );
1043 } else if scrub.errors > 0 {
1044 add(
1045 Severity::Warning,
1046 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
1047 );
1048 }
1049 }
1050 }
1051 for md in &snapshot.raid.md {
1052 if md.active && md.is_degraded() {
1053 add(
1054 Severity::Critical,
1055 format!("{} ({}) degraded", md.name, md.level),
1056 );
1057 } else if !md.active {
1058 add(Severity::Warning, format!("{} inactive", md.name));
1059 }
1060 if let Some(sync) = &md.sync {
1061 add(
1062 Severity::Warning,
1063 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
1064 );
1065 }
1066 }
1067 for pool in &snapshot.raid.zfs {
1068 if pool.is_failing() {
1069 add(
1070 Severity::Critical,
1071 format!("zfs {} {}", pool.name, pool.state),
1072 );
1073 } else if !pool.is_online() {
1074 add(
1075 Severity::Warning,
1076 format!("zfs {} {}", pool.name, pool.state),
1077 );
1078 }
1079 }
1080 for unit in &snapshot.failed_units {
1081 if !limits.ignored_units.contains(unit) {
1082 add(Severity::Warning, format!("systemd: {unit} failed"));
1083 }
1084 }
1085 for smart in &snapshot.smart {
1086 if let Some(error) = &smart.error {
1087 add(
1088 Severity::Warning,
1089 format!("{}: SMART unreadable ({error})", smart.device),
1090 );
1091 } else if smart.passed == Some(false) {
1092 add(
1093 Severity::Critical,
1094 format!("{}: SMART failed", smart.device),
1095 );
1096 } else if smart.needs_attention() {
1097 add(
1098 Severity::Critical,
1099 format!("{}: SMART warning", smart.device),
1100 );
1101 }
1102 }
1103 for fs in &snapshot.filesystems {
1104 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
1105 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
1106 let name = fs
1107 .mount_points
1108 .first()
1109 .map_or(fs.device.as_str(), String::as_str);
1110 if reached(percent, limits.filesystem_critical_percent) {
1111 add(Severity::Critical, format!("{name} {percent:.0}% full"));
1112 } else if reached(percent, limits.filesystem_warning_percent) {
1113 add(Severity::Warning, format!("{name} {percent:.0}% full"));
1114 }
1115 let soon = limits.filesystem_full_warning_hours * 3600.0;
1116 if let Some(secs) = fs.full_in_secs
1117 && soon > 0.0
1118 && secs as f64 <= soon
1119 {
1120 add(
1121 Severity::Warning,
1122 format!("{name} full in ~{}", format::duration(secs)),
1123 );
1124 }
1125 }
1126 if let Some(text) = oom_alert(&snapshot.memory.oom_kills) {
1127 add(Severity::Critical, text);
1128 }
1129 let memory = &snapshot.memory;
1130 let percent = memory.used_bytes as f64 / memory.total_bytes.max(1) as f64 * 100.0;
1131 if memory.total_bytes > 0 && reached(percent, limits.memory_warning_percent) {
1132 add(Severity::Warning, format!("memory {percent:.0}% used"));
1133 }
1134 if let Some(pressure) = &snapshot.pressure {
1135 let memory = pressure.memory.some_avg60 as f64;
1136 if reached(memory, limits.memory_pressure_warning_percent) {
1137 add(Severity::Warning, format!("memory pressure {memory:.0}%"));
1138 }
1139 let io = pressure.io.full_avg60 as f64;
1140 if reached(io, limits.io_pressure_warning_percent) {
1141 add(Severity::Warning, format!("I/O pressure {io:.0}%"));
1142 }
1143 }
1144 let hottest_cpu = snapshot
1145 .cpu
1146 .temperatures
1147 .iter()
1148 .map(|t| t.celsius)
1149 .chain(snapshot.cpu.per_core_celsius.iter().flatten().copied())
1150 .fold(f32::NAN, f32::max);
1151 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
1152 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
1153 }
1154 for gpu in &snapshot.gpus {
1155 if let Some(t) = gpu
1156 .temperature_c
1157 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
1158 {
1159 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
1160 }
1161 if let (Some(used), Some(total)) = (gpu.memory_used_bytes, gpu.memory_total_bytes)
1162 && total > 0
1163 {
1164 let percent = used as f64 / total as f64 * 100.0;
1165 if reached(percent, limits.gpu_memory_warning_percent) {
1166 add(
1167 Severity::Warning,
1168 format!("GPU {} memory {percent:.0}% used", gpu.index),
1169 );
1170 }
1171 }
1172 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
1173 if !slowdown.is_empty() {
1174 add(
1175 Severity::Warning,
1176 format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
1177 );
1178 }
1179 if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
1180 add(
1181 Severity::Critical,
1182 format!(
1183 "GPU {}: {} uncorrected ECC errors",
1184 gpu.index, ecc.uncorrected
1185 ),
1186 );
1187 }
1188 }
1189 for (device, celsius) in drive_temperatures(snapshot) {
1190 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
1191 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
1192 }
1193 }
1194 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
1196 alerts
1197}
1198
1199fn oom_alert(kills: &[OomKill]) -> Option<String> {
1203 let mut names: Vec<&str> = Vec::new();
1204 for name in kills
1205 .iter()
1206 .rev()
1207 .filter_map(|k| k.likely_process.as_deref())
1208 {
1209 if !names.contains(&name) {
1210 names.push(name);
1211 }
1212 }
1213 let likely = if names.is_empty() {
1214 String::new()
1215 } else {
1216 format!(" (likely {})", names.join(", "))
1217 };
1218 match kills.len() {
1219 0 => None,
1220 1 => Some(match names.first() {
1221 Some(name) => format!("OOM killer stopped {name} (likely)"),
1222 None => "OOM killer stopped a process".to_owned(),
1223 }),
1224 n => Some(format!("OOM killer stopped {n} processes{likely}")),
1225 }
1226}
1227
1228fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
1231 let mut drives: Vec<(String, f32)> = snapshot
1232 .smart
1233 .iter()
1234 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
1235 .collect();
1236 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
1237 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
1239 if drives.iter().any(|(d, _)| d == device) {
1240 continue;
1241 }
1242 let composite = chip
1243 .temperatures
1244 .iter()
1245 .find(|t| t.label == "Composite")
1246 .or(chip.temperatures.first());
1247 if let Some(t) = composite {
1248 drives.push((device.to_owned(), t.celsius));
1249 }
1250 }
1251 drives
1252}
1253
1254fn gpu_usage_by_pid(snapshot: &Snapshot) -> HashMap<u32, (Option<f32>, Option<u64>)> {
1256 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
1257 for gpu in &snapshot.gpus {
1258 for p in &gpu.processes {
1259 let entry = gpu_usage.entry(p.pid).or_default();
1260 if let Some(sm) = p.sm_percent {
1261 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
1262 }
1263 if let Some(mem) = p.memory_bytes {
1264 entry.1 = Some(entry.1.unwrap_or(0) + mem);
1265 }
1266 }
1267 }
1268 gpu_usage
1269}
1270
1271#[derive(Debug, Clone, PartialEq)]
1273pub struct ContainerUsage {
1274 pub name: String,
1275 pub processes: usize,
1276 pub cpu_percent: f32,
1277 pub memory_bytes: u64,
1278 pub gpu_percent: Option<f32>,
1280 pub vram_bytes: Option<u64>,
1281}
1282
1283pub fn container_usage(snapshot: &Snapshot) -> Vec<ContainerUsage> {
1286 let gpu_usage = gpu_usage_by_pid(snapshot);
1287 let mut by_name: HashMap<&str, ContainerUsage> = HashMap::new();
1288 for process in &snapshot.processes {
1289 let Some(name) = process.container.as_deref() else {
1290 continue;
1291 };
1292 let usage = by_name.entry(name).or_insert_with(|| ContainerUsage {
1293 name: name.to_owned(),
1294 processes: 0,
1295 cpu_percent: 0.0,
1296 memory_bytes: 0,
1297 gpu_percent: None,
1298 vram_bytes: None,
1299 });
1300 usage.processes += 1;
1301 usage.cpu_percent += process.cpu_percent;
1302 usage.memory_bytes += process.memory_bytes;
1303 if let Some((gpu, vram)) = gpu_usage.get(&process.pid) {
1304 if let Some(gpu) = gpu {
1305 usage.gpu_percent = Some(usage.gpu_percent.unwrap_or(0.0) + gpu);
1306 }
1307 if let Some(vram) = vram {
1308 usage.vram_bytes = Some(usage.vram_bytes.unwrap_or(0) + vram);
1309 }
1310 }
1311 }
1312 let mut list: Vec<ContainerUsage> = by_name.into_values().collect();
1313 list.sort_by(|a, b| {
1314 b.cpu_percent
1315 .total_cmp(&a.cpu_percent)
1316 .then(b.memory_bytes.cmp(&a.memory_bytes))
1317 .then(a.name.cmp(&b.name))
1318 });
1319 list
1320}
1321
1322#[derive(Debug, Clone, Copy)]
1324pub struct ProcessQuery<'q> {
1325 pub sort_key: SortKey,
1327 pub descending: bool,
1329 pub filter: &'q str,
1331 pub show_kernel_threads: bool,
1333 pub tree: bool,
1336}
1337
1338pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
1340 let gpu_usage = gpu_usage_by_pid(snapshot);
1341
1342 let filter = query.filter.to_lowercase();
1343 let matches = |p: &ProcessInfo| {
1344 filter.is_empty()
1345 || p.name.to_lowercase().contains(&filter)
1346 || p.command.to_lowercase().contains(&filter)
1347 || p.user.to_lowercase().contains(&filter)
1348 || p.container
1349 .as_ref()
1350 .is_some_and(|c| c.to_lowercase().contains(&filter))
1351 || p.pid.to_string() == filter
1352 };
1353 let candidates = snapshot
1354 .processes
1355 .iter()
1356 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
1357 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
1358 with_ancestors(candidates.collect(), matches)
1359 } else {
1360 candidates.filter(|p| matches(p)).collect()
1361 };
1362 let mut rows: Vec<ProcessRow<'a>> = shown
1363 .into_iter()
1364 .map(|info| {
1365 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
1366 ProcessRow {
1367 info,
1368 gpu_percent,
1369 vram_bytes,
1370 tree_prefix: String::new(),
1371 }
1372 })
1373 .collect();
1374
1375 let key = query.sort_key;
1376 rows.sort_by(|a, b| {
1377 let ordering = match key {
1378 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
1379 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
1380 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
1381 SortKey::User => a.info.user.cmp(&b.info.user),
1382 SortKey::Container => match (&a.info.container, &b.info.container) {
1383 (Some(a), Some(b)) => a.cmp(b),
1384 (Some(_), None) => std::cmp::Ordering::Less,
1385 (None, Some(_)) => std::cmp::Ordering::Greater,
1386 (None, None) => std::cmp::Ordering::Equal,
1387 },
1388 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
1389 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
1390 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
1391 SortKey::Gpu => a
1392 .gpu_percent
1393 .unwrap_or(-1.0)
1394 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
1395 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
1396 }
1397 .then(a.info.pid.cmp(&b.info.pid));
1399 if query.descending {
1400 ordering.reverse()
1401 } else {
1402 ordering
1403 }
1404 });
1405 if query.tree { into_tree(rows) } else { rows }
1406}
1407
1408fn with_ancestors(
1411 processes: Vec<&ProcessInfo>,
1412 matches: impl Fn(&ProcessInfo) -> bool,
1413) -> Vec<&ProcessInfo> {
1414 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
1415 let mut keep: HashSet<u32> = HashSet::new();
1416 for process in processes.iter().filter(|p| matches(p)) {
1417 let mut current = Some(*process);
1418 while let Some(p) = current
1420 && keep.insert(p.pid)
1421 {
1422 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1423 }
1424 }
1425 processes
1426 .into_iter()
1427 .filter(|p| keep.contains(&p.pid))
1428 .collect()
1429}
1430
1431fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1435 let index: HashMap<u32, usize> = rows
1436 .iter()
1437 .enumerate()
1438 .map(|(i, row)| (row.info.pid, i))
1439 .collect();
1440 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1441 let mut roots = Vec::new();
1442 for (i, row) in rows.iter().enumerate() {
1443 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1444 Some(&parent) if parent != i => children[parent].push(i),
1445 _ => roots.push(i),
1446 }
1447 }
1448 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1449 let mut visited = vec![false; rows.len()];
1450 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1452 for start in starts {
1453 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1455 while let Some((i, lead, last)) = stack.pop() {
1456 if std::mem::replace(&mut visited[i], true) {
1457 continue;
1458 }
1459 let (prefix, child_lead) = match last {
1460 None => (String::new(), String::new()),
1461 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
1462 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1463 };
1464 order.push((i, prefix));
1465 let kids = &children[i];
1466 for (k, &child) in kids.iter().enumerate().rev() {
1467 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1468 }
1469 }
1470 }
1471 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1472 order
1473 .into_iter()
1474 .filter_map(|(i, prefix)| {
1475 let mut row = rows[i].take()?;
1476 row.tree_prefix = prefix;
1477 Some(row)
1478 })
1479 .collect()
1480}
1481
1482pub fn disk_rate(process: &ProcessInfo) -> f64 {
1484 process.read_bytes_per_sec + process.write_bytes_per_sec
1485}
1486
1487pub fn disk_rate_text(process: &ProcessInfo) -> String {
1490 let rate = disk_rate(process);
1491 if rate > 0.0 {
1492 format::rate(rate)
1493 } else {
1494 String::new()
1495 }
1496}
1497
1498pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1501
1502pub fn lhm_help(error: &str) -> String {
1504 format!(
1505 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1506 Start it and turn on Options → Remote Web Server → Run.\n\
1507 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1508 {error}"
1509 )
1510}
1511
1512pub fn signal_name(signal: ProcessSignal) -> &'static str {
1515 match signal {
1516 ProcessSignal::Terminate => "SIGTERM",
1517 ProcessSignal::Kill => "SIGKILL",
1518 ProcessSignal::Stop => "SIGSTOP",
1519 ProcessSignal::Continue => "SIGCONT",
1520 ProcessSignal::Nice(_) => "renice",
1521 }
1522}
1523
1524pub fn action_text(signal: ProcessSignal) -> String {
1526 match signal {
1527 ProcessSignal::Nice(nice) => format!("nice {nice}"),
1528 other => signal_name(other).to_owned(),
1529 }
1530}
1531
1532pub const NICE_STEP: i32 = 5;
1534
1535pub fn nice_step(process: &ProcessInfo, later: bool) -> Option<i32> {
1538 let current = process.nice.unwrap_or(0);
1539 let next = if later {
1540 (current + NICE_STEP).min(19)
1541 } else {
1542 (current - NICE_STEP).max(-20)
1543 };
1544 (next != current).then_some(next)
1545}
1546
1547pub fn is_stopped(process: &ProcessInfo) -> bool {
1549 process.state == "Stopped" || process.state == "Tracing"
1550}
1551
1552pub fn is_virtual_interface(name: &str) -> bool {
1555 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1556}
1557
1558pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1560 snapshot
1561 .networks
1562 .iter()
1563 .filter(|n| !is_virtual_interface(&n.name))
1564 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1565 .or_else(|| snapshot.networks.first())
1566 .map(|n| n.name.clone())
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571 use super::*;
1572 use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1573
1574 #[test]
1575 fn process_details_rows() {
1576 let mut snapshot = Snapshot::default();
1577 let mut shell = process(20, "zsh", 0.0, false);
1578 shell.parent_pid = Some(1);
1579 let mut python = process(30, "python", 99.5, false);
1580 python.parent_pid = Some(20);
1581 python.command = "python train.py --epochs 10".to_owned();
1582 python.container = Some("trainer".to_owned());
1583 python.run_time_secs = 3_660;
1584 snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1585 snapshot.gpus = vec![GpuStats {
1586 index: 0,
1587 processes: vec![GpuProcess {
1588 pid: 30,
1589 memory_bytes: Some(2 << 30),
1590 sm_percent: Some(97.0),
1591 }],
1592 ..Default::default()
1593 }];
1594 let now = SystemTime::now();
1595 let rows = process_details(&snapshot, 30, now).unwrap();
1596 let get = |label: &str| {
1597 rows.iter()
1598 .find(|(l, _)| *l == label)
1599 .map(|(_, v)| v.as_str())
1600 };
1601 assert_eq!(get("Program"), Some("python (pid 30)"));
1602 assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1603 assert_eq!(get("Children"), None);
1604 assert_eq!(get("Container"), Some("trainer"));
1605 assert!(get("Running").unwrap().starts_with("1h01m (since "));
1606 assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1607 assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1608 let shell = process_details(&snapshot, 20, now).unwrap();
1609 assert!(shell.contains(&("Children", "1".to_owned())));
1610 assert!(process_details(&snapshot, 99, now).is_none());
1611 }
1612
1613 #[test]
1614 fn attention_ranks() {
1615 let alert = |severity| Alert {
1616 severity,
1617 text: String::new(),
1618 };
1619 assert_eq!(attention_rank(&[], false, false), 2);
1620 assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1621 assert_eq!(attention_rank(&[], false, true), 1);
1622 assert_eq!(
1623 attention_rank(
1624 &[alert(Severity::Warning), alert(Severity::Critical)],
1625 false,
1626 false
1627 ),
1628 0
1629 );
1630 assert_eq!(attention_rank(&[], true, false), 0);
1631 }
1632
1633 #[test]
1634 fn gpu_notes_and_pcie() {
1635 let mut gpu = GpuStats {
1636 throttle_reasons: vec!["power cap".to_owned()],
1637 ..Default::default()
1638 };
1639 assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1640 gpu.throttle_reasons.push("thermal".to_owned());
1641 gpu.ecc = Some(EccErrors {
1642 corrected: 3,
1643 uncorrected: 0,
1644 });
1645 assert_eq!(
1646 gpu_notes(&gpu),
1647 [
1648 ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1649 ("ECC 3 corrected".to_owned(), None)
1650 ]
1651 );
1652 let full = PcieLink {
1653 generation: 4,
1654 width: 16,
1655 max_generation: 4,
1656 max_width: 16,
1657 };
1658 assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1659 let idle = PcieLink {
1660 generation: 1,
1661 width: 8,
1662 ..full
1663 };
1664 assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1665 }
1666
1667 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1668 ProcessInfo {
1669 pid,
1670 name: name.to_owned(),
1671 cpu_percent: cpu,
1672 kernel_thread: kernel,
1673 ..Default::default()
1674 }
1675 }
1676
1677 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1678 ProcessQuery {
1679 sort_key,
1680 descending: sort_key.descending_by_default(),
1681 filter: "",
1682 show_kernel_threads: false,
1683 tree: false,
1684 }
1685 }
1686
1687 #[test]
1688 fn history_keeps_newest_samples() {
1689 let mut history = History::default();
1690 for i in 0..(HISTORY_LEN + 5) {
1691 history.push(i as f64);
1692 }
1693 assert_eq!(
1694 history.last(2),
1695 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1696 );
1697 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1698 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1699 }
1700
1701 #[test]
1702 fn graph_spans_spread_samples_over_the_width() {
1703 let second = Duration::from_secs(1);
1704 let mut history = History::default();
1705 for i in 0..120 {
1706 history.push(i as f64);
1707 }
1708 assert_eq!(
1710 history.graph(3, GraphSpan::Fit, second),
1711 vec![117.0, 118.0, 119.0]
1712 );
1713 let minute = history.graph(30, GraphSpan::Secs(60), second);
1715 assert_eq!(minute.len(), 30);
1716 assert_eq!(minute[0], 60.5);
1717 assert_eq!(minute[29], 118.5);
1718 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1720 assert_eq!(stretched.len(), 120);
1721 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1722 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1723 assert_eq!(stretched[119], 119.0);
1724 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1725 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1727 assert_eq!(hour.len(), 10);
1728 assert_eq!(hour[0], 5.5);
1729 assert!(
1730 History::default()
1731 .graph(10, GraphSpan::Secs(60), second)
1732 .is_empty()
1733 );
1734 }
1735
1736 #[test]
1737 fn long_spans_draw_from_the_averages() {
1738 let second = Duration::from_secs(1);
1739 let mut history = History::default();
1740 for i in 0..7200 {
1742 history.push(if i < 3600 { 0.0 } else { 100.0 });
1743 }
1744 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1746 assert_eq!(six.len(), 30);
1747 assert_eq!((six[0], six[29]), (0.0, 100.0));
1748 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1750 assert!(hour.iter().all(|v| *v == 100.0));
1751 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1753 history.push(i as f64);
1754 }
1755 assert_eq!(history.coarse.len(), COARSE_LEN);
1756 }
1757
1758 #[test]
1759 fn graph_span_choices_cycle() {
1760 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1761 .take(6)
1762 .map(GraphSpan::label)
1763 .collect();
1764 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1765 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1766 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1767 assert_eq!(
1768 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1769 GraphSpan::Secs(300)
1770 );
1771 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1772 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1774 }
1775
1776 #[test]
1777 fn rows_filter_sort_and_join_gpu_usage() {
1778 let snapshot = Snapshot {
1779 processes: vec![
1780 process(10, "bash", 5.0, false),
1781 process(2, "kworker/0:1", 50.0, true),
1782 process(30, "python", 1.0, false),
1783 ],
1784 gpus: vec![GpuStats {
1785 processes: vec![GpuProcess {
1786 pid: 30,
1787 memory_bytes: Some(1 << 30),
1788 sm_percent: Some(70.0),
1789 }],
1790 ..Default::default()
1791 }],
1792 ..Default::default()
1793 };
1794 let pids = |q: &ProcessQuery| {
1795 process_rows(&snapshot, q)
1796 .iter()
1797 .map(|r| r.info.pid)
1798 .collect::<Vec<_>>()
1799 };
1800 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1801 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1802 assert_eq!(
1803 pids(&ProcessQuery {
1804 show_kernel_threads: true,
1805 ..query(SortKey::Cpu)
1806 }),
1807 vec![2, 10, 30]
1808 );
1809 assert_eq!(
1810 pids(&ProcessQuery {
1811 filter: "PYT",
1812 ..query(SortKey::Pid)
1813 }),
1814 vec![30]
1815 );
1816 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1817 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1818 assert_eq!(rows[1].gpu_percent, Some(70.0));
1819 }
1820
1821 #[test]
1822 fn since_notes_say_how_long_and_since_when() {
1823 let now = SystemTime::now();
1824 let ago = |secs: u64| now - Duration::from_secs(secs);
1825 assert!(since_note(ago(42), now).starts_with("42s (since "));
1826 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1827 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1828 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1829 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1831 }
1832
1833 #[test]
1834 fn tree_puts_children_under_parents() {
1835 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1836 parent_pid: Some(parent),
1837 ..process(pid, name, cpu, false)
1838 };
1839 let snapshot = Snapshot {
1840 processes: vec![
1841 process(1, "init", 0.0, false),
1842 child(10, 1, "sshd", 1.0),
1843 child(11, 10, "bash", 2.0),
1844 child(12, 11, "python", 50.0),
1845 child(20, 1, "cron", 3.0),
1846 child(30, 999, "orphan", 4.0),
1848 child(40, 41, "loop-a", 0.0),
1850 child(41, 40, "loop-b", 0.0),
1851 ],
1852 ..Default::default()
1853 };
1854 let tree = |filter| {
1855 process_rows(
1856 &snapshot,
1857 &ProcessQuery {
1858 filter,
1859 tree: true,
1860 ..query(SortKey::Cpu)
1861 },
1862 )
1863 .iter()
1864 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1865 .collect::<Vec<_>>()
1866 };
1867 assert_eq!(
1869 tree(""),
1870 vec![
1871 "orphan",
1872 "init",
1873 "├ cron",
1874 "└ sshd",
1875 " └ bash",
1876 " └ python",
1877 "loop-b",
1878 "└ loop-a",
1879 ]
1880 );
1881 assert_eq!(
1883 tree("pyth"),
1884 vec!["init", "└ sshd", " └ bash", " └ python"]
1885 );
1886 }
1887
1888 #[test]
1889 fn disk_column_sums_reads_and_writes() {
1890 let reader = ProcessInfo {
1891 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1892 ..process(10, "reader", 0.0, false)
1893 };
1894 let writer = ProcessInfo {
1895 read_bytes_per_sec: 1024.0 * 1024.0,
1896 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1897 ..process(20, "writer", 0.0, false)
1898 };
1899 let idle = process(30, "idle", 0.0, false);
1900 assert_eq!(disk_rate_text(&writer), "5.0M/s");
1901 assert_eq!(disk_rate_text(&idle), "");
1902 let snapshot = Snapshot {
1903 processes: vec![idle, reader, writer],
1904 ..Default::default()
1905 };
1906 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1907 .iter()
1908 .map(|r| r.info.pid)
1909 .collect();
1910 assert_eq!(pids, vec![20, 10, 30]);
1911 }
1912
1913 #[test]
1914 fn metric_history_records_and_switches_interfaces() {
1915 let snapshot = Snapshot {
1916 networks: vec![
1917 NetworkStats {
1918 name: "enp9s0".to_owned(),
1919 rx_total_bytes: 10,
1920 rx_bytes_per_sec: 100.0,
1921 ..Default::default()
1922 },
1923 NetworkStats {
1924 name: "wlp7s0".to_owned(),
1925 tx_bytes_per_sec: 5.0,
1926 ..Default::default()
1927 },
1928 ],
1929 gpus: vec![GpuStats {
1930 utilization_percent: Some(40.0),
1931 ..Default::default()
1932 }],
1933 ..Default::default()
1934 };
1935 let mut history = MetricHistory::default();
1936 history.record(&snapshot);
1937 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1938 assert_eq!(history.net_rx.last(1), vec![100.0]);
1939 assert_eq!(history.gpus[0].last(1), vec![40.0]);
1940 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1941 assert!(history.net_rx.last(10).is_empty());
1943 history.record(&snapshot);
1944 assert_eq!(history.net_tx.last(1), vec![5.0]);
1945 }
1946
1947 #[test]
1948 fn alerts_flag_storage_problems_first() {
1949 use crate::snapshot::{
1950 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1951 ZfsPool,
1952 };
1953 let snapshot = Snapshot {
1954 filesystems: vec![FilesystemUsage {
1955 mount_points: vec!["/data".to_owned()],
1956 total_bytes: 100,
1957 available_bytes: 8,
1958 ..Default::default()
1959 }],
1960 raid: RaidStats {
1961 btrfs: vec![BtrfsFilesystem {
1962 mount_points: vec!["/".to_owned()],
1963 device_stats: vec![BtrfsDeviceStats {
1964 devid: 1,
1965 corruption_errs: 2,
1966 ..Default::default()
1967 }],
1968 ..Default::default()
1969 }],
1970 md: vec![MdArray {
1971 name: "md0".to_owned(),
1972 active: true,
1973 level: "raid1".to_owned(),
1974 expected_devices: Some(2),
1975 working_devices: Some(1),
1976 ..Default::default()
1977 }],
1978 zfs: vec![
1979 ZfsPool {
1980 name: "tank".to_owned(),
1981 state: "DEGRADED".to_owned(),
1982 },
1983 ZfsPool {
1984 name: "cold".to_owned(),
1985 state: "OFFLINE".to_owned(),
1986 },
1987 ZfsPool {
1988 name: "fast".to_owned(),
1989 state: "ONLINE".to_owned(),
1990 },
1991 ],
1992 },
1993 smart: vec![SmartDevice {
1994 device: "sda".to_owned(),
1995 passed: Some(true),
1996 ..Default::default()
1997 }],
1998 ..Default::default()
1999 };
2000 let alerts = alerts(&snapshot);
2001 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
2002 assert_eq!(
2003 texts,
2004 vec![
2005 "btrfs /: 2 I/O errors (devid 1)",
2006 "md0 (raid1) degraded",
2007 "zfs tank DEGRADED",
2008 "zfs cold OFFLINE",
2009 "/data 92% full"
2010 ]
2011 );
2012 assert_eq!(alerts[0].severity, Severity::Critical);
2013 assert_eq!(alerts[2].severity, Severity::Critical);
2014 assert_eq!(alerts[3].severity, Severity::Warning);
2015 assert!(super::alerts(&Snapshot::default()).is_empty());
2016 }
2017
2018 #[test]
2019 fn event_times_are_as_short_as_is_clear() {
2020 let now = SystemTime::now();
2021 let ms = |t: SystemTime| {
2022 t.duration_since(SystemTime::UNIX_EPOCH)
2023 .unwrap()
2024 .as_millis() as u64
2025 };
2026 assert_eq!(event_time(ms(now), now).len(), 8);
2028 let week_ago = now - Duration::from_secs(10 * 86_400);
2029 assert!(event_time(ms(week_ago), now).contains(' '));
2030 assert_ne!(event_time(ms(week_ago), now).len(), 8);
2031 }
2032
2033 #[test]
2034 fn snoozing_a_raised_alert_logs_no_clearing() {
2035 let disk = Alert {
2036 severity: Severity::Warning,
2037 text: "sdc: SMART warning".to_owned(),
2038 };
2039 let mut log = AlertLog::default();
2040 assert_eq!(log.update("nas", std::slice::from_ref(&disk), 1).len(), 1);
2041 assert!(
2043 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 2)
2044 .is_empty()
2045 );
2046 assert!(
2047 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 3)
2048 .is_empty()
2049 );
2050 let events = log.update_snoozed("nas", std::slice::from_ref(&disk), &[], 4);
2052 assert_eq!(events.len(), 1);
2053 assert_eq!(events[0].change, AlertChange::Raised);
2054 assert_eq!(
2056 log.update_snoozed("nas", &[], &[], 5)[0].change,
2057 AlertChange::Cleared
2058 );
2059 }
2060
2061 #[test]
2062 fn alert_history_survives_a_restart() {
2063 let tmp = tempfile::tempdir().unwrap();
2064 let path = tmp.path().join("state/alerts-tui.jsonl");
2065 let degraded = Alert {
2066 severity: Severity::Critical,
2067 text: "md0 (raid1) degraded".to_owned(),
2068 };
2069 let full = Alert {
2070 severity: Severity::Warning,
2071 text: "/srv 92% full".to_owned(),
2072 };
2073 let mut log = AlertLog::persistent(path.clone());
2074 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
2075 log.update("nas", std::slice::from_ref(°raded), 2_000);
2076 assert_eq!(log.events().count(), 3);
2077
2078 let mut log = AlertLog::persistent(path.clone());
2081 assert_eq!(log.events().count(), 3);
2082 assert_eq!(log.unread(), (0, None));
2083 assert!(
2084 log.update("nas", std::slice::from_ref(°raded), 3_000)
2085 .is_empty()
2086 );
2087 assert_eq!(log.events().count(), 3);
2088 let changes = log.update("nas", &[], 4_000);
2089 assert_eq!(changes.len(), 1);
2090 assert_eq!(changes[0].alert, degraded);
2091 let last = log.events().last().unwrap();
2092 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
2093 assert_eq!(AlertLog::persistent(path.clone()).events().count(), 4);
2094
2095 let exited = Alert {
2098 severity: Severity::Warning,
2099 text: "train.py (pid 7) exited after 3h".to_owned(),
2100 };
2101 let mut log = AlertLog::persistent(path.clone());
2102 log.record("nas", exited.clone(), 5_000);
2103 assert_eq!(log.unread(), (1, Some(Severity::Warning)));
2104 let mut log = AlertLog::persistent(path);
2105 assert!(log.update("nas", &[], 6_000).is_empty());
2106 let last = log.events().last().unwrap();
2107 assert_eq!((last.change, &last.alert), (AlertChange::Notice, &exited));
2108 }
2109
2110 #[test]
2111 fn cards_skip_gpus_without_readings() {
2112 let snapshot = Snapshot {
2113 gpus: vec![
2114 GpuStats {
2115 index: 0,
2116 name: "Intel UHD Graphics".to_owned(),
2117 temperature_c: Some(50.0),
2118 ..Default::default()
2119 },
2120 GpuStats {
2121 index: 1,
2122 utilization_percent: Some(3.0),
2123 ..Default::default()
2124 },
2125 ],
2126 ..Default::default()
2127 };
2128 let shown: Vec<u32> = card_gpus(&snapshot).map(|g| g.index).collect();
2129 assert_eq!(shown, [1]);
2130 }
2131
2132 #[test]
2133 fn containers_add_up_their_processes() {
2134 use crate::snapshot::GpuProcess;
2135 let process = |pid, container: Option<&str>, cpu, mem| ProcessInfo {
2136 pid,
2137 container: container.map(str::to_owned),
2138 cpu_percent: cpu,
2139 memory_bytes: mem,
2140 ..Default::default()
2141 };
2142 let snapshot = Snapshot {
2143 processes: vec![
2144 process(1, None, 50.0, 100),
2145 process(2, Some("db"), 3.0, 500),
2146 process(3, Some("web"), 10.0, 200),
2147 process(4, Some("web"), 5.0, 300),
2148 ],
2149 gpus: vec![GpuStats {
2150 processes: vec![GpuProcess {
2151 pid: 3,
2152 sm_percent: Some(40.0),
2153 memory_bytes: Some(1 << 30),
2154 }],
2155 ..Default::default()
2156 }],
2157 ..Default::default()
2158 };
2159 let usage = container_usage(&snapshot);
2160 assert_eq!(usage.len(), 2, "processes outside containers are left out");
2161 assert_eq!(usage[0].name, "web");
2162 assert_eq!(usage[0].processes, 2);
2163 assert_eq!(usage[0].cpu_percent, 15.0);
2164 assert_eq!(usage[0].memory_bytes, 500);
2165 assert_eq!(usage[0].gpu_percent, Some(40.0));
2166 assert_eq!(usage[0].vram_bytes, Some(1 << 30));
2167 assert_eq!(usage[1].gpu_percent, None);
2168 assert!(container_usage(&Snapshot::default()).is_empty());
2169 }
2170
2171 #[test]
2172 fn processes_match_by_name_or_file() {
2173 let process = ProcessInfo {
2174 name: "python3".to_owned(),
2175 command: "/usr/bin/python3 /home/me/jobs/train.py --epochs 3".to_owned(),
2176 ..Default::default()
2177 };
2178 assert!(process_matches(&process, "python3"));
2179 assert!(process_matches(&process, "train.py"));
2180 assert!(process_matches(&process, " train.py "));
2181 assert!(process_matches(&process, "/home/me/jobs/train.py"));
2182 assert!(!process_matches(&process, "train"));
2183 assert!(!process_matches(&process, "epochs"));
2184 assert!(!process_matches(&process, ""));
2185 }
2186
2187 #[test]
2188 fn exit_watch_reports_exits_it_can_tell() {
2189 let process = |pid: u32, name: &str| ProcessInfo {
2190 pid,
2191 name: name.to_owned(),
2192 run_time_secs: 3_600,
2193 ..Default::default()
2194 };
2195 let mut watch = ExitWatch::default();
2196 assert!(watch.toggle(&process(7, "train.py"), 10_000_000));
2197 assert!(watch.toggle(&process(8, "make"), 10_000_000));
2198 assert!(!watch.toggle(&process(8, "make"), 10_000_000));
2199 assert_eq!(watch.pids(), [7]);
2200
2201 let mut snapshot = Snapshot::default();
2204 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2205 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2206 snapshot.processes = vec![process(7, "train.py")];
2208 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2209
2210 snapshot.watched_pids = vec![7];
2211 snapshot.processes = vec![process(7, "train.py")];
2212 assert_eq!(watch.state(&snapshot, 7), WatchState::Running);
2213 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2214
2215 snapshot.processes.clear();
2217 assert_eq!(watch.state(&snapshot, 7), WatchState::Exited);
2218 let alerts = watch.check(&snapshot, 10_000_000 + 1_800_000);
2219 assert_eq!(alerts.len(), 1);
2220 assert_eq!(alerts[0].text, "train.py (pid 7) exited after 1h30m");
2221 assert_eq!(watch.pids(), Vec::<u32>::new(), "reported once");
2222
2223 watch.toggle(&process(9, "rsync"), 0);
2225 snapshot.processes = vec![process(9, "bash")];
2226 assert_eq!(watch.check(&snapshot, 0).len(), 1);
2227
2228 let python = process(12, "python3");
2230 assert!(watch.watch_as(&python, Some("train.py"), 0));
2231 assert!(!watch.watch_as(&python, Some("train.py"), 0), "once");
2232 snapshot.processes.clear();
2233 snapshot.watched_pids = vec![12];
2234 assert_eq!(
2235 watch.check(&snapshot, 60_000)[0].text,
2236 "train.py (pid 12) exited after 01:00"
2237 );
2238 }
2239
2240 #[test]
2241 fn failed_units_warn_unless_ignored() {
2242 let snapshot = Snapshot {
2243 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
2244 ..Default::default()
2245 };
2246 let limits = AlertsConfig {
2247 ignored_units: vec!["wait-online.service".to_owned()],
2248 ..Default::default()
2249 };
2250 let alerts = alerts_with(&snapshot, &limits);
2251 assert_eq!(alerts.len(), 1);
2252 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
2253 assert_eq!(alerts[0].severity, Severity::Warning);
2254 }
2255
2256 #[test]
2257 fn version_notes_only_for_other_versions() {
2258 let mut snapshot = Snapshot::default();
2259 assert_eq!(version_note(&snapshot), None);
2260 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
2261 assert_eq!(version_note(&snapshot), None);
2262 snapshot.host.rigtop_version = "0.1.0".to_owned();
2263 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
2264 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
2265 }
2266
2267 #[test]
2268 fn alert_log_records_alerts_appearing_and_clearing() {
2269 let alert = |severity, text: &str| Alert {
2270 severity,
2271 text: text.to_owned(),
2272 };
2273 let full = alert(Severity::Warning, "/data 91% full");
2274 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
2275 let mut log = AlertLog::default();
2276 log.update("nas", std::slice::from_ref(&full), 1_000);
2277 log.update(
2279 "nas",
2280 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
2281 2_000,
2282 );
2283 log.update("zefat", &[], 2_000);
2284 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
2285 log.mark_read();
2286 log.update("nas", std::slice::from_ref(&errors), 3_000);
2287 assert_eq!(log.unread(), (0, None));
2288 let summary: Vec<(u64, AlertChange, &str)> = log
2289 .events()
2290 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
2291 .collect();
2292 assert_eq!(
2293 summary,
2294 [
2295 (1_000, AlertChange::Raised, "/data 91% full"),
2296 (
2297 2_000,
2298 AlertChange::Raised,
2299 "btrfs /: 2 I/O errors (nvme1n1)"
2300 ),
2301 (3_000, AlertChange::Cleared, "/data 92% full"),
2302 ]
2303 );
2304 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
2305 log.update(
2306 "flappy",
2307 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
2308 i,
2309 );
2310 }
2311 assert_eq!(log.events().count(), ALERT_LOG_LEN);
2312 }
2313
2314 #[test]
2315 fn filesystems_filling_up_warn() {
2316 use crate::snapshot::FilesystemUsage;
2317 let filling = |full_in_secs| Snapshot {
2318 filesystems: vec![FilesystemUsage {
2319 mount_points: vec!["/srv".to_owned()],
2320 total_bytes: 100,
2321 available_bytes: 50,
2322 full_in_secs,
2323 ..Default::default()
2324 }],
2325 ..Default::default()
2326 };
2327 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<String> {
2328 alerts_with(snapshot, limits)
2329 .into_iter()
2330 .map(|a| a.text)
2331 .collect()
2332 };
2333 let defaults = AlertsConfig::default();
2334 assert_eq!(
2335 texts(&filling(Some(5 * 3600)), &defaults),
2336 ["/srv full in ~5h00m"]
2337 );
2338 assert!(texts(&filling(Some(30 * 3600)), &defaults).is_empty());
2339 assert!(texts(&filling(None), &defaults).is_empty());
2340 let off = AlertsConfig {
2341 filesystem_full_warning_hours: 0.0,
2342 ..Default::default()
2343 };
2344 assert!(texts(&filling(Some(60)), &off).is_empty());
2345 }
2346
2347 #[test]
2348 fn alert_thresholds_come_from_the_configuration() {
2349 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
2350 let temp = |label: &str, celsius| Temperature {
2351 label: label.to_owned(),
2352 celsius,
2353 ..Default::default()
2354 };
2355 let mut snapshot = Snapshot {
2356 filesystems: vec![FilesystemUsage {
2357 mount_points: vec!["/data".to_owned()],
2358 total_bytes: 100,
2359 available_bytes: 8,
2360 ..Default::default()
2361 }],
2362 gpus: vec![GpuStats {
2363 index: 0,
2364 temperature_c: Some(80.0),
2365 ..Default::default()
2366 }],
2367 smart: vec![SmartDevice {
2368 device: "nvme0".to_owned(),
2369 temperature_c: Some(72.0),
2370 ..Default::default()
2371 }],
2372 sensors: vec![
2374 SensorChip {
2375 key: "nvme:nvme0".to_owned(),
2376 name: "nvme".to_owned(),
2377 temperatures: vec![temp("Composite", 71.0)],
2378 ..Default::default()
2379 },
2380 SensorChip {
2381 key: "nvme:nvme1".to_owned(),
2382 name: "nvme".to_owned(),
2383 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
2384 ..Default::default()
2385 },
2386 ],
2387 ..Default::default()
2388 };
2389 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
2390 let texts_of = |snapshot: &Snapshot| -> Vec<String> {
2391 alerts_with(snapshot, &AlertsConfig::default())
2392 .into_iter()
2393 .map(|a| a.text)
2394 .collect()
2395 };
2396 let texts = |limits: &AlertsConfig| -> Vec<String> {
2397 alerts_with(&snapshot, limits)
2398 .into_iter()
2399 .map(|a| a.text)
2400 .collect()
2401 };
2402 assert_eq!(
2403 texts(&AlertsConfig::default()),
2404 [
2405 "/data 92% full",
2406 "CPU at 97°C",
2407 "nvme0 at 72°C",
2408 "nvme1 at 74°C"
2409 ]
2410 );
2411 let custom = AlertsConfig {
2412 filesystem_warning_percent: 0.0,
2413 cpu_warning_celsius: 0.0,
2414 gpu_warning_celsius: 75.0,
2415 drive_warning_celsius: 73.0,
2416 ..Default::default()
2417 };
2418 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
2419
2420 let mut gpu = snapshot.gpus[0].clone();
2422 gpu.throttle_reasons = vec!["power cap".to_owned()];
2423 let mut capped = snapshot.clone();
2424 capped.gpus = vec![gpu.clone()];
2425 assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
2426 gpu.throttle_reasons.push("hw thermal".to_owned());
2427 gpu.ecc = Some(EccErrors {
2428 corrected: 5,
2429 uncorrected: 2,
2430 });
2431 capped.gpus = vec![gpu];
2432 let alerts = alerts_with(&capped, &AlertsConfig::default());
2433 assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
2434 assert_eq!(alerts[0].severity, Severity::Critical);
2435 assert!(
2436 alerts
2437 .iter()
2438 .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
2439 );
2440 let critical = AlertsConfig {
2441 filesystem_critical_percent: 90.0,
2442 ..custom
2443 };
2444 assert_eq!(
2445 alerts_with(&snapshot, &critical)[0],
2446 Alert {
2447 severity: Severity::Critical,
2448 text: "/data 92% full".to_owned()
2449 }
2450 );
2451 }
2452
2453 #[test]
2454 fn pressure_alerts_and_readings() {
2455 use crate::snapshot::PressureLevels;
2456 let levels = |some: f32, full: f32| PressureLevels {
2457 some_avg10: some,
2458 some_avg60: some,
2459 full_avg10: full,
2460 full_avg60: full,
2461 };
2462 let mut snapshot = Snapshot {
2463 pressure: Some(Pressure {
2464 cpu: levels(40.0, 0.0),
2465 memory: levels(0.2, 0.1),
2466 io: levels(80.0, 12.0),
2467 }),
2468 ..Default::default()
2469 };
2470 let limits = AlertsConfig::default();
2471 assert!(alerts_with(&snapshot, &limits).is_empty());
2473 let names = |snapshot: &Snapshot| -> Vec<(&'static str, String, bool)> {
2474 pressure_items(snapshot.pressure.as_ref().unwrap(), &limits)
2475 .into_iter()
2476 .map(|i| (i.name, i.value, i.warn))
2477 .collect()
2478 };
2479 assert_eq!(
2481 names(&snapshot),
2482 [
2483 ("cpu", "40%".to_owned(), false),
2484 ("io", "80%".to_owned(), false)
2485 ]
2486 );
2487
2488 snapshot.pressure = Some(Pressure {
2489 cpu: levels(0.0, 0.0),
2490 memory: levels(25.0, 4.0),
2491 io: levels(90.0, 45.0),
2492 });
2493 let texts: Vec<String> = alerts_with(&snapshot, &limits)
2494 .into_iter()
2495 .map(|a| a.text)
2496 .collect();
2497 assert_eq!(texts, ["memory pressure 25%", "I/O pressure 45%"]);
2498 assert_eq!(
2499 names(&snapshot),
2500 [
2501 ("mem", "25%".to_owned(), true),
2502 ("io", "90%".to_owned(), true)
2503 ]
2504 );
2505 assert!(
2506 pressure_details(snapshot.pressure.as_ref().unwrap())
2507 .contains("io: some 90% / 90%, full 45% / 45%")
2508 );
2509 }
2510
2511 #[test]
2512 fn memory_alerts() {
2513 use crate::snapshot::{MemoryStats, OomKill};
2514 let kill = |name: Option<&str>| OomKill {
2515 at_ms: 1,
2516 likely_process: name.map(str::to_owned),
2517 };
2518 let mut snapshot = Snapshot {
2519 memory: MemoryStats {
2520 total_bytes: 100,
2521 used_bytes: 93,
2522 oom_kills: vec![kill(Some("python3"))],
2523 ..Default::default()
2524 },
2525 gpus: vec![GpuStats {
2526 index: 1,
2527 memory_used_bytes: Some(99),
2528 memory_total_bytes: Some(100),
2529 ..Default::default()
2530 }],
2531 ..Default::default()
2532 };
2533 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<(Severity, String)> {
2534 alerts_with(snapshot, limits)
2535 .into_iter()
2536 .map(|a| (a.severity, a.text))
2537 .collect()
2538 };
2539 assert_eq!(
2541 texts(&snapshot, &AlertsConfig::default()),
2542 [
2543 (
2544 Severity::Critical,
2545 "OOM killer stopped python3 (likely)".to_owned()
2546 ),
2547 (Severity::Warning, "memory 93% used".to_owned()),
2548 ]
2549 );
2550 let vram = AlertsConfig {
2551 memory_warning_percent: 0.0,
2552 gpu_memory_warning_percent: 98.0,
2553 ..Default::default()
2554 };
2555 snapshot.memory.oom_kills = vec![kill(Some("bash")), kill(None), kill(Some("python3"))];
2556 assert_eq!(
2557 texts(&snapshot, &vram),
2558 [
2559 (
2560 Severity::Critical,
2561 "OOM killer stopped 3 processes (likely python3, bash)".to_owned()
2562 ),
2563 (Severity::Warning, "GPU 1 memory 99% used".to_owned()),
2564 ]
2565 );
2566 snapshot.memory.oom_kills = vec![kill(None)];
2567 assert_eq!(texts(&snapshot, &vram)[0].1, "OOM killer stopped a process");
2568 }
2569
2570 #[test]
2571 fn interface_helpers() {
2572 assert!(is_virtual_interface("veth5a74356"));
2573 assert!(!is_virtual_interface("enp9s0"));
2574 let snapshot = Snapshot {
2575 networks: vec![
2576 NetworkStats {
2577 name: "docker0".to_owned(),
2578 rx_total_bytes: 1 << 40,
2579 ..Default::default()
2580 },
2581 NetworkStats {
2582 name: "enp9s0".to_owned(),
2583 rx_total_bytes: 1 << 30,
2584 ..Default::default()
2585 },
2586 ],
2587 ..Default::default()
2588 };
2589 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
2590 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
2591 }
2592}