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