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, 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)]
354#[serde(rename_all = "lowercase")]
355pub enum AlertChange {
356 Raised,
357 Cleared,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct AlertEvent {
363 pub at_ms: u64,
365 pub host: String,
366 pub change: AlertChange,
367 pub alert: Alert,
369}
370
371const ALERT_LOG_LEN: usize = 500;
373
374#[derive(Debug, Default)]
377pub struct AlertLog {
378 events: VecDeque<AlertEvent>,
380 active: HashMap<String, Vec<Alert>>,
382 unread: (usize, Option<Severity>),
384 file: Option<PathBuf>,
386}
387
388pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
392 dirs::state_dir()
393 .or_else(dirs::data_local_dir)
394 .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
395}
396
397impl AlertLog {
398 pub fn persistent(path: PathBuf) -> Self {
403 let mut log = Self::default();
404 let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
405 .map(|text| {
406 text.lines()
407 .filter_map(|line| serde_json::from_str(line).ok())
408 .collect()
409 })
410 .unwrap_or_default();
411 let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
412 for event in &loaded {
413 let active = log.active.entry(event.host.clone()).or_default();
414 let key = alert_key(&event.alert);
415 active.retain(|a| alert_key(a) != key);
416 if event.change == AlertChange::Raised {
417 active.push(event.alert.clone());
418 }
419 }
420 log.events = loaded.into_iter().skip(keep).collect();
421 if keep > ALERT_LOG_LEN {
423 let text: String = log
424 .events
425 .iter()
426 .filter_map(|e| serde_json::to_string(e).ok())
427 .map(|line| line + "\n")
428 .collect();
429 let tmp = path.with_extension("jsonl.tmp");
430 let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
431 }
432 log.file = Some(path);
433 log
434 }
435
436 fn save(&self, event: &AlertEvent) {
438 let Some(path) = &self.file else {
439 return;
440 };
441 let Ok(line) = serde_json::to_string(event) else {
442 return;
443 };
444 if let Some(dir) = path.parent() {
445 let _ = fs::create_dir_all(dir);
446 }
447 let _ = OpenOptions::new()
449 .create(true)
450 .append(true)
451 .open(path)
452 .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
453 }
454
455 pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) -> Vec<AlertEvent> {
458 let previous = self.active.remove(host).unwrap_or_default();
459 let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
460 let (before, now) = (keys(&previous), keys(alerts));
461 let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
462 let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
463 let mut changes: Vec<(AlertChange, &Alert)> = raised
464 .map(|a| (AlertChange::Raised, a))
465 .chain(cleared.map(|a| (AlertChange::Cleared, a)))
466 .collect();
467 changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
469 let mut new_events = Vec::with_capacity(changes.len());
470 for (change, alert) in changes {
471 if change == AlertChange::Raised {
472 self.unread.0 += 1;
473 self.unread.1 = self.unread.1.max(Some(alert.severity));
474 }
475 if self.events.len() == ALERT_LOG_LEN {
476 self.events.pop_front();
477 }
478 let event = AlertEvent {
479 at_ms,
480 host: host.to_owned(),
481 change,
482 alert: alert.clone(),
483 };
484 self.save(&event);
485 new_events.push(event.clone());
486 self.events.push_back(event);
487 }
488 self.active.insert(host.to_owned(), alerts.to_vec());
489 new_events
490 }
491
492 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
494 self.events.iter()
495 }
496
497 pub fn unread(&self) -> (usize, Option<Severity>) {
499 self.unread
500 }
501
502 pub fn mark_read(&mut self) {
504 self.unread = (0, None);
505 }
506}
507
508pub fn event_time(at_ms: u64, now: SystemTime) -> String {
511 let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
512 let today: DateTime<Local> = now.into();
513 let days = (today.date_naive() - at.date_naive()).num_days();
514 if days == 0 {
515 at.format("%H:%M:%S").to_string()
516 } else if (1..6).contains(&days) {
517 at.format("%a %H:%M").to_string()
518 } else {
519 at.format("%b %d %H:%M").to_string()
520 }
521}
522
523pub fn process_details(
527 snapshot: &Snapshot,
528 pid: u32,
529 now: SystemTime,
530) -> Option<Vec<(&'static str, String)>> {
531 let by_pid: HashMap<u32, &ProcessInfo> =
532 snapshot.processes.iter().map(|p| (p.pid, p)).collect();
533 let process = by_pid.get(&pid)?;
534 let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
535
536 let mut chain = Vec::new();
539 let mut parent = process.parent_pid;
540 while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
541 match by_pid.get(&ppid) {
542 Some(p) => {
543 chain.push(format!("{} ({})", p.name, p.pid));
544 parent = p.parent_pid.filter(|&next| next != p.pid);
545 }
546 None => {
547 chain.push(ppid.to_string());
548 break;
549 }
550 }
551 }
552 if !chain.is_empty() {
553 chain.reverse();
554 rows.push(("Parents", chain.join(" › ")));
555 }
556 let children = snapshot
557 .processes
558 .iter()
559 .filter(|p| p.parent_pid == Some(pid))
560 .count();
561 if children > 0 {
562 rows.push(("Children", children.to_string()));
563 }
564 rows.push(("User", process.user.clone()));
565 if let Some(container) = &process.container {
566 rows.push(("Container", container.clone()));
567 }
568 if !process.state.is_empty() {
569 rows.push(("State", process.state.clone()));
570 }
571 let started = now - Duration::from_secs(process.run_time_secs);
572 rows.push(("Running", since_note(started, now)));
573 rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
574 rows.push(("Memory", format::bytes(process.memory_bytes)));
575 if let Some(threads) = process.threads {
576 rows.push(("Threads", threads.to_string()));
577 }
578 rows.push((
579 "Disk",
580 format!(
581 "read {}, write {}",
582 format::rate(process.read_bytes_per_sec),
583 format::rate(process.write_bytes_per_sec)
584 ),
585 ));
586 for gpu in &snapshot.gpus {
587 if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
588 let mut text = format!("GPU {}", gpu.index);
589 if let Some(sm) = usage.sm_percent {
590 text.push_str(&format!(" {sm:.0}%"));
591 }
592 if let Some(memory) = usage.memory_bytes {
593 text.push_str(&format!(", {} VRAM", format::bytes(memory)));
594 }
595 rows.push(("GPU", text));
596 }
597 }
598 let command = if process.command.is_empty() {
599 format!("[{}]", process.name)
600 } else {
601 process.command.clone()
602 };
603 rows.push(("Command", command));
604 Some(rows)
605}
606
607pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
611 let worst = alerts.iter().map(|a| a.severity).max();
612 if unreachable || worst == Some(Severity::Critical) {
613 0
614 } else if stale || worst == Some(Severity::Warning) {
615 1
616 } else {
617 2
618 }
619}
620
621pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
625 let mut notes = Vec::new();
626 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
627 if !slowdown.is_empty() {
628 notes.push((
629 format!("slowed: {}", slowdown.join(", ")),
630 Some(Severity::Warning),
631 ));
632 } else if !gpu.throttle_reasons.is_empty() {
633 notes.push(("power cap".to_owned(), None));
634 }
635 if let Some(ecc) = gpu.ecc {
636 if ecc.uncorrected > 0 {
637 notes.push((
638 format!("ECC {} uncorrected", ecc.uncorrected),
639 Some(Severity::Critical),
640 ));
641 } else if ecc.corrected > 0 {
642 notes.push((format!("ECC {} corrected", ecc.corrected), None));
643 }
644 }
645 notes
646}
647
648pub fn pcie_text(link: &PcieLink) -> String {
650 let now = format!("PCIe gen {} x{}", link.generation, link.width);
651 if (link.generation, link.width) == (link.max_generation, link.max_width) {
652 now
653 } else {
654 format!(
655 "{now} (up to gen {} x{}; the link slows down when idle)",
656 link.max_generation, link.max_width
657 )
658 }
659}
660
661pub fn since_note(since: SystemTime, now: SystemTime) -> String {
664 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
665 let span = match secs {
666 0..60 => format!("{secs}s"),
667 60..3_600 => format!("{}m", secs / 60),
668 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
669 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
670 };
671 let at: DateTime<Local> = since.into();
672 let today: DateTime<Local> = now.into();
673 let clock = if at.date_naive() == today.date_naive() {
674 at.format("%H:%M")
675 } else if secs < 6 * 86_400 {
676 at.format("%a %H:%M")
677 } else {
678 at.format("%b %d %H:%M")
679 };
680 format!("{span} (since {clock})")
681}
682
683pub fn version_note(snapshot: &Snapshot) -> Option<String> {
686 let version = &snapshot.host.rigtop_version;
687 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
688 .then(|| format!("agent {version}"))
689}
690
691pub fn version_hint(snapshot: &Snapshot) -> String {
693 format!(
694 "this host runs rigtop {}, this machine {}: install the same version on both \
695 for every reading",
696 snapshot.host.rigtop_version,
697 env!("CARGO_PKG_VERSION")
698 )
699}
700
701pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
703 alerts_with(snapshot, &AlertsConfig::default())
704}
705
706pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
710 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
712 let mut alerts = Vec::new();
713 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
714 for fs in &snapshot.raid.btrfs {
715 let name = fs
716 .mount_points
717 .first()
718 .map_or(fs.fsid.as_str(), String::as_str);
719 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
720 if missing > 0 {
721 add(
722 Severity::Critical,
723 format!("btrfs {name}: {missing} device(s) missing"),
724 );
725 }
726 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
727 if errors > 0 {
728 let devices: Vec<String> = fs
730 .device_stats
731 .iter()
732 .filter(|d| d.total_errors() > 0)
733 .map(|d| d.label())
734 .collect();
735 add(
736 Severity::Critical,
737 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
738 );
739 }
740 if let Some(scrub) = &fs.scrub {
741 if scrub.uncorrectable > 0 {
742 add(
743 Severity::Critical,
744 format!(
745 "btrfs {name}: scrub found {} uncorrectable errors",
746 scrub.uncorrectable
747 ),
748 );
749 } else if scrub.errors > 0 {
750 add(
751 Severity::Warning,
752 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
753 );
754 }
755 }
756 }
757 for md in &snapshot.raid.md {
758 if md.active && md.is_degraded() {
759 add(
760 Severity::Critical,
761 format!("{} ({}) degraded", md.name, md.level),
762 );
763 } else if !md.active {
764 add(Severity::Warning, format!("{} inactive", md.name));
765 }
766 if let Some(sync) = &md.sync {
767 add(
768 Severity::Warning,
769 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
770 );
771 }
772 }
773 for pool in &snapshot.raid.zfs {
774 if pool.is_failing() {
775 add(
776 Severity::Critical,
777 format!("zfs {} {}", pool.name, pool.state),
778 );
779 } else if !pool.is_online() {
780 add(
781 Severity::Warning,
782 format!("zfs {} {}", pool.name, pool.state),
783 );
784 }
785 }
786 for unit in &snapshot.failed_units {
787 if !limits.ignored_units.contains(unit) {
788 add(Severity::Warning, format!("systemd: {unit} failed"));
789 }
790 }
791 for smart in &snapshot.smart {
792 if let Some(error) = &smart.error {
793 add(
794 Severity::Warning,
795 format!("{}: SMART unreadable ({error})", smart.device),
796 );
797 } else if smart.passed == Some(false) {
798 add(
799 Severity::Critical,
800 format!("{}: SMART failed", smart.device),
801 );
802 } else if smart.needs_attention() {
803 add(
804 Severity::Critical,
805 format!("{}: SMART warning", smart.device),
806 );
807 }
808 }
809 for fs in &snapshot.filesystems {
810 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
811 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
812 let name = fs
813 .mount_points
814 .first()
815 .map_or(fs.device.as_str(), String::as_str);
816 if reached(percent, limits.filesystem_critical_percent) {
817 add(Severity::Critical, format!("{name} {percent:.0}% full"));
818 } else if reached(percent, limits.filesystem_warning_percent) {
819 add(Severity::Warning, format!("{name} {percent:.0}% full"));
820 }
821 }
822 let hottest_cpu = snapshot
823 .cpu
824 .temperatures
825 .iter()
826 .map(|t| t.celsius)
827 .fold(f32::NAN, f32::max);
828 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
829 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
830 }
831 for gpu in &snapshot.gpus {
832 if let Some(t) = gpu
833 .temperature_c
834 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
835 {
836 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
837 }
838 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
839 if !slowdown.is_empty() {
840 add(
841 Severity::Warning,
842 format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
843 );
844 }
845 if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
846 add(
847 Severity::Critical,
848 format!(
849 "GPU {}: {} uncorrected ECC errors",
850 gpu.index, ecc.uncorrected
851 ),
852 );
853 }
854 }
855 for (device, celsius) in drive_temperatures(snapshot) {
856 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
857 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
858 }
859 }
860 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
862 alerts
863}
864
865fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
868 let mut drives: Vec<(String, f32)> = snapshot
869 .smart
870 .iter()
871 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
872 .collect();
873 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
874 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
876 if drives.iter().any(|(d, _)| d == device) {
877 continue;
878 }
879 let composite = chip
880 .temperatures
881 .iter()
882 .find(|t| t.label == "Composite")
883 .or(chip.temperatures.first());
884 if let Some(t) = composite {
885 drives.push((device.to_owned(), t.celsius));
886 }
887 }
888 drives
889}
890
891#[derive(Debug, Clone, Copy)]
893pub struct ProcessQuery<'q> {
894 pub sort_key: SortKey,
896 pub descending: bool,
898 pub filter: &'q str,
900 pub show_kernel_threads: bool,
902 pub tree: bool,
905}
906
907pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
909 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
911 for gpu in &snapshot.gpus {
912 for p in &gpu.processes {
913 let entry = gpu_usage.entry(p.pid).or_default();
914 if let Some(sm) = p.sm_percent {
915 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
916 }
917 if let Some(mem) = p.memory_bytes {
918 entry.1 = Some(entry.1.unwrap_or(0) + mem);
919 }
920 }
921 }
922
923 let filter = query.filter.to_lowercase();
924 let matches = |p: &ProcessInfo| {
925 filter.is_empty()
926 || p.name.to_lowercase().contains(&filter)
927 || p.command.to_lowercase().contains(&filter)
928 || p.user.to_lowercase().contains(&filter)
929 || p.container
930 .as_ref()
931 .is_some_and(|c| c.to_lowercase().contains(&filter))
932 || p.pid.to_string() == filter
933 };
934 let candidates = snapshot
935 .processes
936 .iter()
937 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
938 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
939 with_ancestors(candidates.collect(), matches)
940 } else {
941 candidates.filter(|p| matches(p)).collect()
942 };
943 let mut rows: Vec<ProcessRow<'a>> = shown
944 .into_iter()
945 .map(|info| {
946 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
947 ProcessRow {
948 info,
949 gpu_percent,
950 vram_bytes,
951 tree_prefix: String::new(),
952 }
953 })
954 .collect();
955
956 let key = query.sort_key;
957 rows.sort_by(|a, b| {
958 let ordering = match key {
959 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
960 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
961 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
962 SortKey::User => a.info.user.cmp(&b.info.user),
963 SortKey::Container => match (&a.info.container, &b.info.container) {
964 (Some(a), Some(b)) => a.cmp(b),
965 (Some(_), None) => std::cmp::Ordering::Less,
966 (None, Some(_)) => std::cmp::Ordering::Greater,
967 (None, None) => std::cmp::Ordering::Equal,
968 },
969 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
970 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
971 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
972 SortKey::Gpu => a
973 .gpu_percent
974 .unwrap_or(-1.0)
975 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
976 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
977 }
978 .then(a.info.pid.cmp(&b.info.pid));
980 if query.descending {
981 ordering.reverse()
982 } else {
983 ordering
984 }
985 });
986 if query.tree { into_tree(rows) } else { rows }
987}
988
989fn with_ancestors(
992 processes: Vec<&ProcessInfo>,
993 matches: impl Fn(&ProcessInfo) -> bool,
994) -> Vec<&ProcessInfo> {
995 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
996 let mut keep: HashSet<u32> = HashSet::new();
997 for process in processes.iter().filter(|p| matches(p)) {
998 let mut current = Some(*process);
999 while let Some(p) = current
1001 && keep.insert(p.pid)
1002 {
1003 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1004 }
1005 }
1006 processes
1007 .into_iter()
1008 .filter(|p| keep.contains(&p.pid))
1009 .collect()
1010}
1011
1012fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1016 let index: HashMap<u32, usize> = rows
1017 .iter()
1018 .enumerate()
1019 .map(|(i, row)| (row.info.pid, i))
1020 .collect();
1021 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1022 let mut roots = Vec::new();
1023 for (i, row) in rows.iter().enumerate() {
1024 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1025 Some(&parent) if parent != i => children[parent].push(i),
1026 _ => roots.push(i),
1027 }
1028 }
1029 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1030 let mut visited = vec![false; rows.len()];
1031 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1033 for start in starts {
1034 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1036 while let Some((i, lead, last)) = stack.pop() {
1037 if std::mem::replace(&mut visited[i], true) {
1038 continue;
1039 }
1040 let (prefix, child_lead) = match last {
1041 None => (String::new(), String::new()),
1042 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
1043 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1044 };
1045 order.push((i, prefix));
1046 let kids = &children[i];
1047 for (k, &child) in kids.iter().enumerate().rev() {
1048 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1049 }
1050 }
1051 }
1052 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1053 order
1054 .into_iter()
1055 .filter_map(|(i, prefix)| {
1056 let mut row = rows[i].take()?;
1057 row.tree_prefix = prefix;
1058 Some(row)
1059 })
1060 .collect()
1061}
1062
1063pub fn disk_rate(process: &ProcessInfo) -> f64 {
1065 process.read_bytes_per_sec + process.write_bytes_per_sec
1066}
1067
1068pub fn disk_rate_text(process: &ProcessInfo) -> String {
1071 let rate = disk_rate(process);
1072 if rate > 0.0 {
1073 format::rate(rate)
1074 } else {
1075 String::new()
1076 }
1077}
1078
1079pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1082
1083pub fn lhm_help(error: &str) -> String {
1085 format!(
1086 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1087 Start it and turn on Options → Remote Web Server → Run.\n\
1088 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1089 {error}"
1090 )
1091}
1092
1093pub fn signal_name(signal: ProcessSignal) -> &'static str {
1095 match signal {
1096 ProcessSignal::Terminate => "SIGTERM",
1097 ProcessSignal::Kill => "SIGKILL",
1098 }
1099}
1100
1101pub fn is_virtual_interface(name: &str) -> bool {
1104 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1105}
1106
1107pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1109 snapshot
1110 .networks
1111 .iter()
1112 .filter(|n| !is_virtual_interface(&n.name))
1113 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1114 .or_else(|| snapshot.networks.first())
1115 .map(|n| n.name.clone())
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1122
1123 #[test]
1124 fn process_details_rows() {
1125 let mut snapshot = Snapshot::default();
1126 let mut shell = process(20, "zsh", 0.0, false);
1127 shell.parent_pid = Some(1);
1128 let mut python = process(30, "python", 99.5, false);
1129 python.parent_pid = Some(20);
1130 python.command = "python train.py --epochs 10".to_owned();
1131 python.container = Some("trainer".to_owned());
1132 python.run_time_secs = 3_660;
1133 snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1134 snapshot.gpus = vec![GpuStats {
1135 index: 0,
1136 processes: vec![GpuProcess {
1137 pid: 30,
1138 memory_bytes: Some(2 << 30),
1139 sm_percent: Some(97.0),
1140 }],
1141 ..Default::default()
1142 }];
1143 let now = SystemTime::now();
1144 let rows = process_details(&snapshot, 30, now).unwrap();
1145 let get = |label: &str| {
1146 rows.iter()
1147 .find(|(l, _)| *l == label)
1148 .map(|(_, v)| v.as_str())
1149 };
1150 assert_eq!(get("Program"), Some("python (pid 30)"));
1151 assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1152 assert_eq!(get("Children"), None);
1153 assert_eq!(get("Container"), Some("trainer"));
1154 assert!(get("Running").unwrap().starts_with("1h01m (since "));
1155 assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1156 assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1157 let shell = process_details(&snapshot, 20, now).unwrap();
1158 assert!(shell.contains(&("Children", "1".to_owned())));
1159 assert!(process_details(&snapshot, 99, now).is_none());
1160 }
1161
1162 #[test]
1163 fn attention_ranks() {
1164 let alert = |severity| Alert {
1165 severity,
1166 text: String::new(),
1167 };
1168 assert_eq!(attention_rank(&[], false, false), 2);
1169 assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1170 assert_eq!(attention_rank(&[], false, true), 1);
1171 assert_eq!(
1172 attention_rank(
1173 &[alert(Severity::Warning), alert(Severity::Critical)],
1174 false,
1175 false
1176 ),
1177 0
1178 );
1179 assert_eq!(attention_rank(&[], true, false), 0);
1180 }
1181
1182 #[test]
1183 fn gpu_notes_and_pcie() {
1184 let mut gpu = GpuStats {
1185 throttle_reasons: vec!["power cap".to_owned()],
1186 ..Default::default()
1187 };
1188 assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1189 gpu.throttle_reasons.push("thermal".to_owned());
1190 gpu.ecc = Some(EccErrors {
1191 corrected: 3,
1192 uncorrected: 0,
1193 });
1194 assert_eq!(
1195 gpu_notes(&gpu),
1196 [
1197 ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1198 ("ECC 3 corrected".to_owned(), None)
1199 ]
1200 );
1201 let full = PcieLink {
1202 generation: 4,
1203 width: 16,
1204 max_generation: 4,
1205 max_width: 16,
1206 };
1207 assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1208 let idle = PcieLink {
1209 generation: 1,
1210 width: 8,
1211 ..full
1212 };
1213 assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1214 }
1215
1216 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1217 ProcessInfo {
1218 pid,
1219 name: name.to_owned(),
1220 cpu_percent: cpu,
1221 kernel_thread: kernel,
1222 ..Default::default()
1223 }
1224 }
1225
1226 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1227 ProcessQuery {
1228 sort_key,
1229 descending: sort_key.descending_by_default(),
1230 filter: "",
1231 show_kernel_threads: false,
1232 tree: false,
1233 }
1234 }
1235
1236 #[test]
1237 fn history_keeps_newest_samples() {
1238 let mut history = History::default();
1239 for i in 0..(HISTORY_LEN + 5) {
1240 history.push(i as f64);
1241 }
1242 assert_eq!(
1243 history.last(2),
1244 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1245 );
1246 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1247 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1248 }
1249
1250 #[test]
1251 fn graph_spans_spread_samples_over_the_width() {
1252 let second = Duration::from_secs(1);
1253 let mut history = History::default();
1254 for i in 0..120 {
1255 history.push(i as f64);
1256 }
1257 assert_eq!(
1259 history.graph(3, GraphSpan::Fit, second),
1260 vec![117.0, 118.0, 119.0]
1261 );
1262 let minute = history.graph(30, GraphSpan::Secs(60), second);
1264 assert_eq!(minute.len(), 30);
1265 assert_eq!(minute[0], 60.5);
1266 assert_eq!(minute[29], 118.5);
1267 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1269 assert_eq!(stretched.len(), 120);
1270 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1271 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1272 assert_eq!(stretched[119], 119.0);
1273 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1274 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1276 assert_eq!(hour.len(), 10);
1277 assert_eq!(hour[0], 5.5);
1278 assert!(
1279 History::default()
1280 .graph(10, GraphSpan::Secs(60), second)
1281 .is_empty()
1282 );
1283 }
1284
1285 #[test]
1286 fn long_spans_draw_from_the_averages() {
1287 let second = Duration::from_secs(1);
1288 let mut history = History::default();
1289 for i in 0..7200 {
1291 history.push(if i < 3600 { 0.0 } else { 100.0 });
1292 }
1293 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1295 assert_eq!(six.len(), 30);
1296 assert_eq!((six[0], six[29]), (0.0, 100.0));
1297 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1299 assert!(hour.iter().all(|v| *v == 100.0));
1300 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1302 history.push(i as f64);
1303 }
1304 assert_eq!(history.coarse.len(), COARSE_LEN);
1305 }
1306
1307 #[test]
1308 fn graph_span_choices_cycle() {
1309 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1310 .take(6)
1311 .map(GraphSpan::label)
1312 .collect();
1313 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1314 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1315 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1316 assert_eq!(
1317 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1318 GraphSpan::Secs(300)
1319 );
1320 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1321 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1323 }
1324
1325 #[test]
1326 fn rows_filter_sort_and_join_gpu_usage() {
1327 let snapshot = Snapshot {
1328 processes: vec![
1329 process(10, "bash", 5.0, false),
1330 process(2, "kworker/0:1", 50.0, true),
1331 process(30, "python", 1.0, false),
1332 ],
1333 gpus: vec![GpuStats {
1334 processes: vec![GpuProcess {
1335 pid: 30,
1336 memory_bytes: Some(1 << 30),
1337 sm_percent: Some(70.0),
1338 }],
1339 ..Default::default()
1340 }],
1341 ..Default::default()
1342 };
1343 let pids = |q: &ProcessQuery| {
1344 process_rows(&snapshot, q)
1345 .iter()
1346 .map(|r| r.info.pid)
1347 .collect::<Vec<_>>()
1348 };
1349 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1350 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1351 assert_eq!(
1352 pids(&ProcessQuery {
1353 show_kernel_threads: true,
1354 ..query(SortKey::Cpu)
1355 }),
1356 vec![2, 10, 30]
1357 );
1358 assert_eq!(
1359 pids(&ProcessQuery {
1360 filter: "PYT",
1361 ..query(SortKey::Pid)
1362 }),
1363 vec![30]
1364 );
1365 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1366 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1367 assert_eq!(rows[1].gpu_percent, Some(70.0));
1368 }
1369
1370 #[test]
1371 fn since_notes_say_how_long_and_since_when() {
1372 let now = SystemTime::now();
1373 let ago = |secs: u64| now - Duration::from_secs(secs);
1374 assert!(since_note(ago(42), now).starts_with("42s (since "));
1375 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1376 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1377 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1378 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1380 }
1381
1382 #[test]
1383 fn tree_puts_children_under_parents() {
1384 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1385 parent_pid: Some(parent),
1386 ..process(pid, name, cpu, false)
1387 };
1388 let snapshot = Snapshot {
1389 processes: vec![
1390 process(1, "init", 0.0, false),
1391 child(10, 1, "sshd", 1.0),
1392 child(11, 10, "bash", 2.0),
1393 child(12, 11, "python", 50.0),
1394 child(20, 1, "cron", 3.0),
1395 child(30, 999, "orphan", 4.0),
1397 child(40, 41, "loop-a", 0.0),
1399 child(41, 40, "loop-b", 0.0),
1400 ],
1401 ..Default::default()
1402 };
1403 let tree = |filter| {
1404 process_rows(
1405 &snapshot,
1406 &ProcessQuery {
1407 filter,
1408 tree: true,
1409 ..query(SortKey::Cpu)
1410 },
1411 )
1412 .iter()
1413 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1414 .collect::<Vec<_>>()
1415 };
1416 assert_eq!(
1418 tree(""),
1419 vec![
1420 "orphan",
1421 "init",
1422 "├ cron",
1423 "└ sshd",
1424 " └ bash",
1425 " └ python",
1426 "loop-b",
1427 "└ loop-a",
1428 ]
1429 );
1430 assert_eq!(
1432 tree("pyth"),
1433 vec!["init", "└ sshd", " └ bash", " └ python"]
1434 );
1435 }
1436
1437 #[test]
1438 fn disk_column_sums_reads_and_writes() {
1439 let reader = ProcessInfo {
1440 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1441 ..process(10, "reader", 0.0, false)
1442 };
1443 let writer = ProcessInfo {
1444 read_bytes_per_sec: 1024.0 * 1024.0,
1445 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1446 ..process(20, "writer", 0.0, false)
1447 };
1448 let idle = process(30, "idle", 0.0, false);
1449 assert_eq!(disk_rate_text(&writer), "5.0M/s");
1450 assert_eq!(disk_rate_text(&idle), "");
1451 let snapshot = Snapshot {
1452 processes: vec![idle, reader, writer],
1453 ..Default::default()
1454 };
1455 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1456 .iter()
1457 .map(|r| r.info.pid)
1458 .collect();
1459 assert_eq!(pids, vec![20, 10, 30]);
1460 }
1461
1462 #[test]
1463 fn metric_history_records_and_switches_interfaces() {
1464 let snapshot = Snapshot {
1465 networks: vec![
1466 NetworkStats {
1467 name: "enp9s0".to_owned(),
1468 rx_total_bytes: 10,
1469 rx_bytes_per_sec: 100.0,
1470 ..Default::default()
1471 },
1472 NetworkStats {
1473 name: "wlp7s0".to_owned(),
1474 tx_bytes_per_sec: 5.0,
1475 ..Default::default()
1476 },
1477 ],
1478 gpus: vec![GpuStats {
1479 utilization_percent: Some(40.0),
1480 ..Default::default()
1481 }],
1482 ..Default::default()
1483 };
1484 let mut history = MetricHistory::default();
1485 history.record(&snapshot);
1486 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1487 assert_eq!(history.net_rx.last(1), vec![100.0]);
1488 assert_eq!(history.gpus[0].last(1), vec![40.0]);
1489 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1490 assert!(history.net_rx.last(10).is_empty());
1492 history.record(&snapshot);
1493 assert_eq!(history.net_tx.last(1), vec![5.0]);
1494 }
1495
1496 #[test]
1497 fn alerts_flag_storage_problems_first() {
1498 use crate::snapshot::{
1499 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1500 ZfsPool,
1501 };
1502 let snapshot = Snapshot {
1503 filesystems: vec![FilesystemUsage {
1504 mount_points: vec!["/data".to_owned()],
1505 total_bytes: 100,
1506 available_bytes: 8,
1507 ..Default::default()
1508 }],
1509 raid: RaidStats {
1510 btrfs: vec![BtrfsFilesystem {
1511 mount_points: vec!["/".to_owned()],
1512 device_stats: vec![BtrfsDeviceStats {
1513 devid: 1,
1514 corruption_errs: 2,
1515 ..Default::default()
1516 }],
1517 ..Default::default()
1518 }],
1519 md: vec![MdArray {
1520 name: "md0".to_owned(),
1521 active: true,
1522 level: "raid1".to_owned(),
1523 expected_devices: Some(2),
1524 working_devices: Some(1),
1525 ..Default::default()
1526 }],
1527 zfs: vec![
1528 ZfsPool {
1529 name: "tank".to_owned(),
1530 state: "DEGRADED".to_owned(),
1531 },
1532 ZfsPool {
1533 name: "cold".to_owned(),
1534 state: "OFFLINE".to_owned(),
1535 },
1536 ZfsPool {
1537 name: "fast".to_owned(),
1538 state: "ONLINE".to_owned(),
1539 },
1540 ],
1541 },
1542 smart: vec![SmartDevice {
1543 device: "sda".to_owned(),
1544 passed: Some(true),
1545 ..Default::default()
1546 }],
1547 ..Default::default()
1548 };
1549 let alerts = alerts(&snapshot);
1550 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1551 assert_eq!(
1552 texts,
1553 vec![
1554 "btrfs /: 2 I/O errors (devid 1)",
1555 "md0 (raid1) degraded",
1556 "zfs tank DEGRADED",
1557 "zfs cold OFFLINE",
1558 "/data 92% full"
1559 ]
1560 );
1561 assert_eq!(alerts[0].severity, Severity::Critical);
1562 assert_eq!(alerts[2].severity, Severity::Critical);
1563 assert_eq!(alerts[3].severity, Severity::Warning);
1564 assert!(super::alerts(&Snapshot::default()).is_empty());
1565 }
1566
1567 #[test]
1568 fn event_times_are_as_short_as_is_clear() {
1569 let now = SystemTime::now();
1570 let ms = |t: SystemTime| {
1571 t.duration_since(SystemTime::UNIX_EPOCH)
1572 .unwrap()
1573 .as_millis() as u64
1574 };
1575 assert_eq!(event_time(ms(now), now).len(), 8);
1577 let week_ago = now - Duration::from_secs(10 * 86_400);
1578 assert!(event_time(ms(week_ago), now).contains(' '));
1579 assert_ne!(event_time(ms(week_ago), now).len(), 8);
1580 }
1581
1582 #[test]
1583 fn alert_history_survives_a_restart() {
1584 let tmp = tempfile::tempdir().unwrap();
1585 let path = tmp.path().join("state/alerts-tui.jsonl");
1586 let degraded = Alert {
1587 severity: Severity::Critical,
1588 text: "md0 (raid1) degraded".to_owned(),
1589 };
1590 let full = Alert {
1591 severity: Severity::Warning,
1592 text: "/srv 92% full".to_owned(),
1593 };
1594 let mut log = AlertLog::persistent(path.clone());
1595 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
1596 log.update("nas", std::slice::from_ref(°raded), 2_000);
1597 assert_eq!(log.events().count(), 3);
1598
1599 let mut log = AlertLog::persistent(path.clone());
1602 assert_eq!(log.events().count(), 3);
1603 assert_eq!(log.unread(), (0, None));
1604 assert!(
1605 log.update("nas", std::slice::from_ref(°raded), 3_000)
1606 .is_empty()
1607 );
1608 assert_eq!(log.events().count(), 3);
1609 let changes = log.update("nas", &[], 4_000);
1610 assert_eq!(changes.len(), 1);
1611 assert_eq!(changes[0].alert, degraded);
1612 let last = log.events().last().unwrap();
1613 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
1614 assert_eq!(AlertLog::persistent(path).events().count(), 4);
1615 }
1616
1617 #[test]
1618 fn failed_units_warn_unless_ignored() {
1619 let snapshot = Snapshot {
1620 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
1621 ..Default::default()
1622 };
1623 let limits = AlertsConfig {
1624 ignored_units: vec!["wait-online.service".to_owned()],
1625 ..Default::default()
1626 };
1627 let alerts = alerts_with(&snapshot, &limits);
1628 assert_eq!(alerts.len(), 1);
1629 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
1630 assert_eq!(alerts[0].severity, Severity::Warning);
1631 }
1632
1633 #[test]
1634 fn version_notes_only_for_other_versions() {
1635 let mut snapshot = Snapshot::default();
1636 assert_eq!(version_note(&snapshot), None);
1637 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1638 assert_eq!(version_note(&snapshot), None);
1639 snapshot.host.rigtop_version = "0.1.0".to_owned();
1640 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1641 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1642 }
1643
1644 #[test]
1645 fn alert_log_records_alerts_appearing_and_clearing() {
1646 let alert = |severity, text: &str| Alert {
1647 severity,
1648 text: text.to_owned(),
1649 };
1650 let full = alert(Severity::Warning, "/data 91% full");
1651 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1652 let mut log = AlertLog::default();
1653 log.update("nas", std::slice::from_ref(&full), 1_000);
1654 log.update(
1656 "nas",
1657 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1658 2_000,
1659 );
1660 log.update("zefat", &[], 2_000);
1661 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1662 log.mark_read();
1663 log.update("nas", std::slice::from_ref(&errors), 3_000);
1664 assert_eq!(log.unread(), (0, None));
1665 let summary: Vec<(u64, AlertChange, &str)> = log
1666 .events()
1667 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1668 .collect();
1669 assert_eq!(
1670 summary,
1671 [
1672 (1_000, AlertChange::Raised, "/data 91% full"),
1673 (
1674 2_000,
1675 AlertChange::Raised,
1676 "btrfs /: 2 I/O errors (nvme1n1)"
1677 ),
1678 (3_000, AlertChange::Cleared, "/data 92% full"),
1679 ]
1680 );
1681 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1682 log.update(
1683 "flappy",
1684 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1685 i,
1686 );
1687 }
1688 assert_eq!(log.events().count(), ALERT_LOG_LEN);
1689 }
1690
1691 #[test]
1692 fn alert_thresholds_come_from_the_configuration() {
1693 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1694 let temp = |label: &str, celsius| Temperature {
1695 label: label.to_owned(),
1696 celsius,
1697 ..Default::default()
1698 };
1699 let mut snapshot = Snapshot {
1700 filesystems: vec![FilesystemUsage {
1701 mount_points: vec!["/data".to_owned()],
1702 total_bytes: 100,
1703 available_bytes: 8,
1704 ..Default::default()
1705 }],
1706 gpus: vec![GpuStats {
1707 index: 0,
1708 temperature_c: Some(80.0),
1709 ..Default::default()
1710 }],
1711 smart: vec![SmartDevice {
1712 device: "nvme0".to_owned(),
1713 temperature_c: Some(72.0),
1714 ..Default::default()
1715 }],
1716 sensors: vec![
1718 SensorChip {
1719 key: "nvme:nvme0".to_owned(),
1720 name: "nvme".to_owned(),
1721 temperatures: vec![temp("Composite", 71.0)],
1722 ..Default::default()
1723 },
1724 SensorChip {
1725 key: "nvme:nvme1".to_owned(),
1726 name: "nvme".to_owned(),
1727 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1728 ..Default::default()
1729 },
1730 ],
1731 ..Default::default()
1732 };
1733 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1734 let texts_of = |snapshot: &Snapshot| -> Vec<String> {
1735 alerts_with(snapshot, &AlertsConfig::default())
1736 .into_iter()
1737 .map(|a| a.text)
1738 .collect()
1739 };
1740 let texts = |limits: &AlertsConfig| -> Vec<String> {
1741 alerts_with(&snapshot, limits)
1742 .into_iter()
1743 .map(|a| a.text)
1744 .collect()
1745 };
1746 assert_eq!(
1747 texts(&AlertsConfig::default()),
1748 [
1749 "/data 92% full",
1750 "CPU at 97°C",
1751 "nvme0 at 72°C",
1752 "nvme1 at 74°C"
1753 ]
1754 );
1755 let custom = AlertsConfig {
1756 filesystem_warning_percent: 0.0,
1757 cpu_warning_celsius: 0.0,
1758 gpu_warning_celsius: 75.0,
1759 drive_warning_celsius: 73.0,
1760 ..Default::default()
1761 };
1762 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1763
1764 let mut gpu = snapshot.gpus[0].clone();
1766 gpu.throttle_reasons = vec!["power cap".to_owned()];
1767 let mut capped = snapshot.clone();
1768 capped.gpus = vec![gpu.clone()];
1769 assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
1770 gpu.throttle_reasons.push("hw thermal".to_owned());
1771 gpu.ecc = Some(EccErrors {
1772 corrected: 5,
1773 uncorrected: 2,
1774 });
1775 capped.gpus = vec![gpu];
1776 let alerts = alerts_with(&capped, &AlertsConfig::default());
1777 assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
1778 assert_eq!(alerts[0].severity, Severity::Critical);
1779 assert!(
1780 alerts
1781 .iter()
1782 .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
1783 );
1784 let critical = AlertsConfig {
1785 filesystem_critical_percent: 90.0,
1786 ..custom
1787 };
1788 assert_eq!(
1789 alerts_with(&snapshot, &critical)[0],
1790 Alert {
1791 severity: Severity::Critical,
1792 text: "/data 92% full".to_owned()
1793 }
1794 );
1795 }
1796
1797 #[test]
1798 fn interface_helpers() {
1799 assert!(is_virtual_interface("veth5a74356"));
1800 assert!(!is_virtual_interface("enp9s0"));
1801 let snapshot = Snapshot {
1802 networks: vec![
1803 NetworkStats {
1804 name: "docker0".to_owned(),
1805 rx_total_bytes: 1 << 40,
1806 ..Default::default()
1807 },
1808 NetworkStats {
1809 name: "enp9s0".to_owned(),
1810 rx_total_bytes: 1 << 30,
1811 ..Default::default()
1812 },
1813 ],
1814 ..Default::default()
1815 };
1816 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1817 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1818 }
1819}