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