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, 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 record(&mut self, host: &str, alert: Alert, at_ms: u64) -> AlertEvent {
489 self.push(AlertEvent {
490 at_ms,
491 host: host.to_owned(),
492 change: AlertChange::Notice,
493 alert,
494 })
495 }
496
497 fn push(&mut self, event: AlertEvent) -> AlertEvent {
500 if event.change != AlertChange::Cleared {
501 self.unread.0 += 1;
502 self.unread.1 = self.unread.1.max(Some(event.alert.severity));
503 }
504 if self.events.len() == ALERT_LOG_LEN {
505 self.events.pop_front();
506 }
507 self.save(&event);
508 self.events.push_back(event.clone());
509 event
510 }
511
512 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
514 self.events.iter()
515 }
516
517 pub fn unread(&self) -> (usize, Option<Severity>) {
519 self.unread
520 }
521
522 pub fn mark_read(&mut self) {
524 self.unread = (0, None);
525 }
526}
527
528pub fn event_time(at_ms: u64, now: SystemTime) -> String {
531 let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
532 let today: DateTime<Local> = now.into();
533 let days = (today.date_naive() - at.date_naive()).num_days();
534 if days == 0 {
535 at.format("%H:%M:%S").to_string()
536 } else if (1..6).contains(&days) {
537 at.format("%a %H:%M").to_string()
538 } else {
539 at.format("%b %d %H:%M").to_string()
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct WatchedProcess {
546 pub pid: u32,
547 pub name: String,
550 pub started_ms: u64,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub enum WatchState {
558 Running,
559 Exited,
560 Unknown,
563}
564
565#[derive(Debug, Clone, Default)]
570pub struct ExitWatch {
571 processes: Vec<WatchedProcess>,
572}
573
574impl ExitWatch {
575 pub fn contains(&self, pid: u32) -> bool {
577 self.processes.iter().any(|w| w.pid == pid)
578 }
579
580 pub fn toggle(&mut self, process: &ProcessInfo, now_ms: u64) -> bool {
582 if self.contains(process.pid) {
583 self.processes.retain(|w| w.pid != process.pid);
584 return false;
585 }
586 self.processes.push(WatchedProcess {
587 pid: process.pid,
588 name: process.name.clone(),
589 started_ms: now_ms.saturating_sub(process.run_time_secs * 1000),
590 });
591 true
592 }
593
594 pub fn pids(&self) -> Vec<u32> {
596 self.processes.iter().map(|w| w.pid).collect()
597 }
598
599 pub fn state(&self, snapshot: &Snapshot, pid: u32) -> WatchState {
601 match self.processes.iter().find(|w| w.pid == pid) {
602 Some(watched) => watch_state(snapshot, watched),
603 None => WatchState::Unknown,
604 }
605 }
606
607 pub fn check(&mut self, snapshot: &Snapshot, now_ms: u64) -> Vec<Alert> {
610 let mut exited = Vec::new();
611 self.processes.retain(|watched| {
612 if watch_state(snapshot, watched) != WatchState::Exited {
613 return true;
614 }
615 let ran = now_ms.saturating_sub(watched.started_ms) / 1000;
616 exited.push(Alert {
617 severity: Severity::Warning,
618 text: format!(
619 "{} (pid {}) exited after {}",
620 watched.name,
621 watched.pid,
622 format::duration(ran)
623 ),
624 });
625 false
626 });
627 exited
628 }
629}
630
631fn watch_state(snapshot: &Snapshot, watched: &WatchedProcess) -> WatchState {
634 let reported = snapshot.watched_pids.contains(&watched.pid);
635 match snapshot.processes.iter().find(|p| p.pid == watched.pid) {
636 Some(process) if process.name != watched.name => WatchState::Exited,
638 Some(_) if reported => WatchState::Running,
639 None if reported => WatchState::Exited,
640 _ => WatchState::Unknown,
641 }
642}
643
644pub fn process_details(
648 snapshot: &Snapshot,
649 pid: u32,
650 now: SystemTime,
651) -> Option<Vec<(&'static str, String)>> {
652 let by_pid: HashMap<u32, &ProcessInfo> =
653 snapshot.processes.iter().map(|p| (p.pid, p)).collect();
654 let process = by_pid.get(&pid)?;
655 let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
656
657 let mut chain = Vec::new();
660 let mut parent = process.parent_pid;
661 while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
662 match by_pid.get(&ppid) {
663 Some(p) => {
664 chain.push(format!("{} ({})", p.name, p.pid));
665 parent = p.parent_pid.filter(|&next| next != p.pid);
666 }
667 None => {
668 chain.push(ppid.to_string());
669 break;
670 }
671 }
672 }
673 if !chain.is_empty() {
674 chain.reverse();
675 rows.push(("Parents", chain.join(" › ")));
676 }
677 let children = snapshot
678 .processes
679 .iter()
680 .filter(|p| p.parent_pid == Some(pid))
681 .count();
682 if children > 0 {
683 rows.push(("Children", children.to_string()));
684 }
685 rows.push(("User", process.user.clone()));
686 if let Some(container) = &process.container {
687 rows.push(("Container", container.clone()));
688 }
689 if !process.state.is_empty() {
690 rows.push(("State", process.state.clone()));
691 }
692 let started = now - Duration::from_secs(process.run_time_secs);
693 rows.push(("Running", since_note(started, now)));
694 rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
695 rows.push(("Memory", format::bytes(process.memory_bytes)));
696 if let Some(threads) = process.threads {
697 rows.push(("Threads", threads.to_string()));
698 }
699 rows.push((
700 "Disk",
701 format!(
702 "read {}, write {}",
703 format::rate(process.read_bytes_per_sec),
704 format::rate(process.write_bytes_per_sec)
705 ),
706 ));
707 for gpu in &snapshot.gpus {
708 if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
709 let mut text = format!("GPU {}", gpu.index);
710 if let Some(sm) = usage.sm_percent {
711 text.push_str(&format!(" {sm:.0}%"));
712 }
713 if let Some(memory) = usage.memory_bytes {
714 text.push_str(&format!(", {} VRAM", format::bytes(memory)));
715 }
716 rows.push(("GPU", text));
717 }
718 }
719 let command = if process.command.is_empty() {
720 format!("[{}]", process.name)
721 } else {
722 process.command.clone()
723 };
724 rows.push(("Command", command));
725 Some(rows)
726}
727
728pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
732 let worst = alerts.iter().map(|a| a.severity).max();
733 if unreachable || worst == Some(Severity::Critical) {
734 0
735 } else if stale || worst == Some(Severity::Warning) {
736 1
737 } else {
738 2
739 }
740}
741
742pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
746 let mut notes = Vec::new();
747 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
748 if !slowdown.is_empty() {
749 notes.push((
750 format!("slowed: {}", slowdown.join(", ")),
751 Some(Severity::Warning),
752 ));
753 } else if !gpu.throttle_reasons.is_empty() {
754 notes.push(("power cap".to_owned(), None));
755 }
756 if let Some(ecc) = gpu.ecc {
757 if ecc.uncorrected > 0 {
758 notes.push((
759 format!("ECC {} uncorrected", ecc.uncorrected),
760 Some(Severity::Critical),
761 ));
762 } else if ecc.corrected > 0 {
763 notes.push((format!("ECC {} corrected", ecc.corrected), None));
764 }
765 }
766 notes
767}
768
769pub fn pcie_text(link: &PcieLink) -> String {
771 let now = format!("PCIe gen {} x{}", link.generation, link.width);
772 if (link.generation, link.width) == (link.max_generation, link.max_width) {
773 now
774 } else {
775 format!(
776 "{now} (up to gen {} x{}; the link slows down when idle)",
777 link.max_generation, link.max_width
778 )
779 }
780}
781
782pub fn since_note(since: SystemTime, now: SystemTime) -> String {
785 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
786 let span = match secs {
787 0..60 => format!("{secs}s"),
788 60..3_600 => format!("{}m", secs / 60),
789 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
790 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
791 };
792 let at: DateTime<Local> = since.into();
793 let today: DateTime<Local> = now.into();
794 let clock = if at.date_naive() == today.date_naive() {
795 at.format("%H:%M")
796 } else if secs < 6 * 86_400 {
797 at.format("%a %H:%M")
798 } else {
799 at.format("%b %d %H:%M")
800 };
801 format!("{span} (since {clock})")
802}
803
804pub fn version_note(snapshot: &Snapshot) -> Option<String> {
807 let version = &snapshot.host.rigtop_version;
808 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
809 .then(|| format!("agent {version}"))
810}
811
812pub fn version_hint(snapshot: &Snapshot) -> String {
814 format!(
815 "this host runs rigtop {}, this machine {}: install the same version on both \
816 for every reading",
817 snapshot.host.rigtop_version,
818 env!("CARGO_PKG_VERSION")
819 )
820}
821
822pub const REBOOT_NOTE: &str = "reboot needed";
825
826pub fn reboot_hint(reason: &str) -> String {
828 let mut hint = "a reboot is needed to finish an update".to_owned();
829 if !reason.is_empty() {
830 hint.push_str(&format!(" ({reason})"));
831 }
832 hint
833}
834
835pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
837 alerts_with(snapshot, &AlertsConfig::default())
838}
839
840pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
845 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
847 let mut alerts = Vec::new();
848 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
849 for fs in &snapshot.raid.btrfs {
850 let name = fs
851 .mount_points
852 .first()
853 .map_or(fs.fsid.as_str(), String::as_str);
854 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
855 if missing > 0 {
856 add(
857 Severity::Critical,
858 format!("btrfs {name}: {missing} device(s) missing"),
859 );
860 }
861 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
862 if errors > 0 {
863 let devices: Vec<String> = fs
865 .device_stats
866 .iter()
867 .filter(|d| d.total_errors() > 0)
868 .map(|d| d.label())
869 .collect();
870 add(
871 Severity::Critical,
872 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
873 );
874 }
875 if let Some(scrub) = &fs.scrub {
876 if scrub.uncorrectable > 0 {
877 add(
878 Severity::Critical,
879 format!(
880 "btrfs {name}: scrub found {} uncorrectable errors",
881 scrub.uncorrectable
882 ),
883 );
884 } else if scrub.errors > 0 {
885 add(
886 Severity::Warning,
887 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
888 );
889 }
890 }
891 }
892 for md in &snapshot.raid.md {
893 if md.active && md.is_degraded() {
894 add(
895 Severity::Critical,
896 format!("{} ({}) degraded", md.name, md.level),
897 );
898 } else if !md.active {
899 add(Severity::Warning, format!("{} inactive", md.name));
900 }
901 if let Some(sync) = &md.sync {
902 add(
903 Severity::Warning,
904 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
905 );
906 }
907 }
908 for pool in &snapshot.raid.zfs {
909 if pool.is_failing() {
910 add(
911 Severity::Critical,
912 format!("zfs {} {}", pool.name, pool.state),
913 );
914 } else if !pool.is_online() {
915 add(
916 Severity::Warning,
917 format!("zfs {} {}", pool.name, pool.state),
918 );
919 }
920 }
921 for unit in &snapshot.failed_units {
922 if !limits.ignored_units.contains(unit) {
923 add(Severity::Warning, format!("systemd: {unit} failed"));
924 }
925 }
926 for smart in &snapshot.smart {
927 if let Some(error) = &smart.error {
928 add(
929 Severity::Warning,
930 format!("{}: SMART unreadable ({error})", smart.device),
931 );
932 } else if smart.passed == Some(false) {
933 add(
934 Severity::Critical,
935 format!("{}: SMART failed", smart.device),
936 );
937 } else if smart.needs_attention() {
938 add(
939 Severity::Critical,
940 format!("{}: SMART warning", smart.device),
941 );
942 }
943 }
944 for fs in &snapshot.filesystems {
945 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
946 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
947 let name = fs
948 .mount_points
949 .first()
950 .map_or(fs.device.as_str(), String::as_str);
951 if reached(percent, limits.filesystem_critical_percent) {
952 add(Severity::Critical, format!("{name} {percent:.0}% full"));
953 } else if reached(percent, limits.filesystem_warning_percent) {
954 add(Severity::Warning, format!("{name} {percent:.0}% full"));
955 }
956 let soon = limits.filesystem_full_warning_hours * 3600.0;
957 if let Some(secs) = fs.full_in_secs
958 && soon > 0.0
959 && secs as f64 <= soon
960 {
961 add(
962 Severity::Warning,
963 format!("{name} full in ~{}", format::duration(secs)),
964 );
965 }
966 }
967 if let Some(text) = oom_alert(&snapshot.memory.oom_kills) {
968 add(Severity::Critical, text);
969 }
970 let memory = &snapshot.memory;
971 let percent = memory.used_bytes as f64 / memory.total_bytes.max(1) as f64 * 100.0;
972 if memory.total_bytes > 0 && reached(percent, limits.memory_warning_percent) {
973 add(Severity::Warning, format!("memory {percent:.0}% used"));
974 }
975 let hottest_cpu = snapshot
976 .cpu
977 .temperatures
978 .iter()
979 .map(|t| t.celsius)
980 .fold(f32::NAN, f32::max);
981 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
982 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
983 }
984 for gpu in &snapshot.gpus {
985 if let Some(t) = gpu
986 .temperature_c
987 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
988 {
989 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
990 }
991 if let (Some(used), Some(total)) = (gpu.memory_used_bytes, gpu.memory_total_bytes)
992 && total > 0
993 {
994 let percent = used as f64 / total as f64 * 100.0;
995 if reached(percent, limits.gpu_memory_warning_percent) {
996 add(
997 Severity::Warning,
998 format!("GPU {} memory {percent:.0}% used", gpu.index),
999 );
1000 }
1001 }
1002 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
1003 if !slowdown.is_empty() {
1004 add(
1005 Severity::Warning,
1006 format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
1007 );
1008 }
1009 if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
1010 add(
1011 Severity::Critical,
1012 format!(
1013 "GPU {}: {} uncorrected ECC errors",
1014 gpu.index, ecc.uncorrected
1015 ),
1016 );
1017 }
1018 }
1019 for (device, celsius) in drive_temperatures(snapshot) {
1020 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
1021 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
1022 }
1023 }
1024 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
1026 alerts
1027}
1028
1029fn oom_alert(kills: &[OomKill]) -> Option<String> {
1033 let mut names: Vec<&str> = Vec::new();
1034 for name in kills
1035 .iter()
1036 .rev()
1037 .filter_map(|k| k.likely_process.as_deref())
1038 {
1039 if !names.contains(&name) {
1040 names.push(name);
1041 }
1042 }
1043 let likely = if names.is_empty() {
1044 String::new()
1045 } else {
1046 format!(" (likely {})", names.join(", "))
1047 };
1048 match kills.len() {
1049 0 => None,
1050 1 => Some(match names.first() {
1051 Some(name) => format!("OOM killer stopped {name} (likely)"),
1052 None => "OOM killer stopped a process".to_owned(),
1053 }),
1054 n => Some(format!("OOM killer stopped {n} processes{likely}")),
1055 }
1056}
1057
1058fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
1061 let mut drives: Vec<(String, f32)> = snapshot
1062 .smart
1063 .iter()
1064 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
1065 .collect();
1066 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
1067 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
1069 if drives.iter().any(|(d, _)| d == device) {
1070 continue;
1071 }
1072 let composite = chip
1073 .temperatures
1074 .iter()
1075 .find(|t| t.label == "Composite")
1076 .or(chip.temperatures.first());
1077 if let Some(t) = composite {
1078 drives.push((device.to_owned(), t.celsius));
1079 }
1080 }
1081 drives
1082}
1083
1084#[derive(Debug, Clone, Copy)]
1086pub struct ProcessQuery<'q> {
1087 pub sort_key: SortKey,
1089 pub descending: bool,
1091 pub filter: &'q str,
1093 pub show_kernel_threads: bool,
1095 pub tree: bool,
1098}
1099
1100pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
1102 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
1104 for gpu in &snapshot.gpus {
1105 for p in &gpu.processes {
1106 let entry = gpu_usage.entry(p.pid).or_default();
1107 if let Some(sm) = p.sm_percent {
1108 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
1109 }
1110 if let Some(mem) = p.memory_bytes {
1111 entry.1 = Some(entry.1.unwrap_or(0) + mem);
1112 }
1113 }
1114 }
1115
1116 let filter = query.filter.to_lowercase();
1117 let matches = |p: &ProcessInfo| {
1118 filter.is_empty()
1119 || p.name.to_lowercase().contains(&filter)
1120 || p.command.to_lowercase().contains(&filter)
1121 || p.user.to_lowercase().contains(&filter)
1122 || p.container
1123 .as_ref()
1124 .is_some_and(|c| c.to_lowercase().contains(&filter))
1125 || p.pid.to_string() == filter
1126 };
1127 let candidates = snapshot
1128 .processes
1129 .iter()
1130 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
1131 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
1132 with_ancestors(candidates.collect(), matches)
1133 } else {
1134 candidates.filter(|p| matches(p)).collect()
1135 };
1136 let mut rows: Vec<ProcessRow<'a>> = shown
1137 .into_iter()
1138 .map(|info| {
1139 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
1140 ProcessRow {
1141 info,
1142 gpu_percent,
1143 vram_bytes,
1144 tree_prefix: String::new(),
1145 }
1146 })
1147 .collect();
1148
1149 let key = query.sort_key;
1150 rows.sort_by(|a, b| {
1151 let ordering = match key {
1152 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
1153 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
1154 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
1155 SortKey::User => a.info.user.cmp(&b.info.user),
1156 SortKey::Container => match (&a.info.container, &b.info.container) {
1157 (Some(a), Some(b)) => a.cmp(b),
1158 (Some(_), None) => std::cmp::Ordering::Less,
1159 (None, Some(_)) => std::cmp::Ordering::Greater,
1160 (None, None) => std::cmp::Ordering::Equal,
1161 },
1162 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
1163 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
1164 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
1165 SortKey::Gpu => a
1166 .gpu_percent
1167 .unwrap_or(-1.0)
1168 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
1169 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
1170 }
1171 .then(a.info.pid.cmp(&b.info.pid));
1173 if query.descending {
1174 ordering.reverse()
1175 } else {
1176 ordering
1177 }
1178 });
1179 if query.tree { into_tree(rows) } else { rows }
1180}
1181
1182fn with_ancestors(
1185 processes: Vec<&ProcessInfo>,
1186 matches: impl Fn(&ProcessInfo) -> bool,
1187) -> Vec<&ProcessInfo> {
1188 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
1189 let mut keep: HashSet<u32> = HashSet::new();
1190 for process in processes.iter().filter(|p| matches(p)) {
1191 let mut current = Some(*process);
1192 while let Some(p) = current
1194 && keep.insert(p.pid)
1195 {
1196 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1197 }
1198 }
1199 processes
1200 .into_iter()
1201 .filter(|p| keep.contains(&p.pid))
1202 .collect()
1203}
1204
1205fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1209 let index: HashMap<u32, usize> = rows
1210 .iter()
1211 .enumerate()
1212 .map(|(i, row)| (row.info.pid, i))
1213 .collect();
1214 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1215 let mut roots = Vec::new();
1216 for (i, row) in rows.iter().enumerate() {
1217 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1218 Some(&parent) if parent != i => children[parent].push(i),
1219 _ => roots.push(i),
1220 }
1221 }
1222 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1223 let mut visited = vec![false; rows.len()];
1224 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1226 for start in starts {
1227 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1229 while let Some((i, lead, last)) = stack.pop() {
1230 if std::mem::replace(&mut visited[i], true) {
1231 continue;
1232 }
1233 let (prefix, child_lead) = match last {
1234 None => (String::new(), String::new()),
1235 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
1236 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1237 };
1238 order.push((i, prefix));
1239 let kids = &children[i];
1240 for (k, &child) in kids.iter().enumerate().rev() {
1241 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1242 }
1243 }
1244 }
1245 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1246 order
1247 .into_iter()
1248 .filter_map(|(i, prefix)| {
1249 let mut row = rows[i].take()?;
1250 row.tree_prefix = prefix;
1251 Some(row)
1252 })
1253 .collect()
1254}
1255
1256pub fn disk_rate(process: &ProcessInfo) -> f64 {
1258 process.read_bytes_per_sec + process.write_bytes_per_sec
1259}
1260
1261pub fn disk_rate_text(process: &ProcessInfo) -> String {
1264 let rate = disk_rate(process);
1265 if rate > 0.0 {
1266 format::rate(rate)
1267 } else {
1268 String::new()
1269 }
1270}
1271
1272pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1275
1276pub fn lhm_help(error: &str) -> String {
1278 format!(
1279 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1280 Start it and turn on Options → Remote Web Server → Run.\n\
1281 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1282 {error}"
1283 )
1284}
1285
1286pub fn signal_name(signal: ProcessSignal) -> &'static str {
1288 match signal {
1289 ProcessSignal::Terminate => "SIGTERM",
1290 ProcessSignal::Kill => "SIGKILL",
1291 }
1292}
1293
1294pub fn is_virtual_interface(name: &str) -> bool {
1297 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1298}
1299
1300pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1302 snapshot
1303 .networks
1304 .iter()
1305 .filter(|n| !is_virtual_interface(&n.name))
1306 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1307 .or_else(|| snapshot.networks.first())
1308 .map(|n| n.name.clone())
1309}
1310
1311#[cfg(test)]
1312mod tests {
1313 use super::*;
1314 use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1315
1316 #[test]
1317 fn process_details_rows() {
1318 let mut snapshot = Snapshot::default();
1319 let mut shell = process(20, "zsh", 0.0, false);
1320 shell.parent_pid = Some(1);
1321 let mut python = process(30, "python", 99.5, false);
1322 python.parent_pid = Some(20);
1323 python.command = "python train.py --epochs 10".to_owned();
1324 python.container = Some("trainer".to_owned());
1325 python.run_time_secs = 3_660;
1326 snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1327 snapshot.gpus = vec![GpuStats {
1328 index: 0,
1329 processes: vec![GpuProcess {
1330 pid: 30,
1331 memory_bytes: Some(2 << 30),
1332 sm_percent: Some(97.0),
1333 }],
1334 ..Default::default()
1335 }];
1336 let now = SystemTime::now();
1337 let rows = process_details(&snapshot, 30, now).unwrap();
1338 let get = |label: &str| {
1339 rows.iter()
1340 .find(|(l, _)| *l == label)
1341 .map(|(_, v)| v.as_str())
1342 };
1343 assert_eq!(get("Program"), Some("python (pid 30)"));
1344 assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1345 assert_eq!(get("Children"), None);
1346 assert_eq!(get("Container"), Some("trainer"));
1347 assert!(get("Running").unwrap().starts_with("1h01m (since "));
1348 assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1349 assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1350 let shell = process_details(&snapshot, 20, now).unwrap();
1351 assert!(shell.contains(&("Children", "1".to_owned())));
1352 assert!(process_details(&snapshot, 99, now).is_none());
1353 }
1354
1355 #[test]
1356 fn attention_ranks() {
1357 let alert = |severity| Alert {
1358 severity,
1359 text: String::new(),
1360 };
1361 assert_eq!(attention_rank(&[], false, false), 2);
1362 assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1363 assert_eq!(attention_rank(&[], false, true), 1);
1364 assert_eq!(
1365 attention_rank(
1366 &[alert(Severity::Warning), alert(Severity::Critical)],
1367 false,
1368 false
1369 ),
1370 0
1371 );
1372 assert_eq!(attention_rank(&[], true, false), 0);
1373 }
1374
1375 #[test]
1376 fn gpu_notes_and_pcie() {
1377 let mut gpu = GpuStats {
1378 throttle_reasons: vec!["power cap".to_owned()],
1379 ..Default::default()
1380 };
1381 assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1382 gpu.throttle_reasons.push("thermal".to_owned());
1383 gpu.ecc = Some(EccErrors {
1384 corrected: 3,
1385 uncorrected: 0,
1386 });
1387 assert_eq!(
1388 gpu_notes(&gpu),
1389 [
1390 ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1391 ("ECC 3 corrected".to_owned(), None)
1392 ]
1393 );
1394 let full = PcieLink {
1395 generation: 4,
1396 width: 16,
1397 max_generation: 4,
1398 max_width: 16,
1399 };
1400 assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1401 let idle = PcieLink {
1402 generation: 1,
1403 width: 8,
1404 ..full
1405 };
1406 assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1407 }
1408
1409 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1410 ProcessInfo {
1411 pid,
1412 name: name.to_owned(),
1413 cpu_percent: cpu,
1414 kernel_thread: kernel,
1415 ..Default::default()
1416 }
1417 }
1418
1419 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1420 ProcessQuery {
1421 sort_key,
1422 descending: sort_key.descending_by_default(),
1423 filter: "",
1424 show_kernel_threads: false,
1425 tree: false,
1426 }
1427 }
1428
1429 #[test]
1430 fn history_keeps_newest_samples() {
1431 let mut history = History::default();
1432 for i in 0..(HISTORY_LEN + 5) {
1433 history.push(i as f64);
1434 }
1435 assert_eq!(
1436 history.last(2),
1437 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1438 );
1439 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1440 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1441 }
1442
1443 #[test]
1444 fn graph_spans_spread_samples_over_the_width() {
1445 let second = Duration::from_secs(1);
1446 let mut history = History::default();
1447 for i in 0..120 {
1448 history.push(i as f64);
1449 }
1450 assert_eq!(
1452 history.graph(3, GraphSpan::Fit, second),
1453 vec![117.0, 118.0, 119.0]
1454 );
1455 let minute = history.graph(30, GraphSpan::Secs(60), second);
1457 assert_eq!(minute.len(), 30);
1458 assert_eq!(minute[0], 60.5);
1459 assert_eq!(minute[29], 118.5);
1460 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1462 assert_eq!(stretched.len(), 120);
1463 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1464 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1465 assert_eq!(stretched[119], 119.0);
1466 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1467 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1469 assert_eq!(hour.len(), 10);
1470 assert_eq!(hour[0], 5.5);
1471 assert!(
1472 History::default()
1473 .graph(10, GraphSpan::Secs(60), second)
1474 .is_empty()
1475 );
1476 }
1477
1478 #[test]
1479 fn long_spans_draw_from_the_averages() {
1480 let second = Duration::from_secs(1);
1481 let mut history = History::default();
1482 for i in 0..7200 {
1484 history.push(if i < 3600 { 0.0 } else { 100.0 });
1485 }
1486 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1488 assert_eq!(six.len(), 30);
1489 assert_eq!((six[0], six[29]), (0.0, 100.0));
1490 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1492 assert!(hour.iter().all(|v| *v == 100.0));
1493 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1495 history.push(i as f64);
1496 }
1497 assert_eq!(history.coarse.len(), COARSE_LEN);
1498 }
1499
1500 #[test]
1501 fn graph_span_choices_cycle() {
1502 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1503 .take(6)
1504 .map(GraphSpan::label)
1505 .collect();
1506 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1507 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1508 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1509 assert_eq!(
1510 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1511 GraphSpan::Secs(300)
1512 );
1513 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1514 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1516 }
1517
1518 #[test]
1519 fn rows_filter_sort_and_join_gpu_usage() {
1520 let snapshot = Snapshot {
1521 processes: vec![
1522 process(10, "bash", 5.0, false),
1523 process(2, "kworker/0:1", 50.0, true),
1524 process(30, "python", 1.0, false),
1525 ],
1526 gpus: vec![GpuStats {
1527 processes: vec![GpuProcess {
1528 pid: 30,
1529 memory_bytes: Some(1 << 30),
1530 sm_percent: Some(70.0),
1531 }],
1532 ..Default::default()
1533 }],
1534 ..Default::default()
1535 };
1536 let pids = |q: &ProcessQuery| {
1537 process_rows(&snapshot, q)
1538 .iter()
1539 .map(|r| r.info.pid)
1540 .collect::<Vec<_>>()
1541 };
1542 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1543 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1544 assert_eq!(
1545 pids(&ProcessQuery {
1546 show_kernel_threads: true,
1547 ..query(SortKey::Cpu)
1548 }),
1549 vec![2, 10, 30]
1550 );
1551 assert_eq!(
1552 pids(&ProcessQuery {
1553 filter: "PYT",
1554 ..query(SortKey::Pid)
1555 }),
1556 vec![30]
1557 );
1558 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1559 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1560 assert_eq!(rows[1].gpu_percent, Some(70.0));
1561 }
1562
1563 #[test]
1564 fn since_notes_say_how_long_and_since_when() {
1565 let now = SystemTime::now();
1566 let ago = |secs: u64| now - Duration::from_secs(secs);
1567 assert!(since_note(ago(42), now).starts_with("42s (since "));
1568 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1569 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1570 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1571 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1573 }
1574
1575 #[test]
1576 fn tree_puts_children_under_parents() {
1577 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1578 parent_pid: Some(parent),
1579 ..process(pid, name, cpu, false)
1580 };
1581 let snapshot = Snapshot {
1582 processes: vec![
1583 process(1, "init", 0.0, false),
1584 child(10, 1, "sshd", 1.0),
1585 child(11, 10, "bash", 2.0),
1586 child(12, 11, "python", 50.0),
1587 child(20, 1, "cron", 3.0),
1588 child(30, 999, "orphan", 4.0),
1590 child(40, 41, "loop-a", 0.0),
1592 child(41, 40, "loop-b", 0.0),
1593 ],
1594 ..Default::default()
1595 };
1596 let tree = |filter| {
1597 process_rows(
1598 &snapshot,
1599 &ProcessQuery {
1600 filter,
1601 tree: true,
1602 ..query(SortKey::Cpu)
1603 },
1604 )
1605 .iter()
1606 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1607 .collect::<Vec<_>>()
1608 };
1609 assert_eq!(
1611 tree(""),
1612 vec![
1613 "orphan",
1614 "init",
1615 "├ cron",
1616 "└ sshd",
1617 " └ bash",
1618 " └ python",
1619 "loop-b",
1620 "└ loop-a",
1621 ]
1622 );
1623 assert_eq!(
1625 tree("pyth"),
1626 vec!["init", "└ sshd", " └ bash", " └ python"]
1627 );
1628 }
1629
1630 #[test]
1631 fn disk_column_sums_reads_and_writes() {
1632 let reader = ProcessInfo {
1633 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1634 ..process(10, "reader", 0.0, false)
1635 };
1636 let writer = ProcessInfo {
1637 read_bytes_per_sec: 1024.0 * 1024.0,
1638 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1639 ..process(20, "writer", 0.0, false)
1640 };
1641 let idle = process(30, "idle", 0.0, false);
1642 assert_eq!(disk_rate_text(&writer), "5.0M/s");
1643 assert_eq!(disk_rate_text(&idle), "");
1644 let snapshot = Snapshot {
1645 processes: vec![idle, reader, writer],
1646 ..Default::default()
1647 };
1648 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1649 .iter()
1650 .map(|r| r.info.pid)
1651 .collect();
1652 assert_eq!(pids, vec![20, 10, 30]);
1653 }
1654
1655 #[test]
1656 fn metric_history_records_and_switches_interfaces() {
1657 let snapshot = Snapshot {
1658 networks: vec![
1659 NetworkStats {
1660 name: "enp9s0".to_owned(),
1661 rx_total_bytes: 10,
1662 rx_bytes_per_sec: 100.0,
1663 ..Default::default()
1664 },
1665 NetworkStats {
1666 name: "wlp7s0".to_owned(),
1667 tx_bytes_per_sec: 5.0,
1668 ..Default::default()
1669 },
1670 ],
1671 gpus: vec![GpuStats {
1672 utilization_percent: Some(40.0),
1673 ..Default::default()
1674 }],
1675 ..Default::default()
1676 };
1677 let mut history = MetricHistory::default();
1678 history.record(&snapshot);
1679 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1680 assert_eq!(history.net_rx.last(1), vec![100.0]);
1681 assert_eq!(history.gpus[0].last(1), vec![40.0]);
1682 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1683 assert!(history.net_rx.last(10).is_empty());
1685 history.record(&snapshot);
1686 assert_eq!(history.net_tx.last(1), vec![5.0]);
1687 }
1688
1689 #[test]
1690 fn alerts_flag_storage_problems_first() {
1691 use crate::snapshot::{
1692 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1693 ZfsPool,
1694 };
1695 let snapshot = Snapshot {
1696 filesystems: vec![FilesystemUsage {
1697 mount_points: vec!["/data".to_owned()],
1698 total_bytes: 100,
1699 available_bytes: 8,
1700 ..Default::default()
1701 }],
1702 raid: RaidStats {
1703 btrfs: vec![BtrfsFilesystem {
1704 mount_points: vec!["/".to_owned()],
1705 device_stats: vec![BtrfsDeviceStats {
1706 devid: 1,
1707 corruption_errs: 2,
1708 ..Default::default()
1709 }],
1710 ..Default::default()
1711 }],
1712 md: vec![MdArray {
1713 name: "md0".to_owned(),
1714 active: true,
1715 level: "raid1".to_owned(),
1716 expected_devices: Some(2),
1717 working_devices: Some(1),
1718 ..Default::default()
1719 }],
1720 zfs: vec![
1721 ZfsPool {
1722 name: "tank".to_owned(),
1723 state: "DEGRADED".to_owned(),
1724 },
1725 ZfsPool {
1726 name: "cold".to_owned(),
1727 state: "OFFLINE".to_owned(),
1728 },
1729 ZfsPool {
1730 name: "fast".to_owned(),
1731 state: "ONLINE".to_owned(),
1732 },
1733 ],
1734 },
1735 smart: vec![SmartDevice {
1736 device: "sda".to_owned(),
1737 passed: Some(true),
1738 ..Default::default()
1739 }],
1740 ..Default::default()
1741 };
1742 let alerts = alerts(&snapshot);
1743 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1744 assert_eq!(
1745 texts,
1746 vec![
1747 "btrfs /: 2 I/O errors (devid 1)",
1748 "md0 (raid1) degraded",
1749 "zfs tank DEGRADED",
1750 "zfs cold OFFLINE",
1751 "/data 92% full"
1752 ]
1753 );
1754 assert_eq!(alerts[0].severity, Severity::Critical);
1755 assert_eq!(alerts[2].severity, Severity::Critical);
1756 assert_eq!(alerts[3].severity, Severity::Warning);
1757 assert!(super::alerts(&Snapshot::default()).is_empty());
1758 }
1759
1760 #[test]
1761 fn event_times_are_as_short_as_is_clear() {
1762 let now = SystemTime::now();
1763 let ms = |t: SystemTime| {
1764 t.duration_since(SystemTime::UNIX_EPOCH)
1765 .unwrap()
1766 .as_millis() as u64
1767 };
1768 assert_eq!(event_time(ms(now), now).len(), 8);
1770 let week_ago = now - Duration::from_secs(10 * 86_400);
1771 assert!(event_time(ms(week_ago), now).contains(' '));
1772 assert_ne!(event_time(ms(week_ago), now).len(), 8);
1773 }
1774
1775 #[test]
1776 fn alert_history_survives_a_restart() {
1777 let tmp = tempfile::tempdir().unwrap();
1778 let path = tmp.path().join("state/alerts-tui.jsonl");
1779 let degraded = Alert {
1780 severity: Severity::Critical,
1781 text: "md0 (raid1) degraded".to_owned(),
1782 };
1783 let full = Alert {
1784 severity: Severity::Warning,
1785 text: "/srv 92% full".to_owned(),
1786 };
1787 let mut log = AlertLog::persistent(path.clone());
1788 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
1789 log.update("nas", std::slice::from_ref(°raded), 2_000);
1790 assert_eq!(log.events().count(), 3);
1791
1792 let mut log = AlertLog::persistent(path.clone());
1795 assert_eq!(log.events().count(), 3);
1796 assert_eq!(log.unread(), (0, None));
1797 assert!(
1798 log.update("nas", std::slice::from_ref(°raded), 3_000)
1799 .is_empty()
1800 );
1801 assert_eq!(log.events().count(), 3);
1802 let changes = log.update("nas", &[], 4_000);
1803 assert_eq!(changes.len(), 1);
1804 assert_eq!(changes[0].alert, degraded);
1805 let last = log.events().last().unwrap();
1806 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
1807 assert_eq!(AlertLog::persistent(path.clone()).events().count(), 4);
1808
1809 let exited = Alert {
1812 severity: Severity::Warning,
1813 text: "train.py (pid 7) exited after 3h".to_owned(),
1814 };
1815 let mut log = AlertLog::persistent(path.clone());
1816 log.record("nas", exited.clone(), 5_000);
1817 assert_eq!(log.unread(), (1, Some(Severity::Warning)));
1818 let mut log = AlertLog::persistent(path);
1819 assert!(log.update("nas", &[], 6_000).is_empty());
1820 let last = log.events().last().unwrap();
1821 assert_eq!((last.change, &last.alert), (AlertChange::Notice, &exited));
1822 }
1823
1824 #[test]
1825 fn exit_watch_reports_exits_it_can_tell() {
1826 let process = |pid: u32, name: &str| ProcessInfo {
1827 pid,
1828 name: name.to_owned(),
1829 run_time_secs: 3_600,
1830 ..Default::default()
1831 };
1832 let mut watch = ExitWatch::default();
1833 assert!(watch.toggle(&process(7, "train.py"), 10_000_000));
1834 assert!(watch.toggle(&process(8, "make"), 10_000_000));
1835 assert!(!watch.toggle(&process(8, "make"), 10_000_000));
1836 assert_eq!(watch.pids(), [7]);
1837
1838 let mut snapshot = Snapshot::default();
1841 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
1842 assert!(watch.check(&snapshot, 10_000_000).is_empty());
1843 snapshot.processes = vec![process(7, "train.py")];
1845 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
1846
1847 snapshot.watched_pids = vec![7];
1848 snapshot.processes = vec![process(7, "train.py")];
1849 assert_eq!(watch.state(&snapshot, 7), WatchState::Running);
1850 assert!(watch.check(&snapshot, 10_000_000).is_empty());
1851
1852 snapshot.processes.clear();
1854 assert_eq!(watch.state(&snapshot, 7), WatchState::Exited);
1855 let alerts = watch.check(&snapshot, 10_000_000 + 1_800_000);
1856 assert_eq!(alerts.len(), 1);
1857 assert_eq!(alerts[0].text, "train.py (pid 7) exited after 1h30m");
1858 assert_eq!(watch.pids(), Vec::<u32>::new(), "reported once");
1859
1860 watch.toggle(&process(9, "rsync"), 0);
1862 snapshot.processes = vec![process(9, "bash")];
1863 assert_eq!(watch.check(&snapshot, 0).len(), 1);
1864 }
1865
1866 #[test]
1867 fn failed_units_warn_unless_ignored() {
1868 let snapshot = Snapshot {
1869 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
1870 ..Default::default()
1871 };
1872 let limits = AlertsConfig {
1873 ignored_units: vec!["wait-online.service".to_owned()],
1874 ..Default::default()
1875 };
1876 let alerts = alerts_with(&snapshot, &limits);
1877 assert_eq!(alerts.len(), 1);
1878 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
1879 assert_eq!(alerts[0].severity, Severity::Warning);
1880 }
1881
1882 #[test]
1883 fn version_notes_only_for_other_versions() {
1884 let mut snapshot = Snapshot::default();
1885 assert_eq!(version_note(&snapshot), None);
1886 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1887 assert_eq!(version_note(&snapshot), None);
1888 snapshot.host.rigtop_version = "0.1.0".to_owned();
1889 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1890 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1891 }
1892
1893 #[test]
1894 fn alert_log_records_alerts_appearing_and_clearing() {
1895 let alert = |severity, text: &str| Alert {
1896 severity,
1897 text: text.to_owned(),
1898 };
1899 let full = alert(Severity::Warning, "/data 91% full");
1900 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1901 let mut log = AlertLog::default();
1902 log.update("nas", std::slice::from_ref(&full), 1_000);
1903 log.update(
1905 "nas",
1906 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1907 2_000,
1908 );
1909 log.update("zefat", &[], 2_000);
1910 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1911 log.mark_read();
1912 log.update("nas", std::slice::from_ref(&errors), 3_000);
1913 assert_eq!(log.unread(), (0, None));
1914 let summary: Vec<(u64, AlertChange, &str)> = log
1915 .events()
1916 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1917 .collect();
1918 assert_eq!(
1919 summary,
1920 [
1921 (1_000, AlertChange::Raised, "/data 91% full"),
1922 (
1923 2_000,
1924 AlertChange::Raised,
1925 "btrfs /: 2 I/O errors (nvme1n1)"
1926 ),
1927 (3_000, AlertChange::Cleared, "/data 92% full"),
1928 ]
1929 );
1930 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1931 log.update(
1932 "flappy",
1933 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1934 i,
1935 );
1936 }
1937 assert_eq!(log.events().count(), ALERT_LOG_LEN);
1938 }
1939
1940 #[test]
1941 fn filesystems_filling_up_warn() {
1942 use crate::snapshot::FilesystemUsage;
1943 let filling = |full_in_secs| Snapshot {
1944 filesystems: vec![FilesystemUsage {
1945 mount_points: vec!["/srv".to_owned()],
1946 total_bytes: 100,
1947 available_bytes: 50,
1948 full_in_secs,
1949 ..Default::default()
1950 }],
1951 ..Default::default()
1952 };
1953 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<String> {
1954 alerts_with(snapshot, limits)
1955 .into_iter()
1956 .map(|a| a.text)
1957 .collect()
1958 };
1959 let defaults = AlertsConfig::default();
1960 assert_eq!(
1961 texts(&filling(Some(5 * 3600)), &defaults),
1962 ["/srv full in ~5h00m"]
1963 );
1964 assert!(texts(&filling(Some(30 * 3600)), &defaults).is_empty());
1965 assert!(texts(&filling(None), &defaults).is_empty());
1966 let off = AlertsConfig {
1967 filesystem_full_warning_hours: 0.0,
1968 ..Default::default()
1969 };
1970 assert!(texts(&filling(Some(60)), &off).is_empty());
1971 }
1972
1973 #[test]
1974 fn alert_thresholds_come_from_the_configuration() {
1975 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1976 let temp = |label: &str, celsius| Temperature {
1977 label: label.to_owned(),
1978 celsius,
1979 ..Default::default()
1980 };
1981 let mut snapshot = Snapshot {
1982 filesystems: vec![FilesystemUsage {
1983 mount_points: vec!["/data".to_owned()],
1984 total_bytes: 100,
1985 available_bytes: 8,
1986 ..Default::default()
1987 }],
1988 gpus: vec![GpuStats {
1989 index: 0,
1990 temperature_c: Some(80.0),
1991 ..Default::default()
1992 }],
1993 smart: vec![SmartDevice {
1994 device: "nvme0".to_owned(),
1995 temperature_c: Some(72.0),
1996 ..Default::default()
1997 }],
1998 sensors: vec![
2000 SensorChip {
2001 key: "nvme:nvme0".to_owned(),
2002 name: "nvme".to_owned(),
2003 temperatures: vec![temp("Composite", 71.0)],
2004 ..Default::default()
2005 },
2006 SensorChip {
2007 key: "nvme:nvme1".to_owned(),
2008 name: "nvme".to_owned(),
2009 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
2010 ..Default::default()
2011 },
2012 ],
2013 ..Default::default()
2014 };
2015 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
2016 let texts_of = |snapshot: &Snapshot| -> Vec<String> {
2017 alerts_with(snapshot, &AlertsConfig::default())
2018 .into_iter()
2019 .map(|a| a.text)
2020 .collect()
2021 };
2022 let texts = |limits: &AlertsConfig| -> Vec<String> {
2023 alerts_with(&snapshot, limits)
2024 .into_iter()
2025 .map(|a| a.text)
2026 .collect()
2027 };
2028 assert_eq!(
2029 texts(&AlertsConfig::default()),
2030 [
2031 "/data 92% full",
2032 "CPU at 97°C",
2033 "nvme0 at 72°C",
2034 "nvme1 at 74°C"
2035 ]
2036 );
2037 let custom = AlertsConfig {
2038 filesystem_warning_percent: 0.0,
2039 cpu_warning_celsius: 0.0,
2040 gpu_warning_celsius: 75.0,
2041 drive_warning_celsius: 73.0,
2042 ..Default::default()
2043 };
2044 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
2045
2046 let mut gpu = snapshot.gpus[0].clone();
2048 gpu.throttle_reasons = vec!["power cap".to_owned()];
2049 let mut capped = snapshot.clone();
2050 capped.gpus = vec![gpu.clone()];
2051 assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
2052 gpu.throttle_reasons.push("hw thermal".to_owned());
2053 gpu.ecc = Some(EccErrors {
2054 corrected: 5,
2055 uncorrected: 2,
2056 });
2057 capped.gpus = vec![gpu];
2058 let alerts = alerts_with(&capped, &AlertsConfig::default());
2059 assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
2060 assert_eq!(alerts[0].severity, Severity::Critical);
2061 assert!(
2062 alerts
2063 .iter()
2064 .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
2065 );
2066 let critical = AlertsConfig {
2067 filesystem_critical_percent: 90.0,
2068 ..custom
2069 };
2070 assert_eq!(
2071 alerts_with(&snapshot, &critical)[0],
2072 Alert {
2073 severity: Severity::Critical,
2074 text: "/data 92% full".to_owned()
2075 }
2076 );
2077 }
2078
2079 #[test]
2080 fn memory_alerts() {
2081 use crate::snapshot::{MemoryStats, OomKill};
2082 let kill = |name: Option<&str>| OomKill {
2083 at_ms: 1,
2084 likely_process: name.map(str::to_owned),
2085 };
2086 let mut snapshot = Snapshot {
2087 memory: MemoryStats {
2088 total_bytes: 100,
2089 used_bytes: 93,
2090 oom_kills: vec![kill(Some("python3"))],
2091 ..Default::default()
2092 },
2093 gpus: vec![GpuStats {
2094 index: 1,
2095 memory_used_bytes: Some(99),
2096 memory_total_bytes: Some(100),
2097 ..Default::default()
2098 }],
2099 ..Default::default()
2100 };
2101 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<(Severity, String)> {
2102 alerts_with(snapshot, limits)
2103 .into_iter()
2104 .map(|a| (a.severity, a.text))
2105 .collect()
2106 };
2107 assert_eq!(
2109 texts(&snapshot, &AlertsConfig::default()),
2110 [
2111 (
2112 Severity::Critical,
2113 "OOM killer stopped python3 (likely)".to_owned()
2114 ),
2115 (Severity::Warning, "memory 93% used".to_owned()),
2116 ]
2117 );
2118 let vram = AlertsConfig {
2119 memory_warning_percent: 0.0,
2120 gpu_memory_warning_percent: 98.0,
2121 ..Default::default()
2122 };
2123 snapshot.memory.oom_kills = vec![kill(Some("bash")), kill(None), kill(Some("python3"))];
2124 assert_eq!(
2125 texts(&snapshot, &vram),
2126 [
2127 (
2128 Severity::Critical,
2129 "OOM killer stopped 3 processes (likely python3, bash)".to_owned()
2130 ),
2131 (Severity::Warning, "GPU 1 memory 99% used".to_owned()),
2132 ]
2133 );
2134 snapshot.memory.oom_kills = vec![kill(None)];
2135 assert_eq!(texts(&snapshot, &vram)[0].1, "OOM killer stopped a process");
2136 }
2137
2138 #[test]
2139 fn interface_helpers() {
2140 assert!(is_virtual_interface("veth5a74356"));
2141 assert!(!is_virtual_interface("enp9s0"));
2142 let snapshot = Snapshot {
2143 networks: vec![
2144 NetworkStats {
2145 name: "docker0".to_owned(),
2146 rx_total_bytes: 1 << 40,
2147 ..Default::default()
2148 },
2149 NetworkStats {
2150 name: "enp9s0".to_owned(),
2151 rx_total_bytes: 1 << 30,
2152 ..Default::default()
2153 },
2154 ],
2155 ..Default::default()
2156 };
2157 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
2158 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
2159 }
2160}