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