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, IntegratedGpu};
16use crate::format;
17use crate::snapshot::{GpuStats, OomKill, PcieLink, Pressure, ProcessInfo, Snapshot};
18use crate::source::ProcessSignal;
19
20pub const HISTORY_LEN: usize = 4096;
24
25const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
28
29pub const COARSE_FACTOR: usize = 60;
32pub const COARSE_LEN: usize = 1440;
34
35#[derive(Debug, Default, Clone)]
38pub struct History {
39 samples: VecDeque<f64>,
40 coarse: VecDeque<f64>,
41 pending: (f64, usize),
43}
44
45impl History {
46 pub fn push(&mut self, value: f64) {
48 if self.samples.len() == HISTORY_LEN {
49 self.samples.pop_front();
50 }
51 self.samples.push_back(value);
52 self.pending.0 += value;
53 self.pending.1 += 1;
54 if self.pending.1 == COARSE_FACTOR {
55 if self.coarse.len() == COARSE_LEN {
56 self.coarse.pop_front();
57 }
58 self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
59 self.pending = (0.0, 0);
60 }
61 }
62
63 pub fn last(&self, n: usize) -> Vec<f64> {
65 let skip = self.samples.len().saturating_sub(n);
66 self.samples.iter().skip(skip).copied().collect()
67 }
68
69 pub fn max_of_last(&self, n: usize) -> f64 {
71 self.last(n).into_iter().fold(0.0, f64::max)
72 }
73
74 pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
81 let GraphSpan::Secs(secs) = span else {
82 return self.last(columns);
83 };
84 let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
85 if wanted <= HISTORY_LEN {
86 spread(&self.last(wanted), wanted, columns)
87 } else {
88 let wanted = wanted.div_ceil(COARSE_FACTOR);
90 let skip = self.coarse.len().saturating_sub(wanted);
91 let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
92 spread(&points, wanted, columns)
93 }
94 }
95}
96
97fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
102 if columns == 0 || samples.is_empty() {
103 return Vec::new();
104 }
105 let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
107 let shown = shown.clamp(1, columns);
108 let per_column = samples.len() as f64 / shown as f64;
109 if per_column < 1.0 {
110 let last = samples.len() - 1;
112 return (0..shown)
113 .map(|i| {
114 let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
115 let (below, frac) = (at.floor() as usize, at.fract());
116 let above = (below + 1).min(last);
117 samples[below] * (1.0 - frac) + samples[above] * frac
118 })
119 .collect();
120 }
121 (0..shown)
122 .map(|i| {
123 let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
124 let start = ((i as f64 * per_column) as usize).min(end - 1);
125 let part = &samples[start..end];
126 part.iter().sum::<f64>() / part.len() as f64
127 })
128 .collect()
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum GraphSpan {
134 #[default]
137 Fit,
138 Secs(u64),
140}
141
142impl GraphSpan {
143 pub const CHOICES: [GraphSpan; 7] = [
145 GraphSpan::Fit,
146 GraphSpan::Secs(60),
147 GraphSpan::Secs(5 * 60),
148 GraphSpan::Secs(15 * 60),
149 GraphSpan::Secs(60 * 60),
150 GraphSpan::Secs(6 * 60 * 60),
151 GraphSpan::Secs(24 * 60 * 60),
152 ];
153
154 pub fn next(self) -> Self {
156 let index = Self::CHOICES
157 .iter()
158 .position(|c| *c == self)
159 .map_or(0, |i| i + 1);
160 Self::CHOICES[index % Self::CHOICES.len()]
161 }
162
163 pub fn label(self) -> String {
165 match self {
166 GraphSpan::Fit => "fit".to_owned(),
167 GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
168 GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
169 GraphSpan::Secs(secs) => format!("{secs}s"),
170 }
171 }
172
173 pub fn as_secs(self) -> u64 {
175 match self {
176 GraphSpan::Fit => 0,
177 GraphSpan::Secs(secs) => secs,
178 }
179 }
180
181 pub fn from_secs(secs: u64) -> Self {
183 if secs == 0 {
184 GraphSpan::Fit
185 } else {
186 GraphSpan::Secs(secs)
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum SortKey {
194 Pid,
195 Name,
196 Threads,
197 User,
198 Container,
200 Memory,
201 Cpu,
202 Disk,
204 Gpu,
205 Vram,
206}
207
208impl SortKey {
209 pub const ALL: [SortKey; 10] = [
211 SortKey::Pid,
212 SortKey::Name,
213 SortKey::Threads,
214 SortKey::User,
215 SortKey::Container,
216 SortKey::Memory,
217 SortKey::Cpu,
218 SortKey::Disk,
219 SortKey::Gpu,
220 SortKey::Vram,
221 ];
222
223 pub fn descending_by_default(self) -> bool {
225 !matches!(
226 self,
227 SortKey::Pid | SortKey::Name | SortKey::User | SortKey::Container
228 )
229 }
230}
231
232#[derive(Debug, Clone)]
234pub struct ProcessRow<'a> {
235 pub info: &'a ProcessInfo,
236 pub gpu_percent: Option<f32>,
238 pub vram_bytes: Option<u64>,
240 pub tree_prefix: String,
243}
244
245#[derive(Debug, Default, Clone)]
247pub struct MetricHistory {
248 pub cpu: History,
250 pub memory: History,
252 pub gpus: Vec<History>,
254 pub net_rx: History,
256 pub net_tx: History,
258 pub net_interface: Option<String>,
260}
261
262impl MetricHistory {
263 pub fn record(&mut self, snapshot: &Snapshot) {
265 self.cpu.push(snapshot.cpu.total_percent as f64);
266 let memory = &snapshot.memory;
267 if memory.total_bytes > 0 {
268 self.memory
269 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
270 }
271 self.gpus.resize_with(snapshot.gpus.len(), History::default);
272 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
273 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
274 }
275 if self.net_interface.is_none() {
276 self.net_interface = default_interface(snapshot);
277 }
278 let (rx, tx) = self
279 .net_interface
280 .as_deref()
281 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
282 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
283 .unwrap_or((0.0, 0.0));
284 self.net_rx.push(rx);
285 self.net_tx.push(tx);
286 }
287
288 pub fn select_interface(&mut self, name: &str) {
290 if self.net_interface.as_deref() != Some(name) {
291 self.net_interface = Some(name.to_owned());
292 self.net_rx = History::default();
294 self.net_tx = History::default();
295 }
296 }
297
298 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
301 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
302 if names.is_empty() {
303 return None;
304 }
305 let current = self
306 .net_interface
307 .as_deref()
308 .and_then(|name| names.iter().position(|n| *n == name));
309 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
310 self.select_interface(&next);
311 Some(next)
312 }
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
317#[serde(rename_all = "lowercase")]
318pub enum Severity {
319 Warning,
321 Critical,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct Alert {
328 pub severity: Severity,
329 pub text: String,
330}
331
332pub fn alert_key(alert: &Alert) -> String {
336 let mut key = format!("{:?}:", alert.severity);
337 let mut in_number = false;
338 for c in alert.text.chars() {
339 if c.is_ascii_digit() || (in_number && c == '.') {
340 if !in_number {
341 key.push('#');
342 }
343 in_number = true;
344 } else {
345 key.push(c);
346 in_number = false;
347 }
348 }
349 key
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "lowercase")]
356pub enum AlertChange {
357 Raised,
358 Cleared,
359 Notice,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct AlertEvent {
367 pub at_ms: u64,
369 pub host: String,
370 pub change: AlertChange,
371 pub alert: Alert,
373}
374
375const ALERT_LOG_LEN: usize = 500;
377
378#[derive(Debug, Default)]
381pub struct AlertLog {
382 events: VecDeque<AlertEvent>,
384 active: HashMap<String, Vec<Alert>>,
386 unread: (usize, Option<Severity>),
388 file: Option<PathBuf>,
390}
391
392pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
396 dirs::state_dir()
397 .or_else(dirs::data_local_dir)
398 .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
399}
400
401impl AlertLog {
402 pub fn persistent(path: PathBuf) -> Self {
407 let mut log = Self::default();
408 let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
409 .map(|text| {
410 text.lines()
411 .filter_map(|line| serde_json::from_str(line).ok())
412 .collect()
413 })
414 .unwrap_or_default();
415 let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
416 for event in loaded.iter().filter(|e| e.change != AlertChange::Notice) {
417 let active = log.active.entry(event.host.clone()).or_default();
418 let key = alert_key(&event.alert);
419 active.retain(|a| alert_key(a) != key);
420 if event.change == AlertChange::Raised {
421 active.push(event.alert.clone());
422 }
423 }
424 log.events = loaded.into_iter().skip(keep).collect();
425 if keep > ALERT_LOG_LEN {
427 let text: String = log
428 .events
429 .iter()
430 .filter_map(|e| serde_json::to_string(e).ok())
431 .map(|line| line + "\n")
432 .collect();
433 let tmp = path.with_extension("jsonl.tmp");
434 let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
435 }
436 log.file = Some(path);
437 log
438 }
439
440 fn save(&self, event: &AlertEvent) {
442 let Some(path) = &self.file else {
443 return;
444 };
445 let Ok(line) = serde_json::to_string(event) else {
446 return;
447 };
448 if let Some(dir) = path.parent() {
449 let _ = fs::create_dir_all(dir);
450 }
451 let _ = OpenOptions::new()
453 .create(true)
454 .append(true)
455 .open(path)
456 .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
457 }
458
459 pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) -> Vec<AlertEvent> {
462 let previous = self.active.remove(host).unwrap_or_default();
463 let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
464 let (before, now) = (keys(&previous), keys(alerts));
465 let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
466 let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
467 let mut changes: Vec<(AlertChange, &Alert)> = raised
468 .map(|a| (AlertChange::Raised, a))
469 .chain(cleared.map(|a| (AlertChange::Cleared, a)))
470 .collect();
471 changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
473 let mut new_events = Vec::with_capacity(changes.len());
474 for (change, alert) in changes {
475 new_events.push(self.push(AlertEvent {
476 at_ms,
477 host: host.to_owned(),
478 change,
479 alert: alert.clone(),
480 }));
481 }
482 self.active.insert(host.to_owned(), alerts.to_vec());
483 new_events
484 }
485
486 pub fn update_snoozed(
491 &mut self,
492 host: &str,
493 alerts: &[Alert],
494 snoozed: &[Alert],
495 at_ms: u64,
496 ) -> Vec<AlertEvent> {
497 if let Some(previous) = self.active.get_mut(host) {
498 let quiet: HashSet<String> = snoozed.iter().map(alert_key).collect();
499 previous.retain(|a| !quiet.contains(&alert_key(a)));
500 }
501 self.update(host, alerts, at_ms)
502 }
503
504 pub fn record(&mut self, host: &str, alert: Alert, at_ms: u64) -> AlertEvent {
507 self.push(AlertEvent {
508 at_ms,
509 host: host.to_owned(),
510 change: AlertChange::Notice,
511 alert,
512 })
513 }
514
515 fn push(&mut self, event: AlertEvent) -> AlertEvent {
518 if event.change != AlertChange::Cleared {
519 self.unread.0 += 1;
520 self.unread.1 = self.unread.1.max(Some(event.alert.severity));
521 }
522 if self.events.len() == ALERT_LOG_LEN {
523 self.events.pop_front();
524 }
525 self.save(&event);
526 self.events.push_back(event.clone());
527 event
528 }
529
530 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
532 self.events.iter()
533 }
534
535 pub fn unread(&self) -> (usize, Option<Severity>) {
537 self.unread
538 }
539
540 pub fn mark_read(&mut self) {
542 self.unread = (0, None);
543 }
544}
545
546pub fn event_time(at_ms: u64, now: SystemTime) -> String {
549 let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
550 let today: DateTime<Local> = now.into();
551 let days = (today.date_naive() - at.date_naive()).num_days();
552 if days == 0 {
553 at.format("%H:%M:%S").to_string()
554 } else if (1..6).contains(&days) {
555 at.format("%a %H:%M").to_string()
556 } else {
557 at.format("%b %d %H:%M").to_string()
558 }
559}
560
561pub fn process_matches(process: &ProcessInfo, pattern: &str) -> bool {
564 let pattern = pattern.trim();
565 !pattern.is_empty()
566 && (process.name == pattern
567 || process
568 .command
569 .split_whitespace()
570 .any(|word| word == pattern || word.rsplit(['/', '\\']).next() == Some(pattern)))
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct WatchedProcess {
576 pub pid: u32,
577 pub name: String,
580 pub label: Option<String>,
582 pub started_ms: u64,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum WatchState {
590 Running,
591 Exited,
592 Unknown,
595}
596
597#[derive(Debug, Clone, Default)]
602pub struct ExitWatch {
603 processes: Vec<WatchedProcess>,
604}
605
606impl ExitWatch {
607 pub fn contains(&self, pid: u32) -> bool {
609 self.processes.iter().any(|w| w.pid == pid)
610 }
611
612 pub fn toggle(&mut self, process: &ProcessInfo, now_ms: u64) -> bool {
614 if self.contains(process.pid) {
615 self.processes.retain(|w| w.pid != process.pid);
616 return false;
617 }
618 self.watch_as(process, None, now_ms);
619 true
620 }
621
622 pub fn watch_as(&mut self, process: &ProcessInfo, label: Option<&str>, now_ms: u64) -> bool {
625 if self.contains(process.pid) {
626 return false;
627 }
628 self.processes.push(WatchedProcess {
629 pid: process.pid,
630 name: process.name.clone(),
631 label: label.map(str::to_owned),
632 started_ms: now_ms.saturating_sub(process.run_time_secs * 1000),
633 });
634 true
635 }
636
637 pub fn pids(&self) -> Vec<u32> {
639 self.processes.iter().map(|w| w.pid).collect()
640 }
641
642 pub fn state(&self, snapshot: &Snapshot, pid: u32) -> WatchState {
644 match self.processes.iter().find(|w| w.pid == pid) {
645 Some(watched) => watch_state(snapshot, watched),
646 None => WatchState::Unknown,
647 }
648 }
649
650 pub fn check(&mut self, snapshot: &Snapshot, now_ms: u64) -> Vec<Alert> {
653 let mut exited = Vec::new();
654 self.processes.retain(|watched| {
655 if watch_state(snapshot, watched) != WatchState::Exited {
656 return true;
657 }
658 let ran = now_ms.saturating_sub(watched.started_ms) / 1000;
659 exited.push(Alert {
660 severity: Severity::Warning,
661 text: format!(
662 "{} (pid {}) exited after {}",
663 watched.label.as_deref().unwrap_or(&watched.name),
664 watched.pid,
665 format::duration(ran)
666 ),
667 });
668 false
669 });
670 exited
671 }
672}
673
674fn watch_state(snapshot: &Snapshot, watched: &WatchedProcess) -> WatchState {
677 let reported = snapshot.watched_pids.contains(&watched.pid);
678 match snapshot.processes.iter().find(|p| p.pid == watched.pid) {
679 Some(process) if process.name != watched.name => WatchState::Exited,
681 Some(_) if reported => WatchState::Running,
682 None if reported => WatchState::Exited,
683 _ => WatchState::Unknown,
684 }
685}
686
687pub fn process_details(
691 snapshot: &Snapshot,
692 pid: u32,
693 now: SystemTime,
694) -> Option<Vec<(&'static str, String)>> {
695 let by_pid: HashMap<u32, &ProcessInfo> =
696 snapshot.processes.iter().map(|p| (p.pid, p)).collect();
697 let process = by_pid.get(&pid)?;
698 let mut rows = vec![("Program", format!("{} (pid {})", process.name, process.pid))];
699
700 let mut chain = Vec::new();
703 let mut parent = process.parent_pid;
704 while let Some(ppid) = parent.filter(|_| chain.len() < 32) {
705 match by_pid.get(&ppid) {
706 Some(p) => {
707 chain.push(format!("{} ({})", p.name, p.pid));
708 parent = p.parent_pid.filter(|&next| next != p.pid);
709 }
710 None => {
711 chain.push(ppid.to_string());
712 break;
713 }
714 }
715 }
716 if !chain.is_empty() {
717 chain.reverse();
718 rows.push(("Parents", chain.join(" › ")));
719 }
720 let children = snapshot
721 .processes
722 .iter()
723 .filter(|p| p.parent_pid == Some(pid))
724 .count();
725 if children > 0 {
726 rows.push(("Children", children.to_string()));
727 }
728 rows.push(("User", process.user.clone()));
729 if let Some(container) = &process.container {
730 rows.push(("Container", container.clone()));
731 }
732 if !process.state.is_empty() {
733 rows.push(("State", process.state.clone()));
734 }
735 if let Some(nice) = process.nice {
736 rows.push(("Nice", nice.to_string()));
737 }
738 let started = now - Duration::from_secs(process.run_time_secs);
739 rows.push(("Running", since_note(started, now)));
740 rows.push(("CPU", format!("{:.1}%", process.cpu_percent)));
741 rows.push(("Memory", format::bytes(process.memory_bytes)));
742 if let Some(threads) = process.threads {
743 rows.push(("Threads", threads.to_string()));
744 }
745 rows.push((
746 "Disk",
747 format!(
748 "read {}, write {}",
749 format::rate(process.read_bytes_per_sec),
750 format::rate(process.write_bytes_per_sec)
751 ),
752 ));
753 for gpu in &snapshot.gpus {
754 if let Some(usage) = gpu.processes.iter().find(|p| p.pid == pid) {
755 let mut text = format!("GPU {}", gpu.index);
756 if let Some(sm) = usage.sm_percent {
757 text.push_str(&format!(" {sm:.0}%"));
758 }
759 if let Some(memory) = usage.memory_bytes {
760 text.push_str(&format!(", {} VRAM", format::bytes(memory)));
761 }
762 rows.push(("GPU", text));
763 }
764 }
765 let command = if process.command.is_empty() {
766 format!("[{}]", process.name)
767 } else {
768 process.command.clone()
769 };
770 rows.push(("Command", command));
771 Some(rows)
772}
773
774pub fn attention_rank(alerts: &[Alert], unreachable: bool, stale: bool) -> u8 {
778 let worst = alerts.iter().map(|a| a.severity).max();
779 if unreachable || worst == Some(Severity::Critical) {
780 0
781 } else if stale || worst == Some(Severity::Warning) {
782 1
783 } else {
784 2
785 }
786}
787
788pub fn is_secondary_gpu(snapshot: &Snapshot, gpu: &GpuStats) -> bool {
792 gpu.integrated && snapshot.gpus.iter().any(|g| !g.integrated)
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub enum GpuRow {
798 Full,
800 Compact,
802}
803
804pub fn panel_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> Vec<(usize, &GpuStats, GpuRow)> {
808 snapshot
809 .gpus
810 .iter()
811 .enumerate()
812 .filter_map(|(i, gpu)| {
813 let row = match (is_secondary_gpu(snapshot, gpu), mode) {
814 (false, _) | (true, IntegratedGpu::Full) => GpuRow::Full,
815 (true, IntegratedGpu::Compact) => GpuRow::Compact,
816 (true, IntegratedGpu::Hide) => return None,
817 };
818 Some((i, gpu, row))
819 })
820 .collect()
821}
822
823pub fn card_gpus(snapshot: &Snapshot, mode: IntegratedGpu) -> impl Iterator<Item = &GpuStats> {
828 snapshot
829 .gpus
830 .iter()
831 .filter(|g| g.utilization_percent.is_some() || g.memory_used_bytes.is_some())
832 .filter(move |g| mode == IntegratedGpu::Full || !is_secondary_gpu(snapshot, g))
833}
834
835pub fn gpu_detail(gpu: &GpuStats) -> String {
838 let mut text = gpu.name.clone();
839 match (gpu.power_w, gpu.power_limit_w) {
840 (Some(w), Some(limit)) => text.push_str(&format!(" · {w:.0}/{limit:.0}W")),
841 (Some(w), None) => text.push_str(&format!(" · {w:.0}W")),
842 _ => {}
843 }
844 text
845}
846
847pub fn gpu_notes(gpu: &GpuStats) -> Vec<(String, Option<Severity>)> {
851 let mut notes = Vec::new();
852 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
853 if !slowdown.is_empty() {
854 notes.push((
855 format!("slowed: {}", slowdown.join(", ")),
856 Some(Severity::Warning),
857 ));
858 } else if !gpu.throttle_reasons.is_empty() {
859 notes.push(("power cap".to_owned(), None));
860 }
861 if let Some(ecc) = gpu.ecc {
862 if ecc.uncorrected > 0 {
863 notes.push((
864 format!("ECC {} uncorrected", ecc.uncorrected),
865 Some(Severity::Critical),
866 ));
867 } else if ecc.corrected > 0 {
868 notes.push((format!("ECC {} corrected", ecc.corrected), None));
869 }
870 }
871 notes
872}
873
874pub fn pcie_text(link: &PcieLink) -> String {
876 let now = format!("PCIe gen {} x{}", link.generation, link.width);
877 if (link.generation, link.width) == (link.max_generation, link.max_width) {
878 now
879 } else {
880 format!(
881 "{now} (up to gen {} x{}; the link slows down when idle)",
882 link.max_generation, link.max_width
883 )
884 }
885}
886
887pub fn since_note(since: SystemTime, now: SystemTime) -> String {
890 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
891 let span = match secs {
892 0..60 => format!("{secs}s"),
893 60..3_600 => format!("{}m", secs / 60),
894 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
895 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
896 };
897 let at: DateTime<Local> = since.into();
898 let today: DateTime<Local> = now.into();
899 let clock = if at.date_naive() == today.date_naive() {
900 at.format("%H:%M")
901 } else if secs < 6 * 86_400 {
902 at.format("%a %H:%M")
903 } else {
904 at.format("%b %d %H:%M")
905 };
906 format!("{span} (since {clock})")
907}
908
909pub fn version_note(snapshot: &Snapshot) -> Option<String> {
912 let version = &snapshot.host.rigtop_version;
913 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
914 .then(|| format!("agent {version}"))
915}
916
917pub fn version_hint(snapshot: &Snapshot) -> String {
919 format!(
920 "this host runs rigtop {}, this machine {}: install the same version on both \
921 for every reading",
922 snapshot.host.rigtop_version,
923 env!("CARGO_PKG_VERSION")
924 )
925}
926
927pub const REBOOT_NOTE: &str = "reboot needed";
930
931pub fn reboot_hint(reason: &str) -> String {
933 let mut hint = "a reboot is needed to finish an update".to_owned();
934 if !reason.is_empty() {
935 hint.push_str(&format!(" ({reason})"));
936 }
937 hint
938}
939
940pub fn latency_severity(ms: f32) -> Option<Severity> {
943 if ms >= 500.0 {
944 Some(Severity::Critical)
945 } else if ms >= 100.0 {
946 Some(Severity::Warning)
947 } else {
948 None
949 }
950}
951
952pub const SLOW_LINK_MS: f32 = 500.0;
954
955pub fn link_rtt_text(snapshot: &Snapshot) -> Option<(String, bool)> {
958 let ms = snapshot.link_rtt_ms?;
959 Some((format::latency(ms), ms >= SLOW_LINK_MS))
960}
961
962pub const LINK_RTT_HINT: &str = "round trip to the agent over SSH (every 5 s)";
964
965fn scan_percent(scan: &str) -> Option<String> {
967 let done = scan.find("% done")?;
968 let start = scan[..done].rfind(' ').map_or(0, |i| i + 1);
969 let value: f32 = scan[start..done].parse().ok()?;
970 Some(format!("{value:.1}%"))
971}
972
973pub fn zfs_scan_summary(scan: &str) -> (String, Option<Severity>) {
977 let date = scan.rsplit_once(" on ").map(|(_, when)| {
979 let words: Vec<&str> = when.split_whitespace().collect();
980 match words.as_slice() {
981 [_, month, day, ..] => format!("{month} {day}"),
982 _ => when.to_owned(),
983 }
984 });
985 let errors = scan
986 .split_once(" with ")
987 .and_then(|(_, rest)| rest.split_whitespace().next()?.parse::<u64>().ok())
988 .unwrap_or(0);
989 let repaired = scan
990 .split_once("repaired ")
991 .and_then(|(_, rest)| rest.split_whitespace().next())
992 .filter(|amount| *amount != "0B");
993 if scan.starts_with("none requested") {
994 return ("no scrub yet".to_owned(), Some(Severity::Warning));
995 }
996 if scan.contains("in progress") || scan.starts_with("scrub paused") {
997 let what = scan.split_whitespace().next().unwrap_or("scan");
998 let state = if scan.starts_with("scrub paused") {
999 "paused"
1000 } else {
1001 "running"
1002 };
1003 let done = scan_percent(scan).map_or(String::new(), |p| format!(" {p}"));
1004 let severity = (what == "resilver").then_some(Severity::Warning);
1005 return (format!("{what} {state}{done}"), severity);
1006 }
1007 let what = if scan.starts_with("resilvered") {
1008 "resilver"
1009 } else {
1010 "scrub"
1011 };
1012 let mut text = match &date {
1013 Some(date) => format!("{what} {date}"),
1014 None => what.to_owned(),
1015 };
1016 let severity = if errors > 0 {
1017 text.push_str(&format!(", {errors} errors"));
1018 Some(Severity::Critical)
1019 } else if let (Some(amount), "scrub") = (repaired, what) {
1020 text.push_str(&format!(", repaired {amount}"));
1021 Some(Severity::Warning)
1022 } else {
1023 text.push_str(", ok");
1024 None
1025 };
1026 (text, severity)
1027}
1028
1029pub const PRESSURE_LABEL: &str = "Stall";
1031
1032#[derive(Debug, Clone, PartialEq)]
1035pub struct PressureItem {
1036 pub name: &'static str,
1038 pub value: String,
1040 pub warn: bool,
1042}
1043
1044pub fn pressure_items(pressure: &Pressure, limits: &AlertsConfig) -> Vec<PressureItem> {
1047 let over = |value: f32, limit: f64| limit > 0.0 && value as f64 >= limit;
1048 let item = |name, value: f32, warn| PressureItem {
1049 name,
1050 value: format!("{value:.0}%"),
1051 warn,
1052 };
1053 let items = [
1054 item("cpu", pressure.cpu.some_avg10, false),
1055 item(
1056 "mem",
1057 pressure.memory.some_avg10,
1058 over(
1059 pressure.memory.some_avg60,
1060 limits.memory_pressure_warning_percent,
1061 ),
1062 ),
1063 item(
1064 "io",
1065 pressure.io.some_avg10,
1066 over(pressure.io.full_avg60, limits.io_pressure_warning_percent),
1067 ),
1068 ];
1069 items
1070 .into_iter()
1071 .filter(|item| item.warn || item.value != "0%")
1072 .collect()
1073}
1074
1075pub fn pressure_details(pressure: &Pressure) -> String {
1078 let line = |name: &str, levels: &crate::snapshot::PressureLevels| {
1079 format!(
1080 "{name}: some {:.0}% / {:.0}%, full {:.0}% / {:.0}%",
1081 levels.some_avg10, levels.some_avg60, levels.full_avg10, levels.full_avg60
1082 )
1083 };
1084 [
1085 "Share of time tasks waited (10 s / 60 s averages); some: at least one \
1086 task, full: all of them"
1087 .to_owned(),
1088 line("cpu", &pressure.cpu),
1089 line("memory", &pressure.memory),
1090 line("io", &pressure.io),
1091 ]
1092 .join("\n")
1093}
1094
1095pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
1097 alerts_with(snapshot, &AlertsConfig::default())
1098}
1099
1100pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
1105 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
1107 let mut alerts = Vec::new();
1108 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
1109 for fs in &snapshot.raid.btrfs {
1110 let name = fs
1111 .mount_points
1112 .first()
1113 .map_or(fs.fsid.as_str(), String::as_str);
1114 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
1115 if missing > 0 {
1116 add(
1117 Severity::Critical,
1118 format!("btrfs {name}: {missing} device(s) missing"),
1119 );
1120 }
1121 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
1122 if errors > 0 {
1123 let devices: Vec<String> = fs
1125 .device_stats
1126 .iter()
1127 .filter(|d| d.total_errors() > 0)
1128 .map(|d| d.label())
1129 .collect();
1130 add(
1131 Severity::Critical,
1132 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
1133 );
1134 }
1135 if let Some(scrub) = &fs.scrub {
1136 if scrub.uncorrectable > 0 {
1137 add(
1138 Severity::Critical,
1139 format!(
1140 "btrfs {name}: scrub found {} uncorrectable errors",
1141 scrub.uncorrectable
1142 ),
1143 );
1144 } else if scrub.errors > 0 {
1145 add(
1146 Severity::Warning,
1147 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
1148 );
1149 }
1150 }
1151 }
1152 for md in &snapshot.raid.md {
1153 if md.active && md.is_degraded() {
1154 add(
1155 Severity::Critical,
1156 format!("{} ({}) degraded", md.name, md.level),
1157 );
1158 } else if !md.active {
1159 add(Severity::Warning, format!("{} inactive", md.name));
1160 }
1161 if let Some(sync) = &md.sync {
1162 add(
1163 Severity::Warning,
1164 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
1165 );
1166 }
1167 }
1168 for pool in &snapshot.raid.zfs {
1169 if pool.is_failing() {
1170 add(
1171 Severity::Critical,
1172 format!("zfs {} {}", pool.name, pool.state),
1173 );
1174 } else if !pool.is_online() {
1175 add(
1176 Severity::Warning,
1177 format!("zfs {} {}", pool.name, pool.state),
1178 );
1179 }
1180 if let Some(errors) = pool.data_errors.filter(|e| *e > 0) {
1181 add(
1182 Severity::Critical,
1183 format!("zfs {}: {errors} data errors", pool.name),
1184 );
1185 }
1186 for device in pool.troubled_devices() {
1187 let text = if device.is_unhealthy() {
1188 format!("zfs {}: {} {}", pool.name, device.name, device.state)
1189 } else {
1190 format!(
1191 "zfs {}: {} has {} errors",
1192 pool.name,
1193 device.name,
1194 device.errors()
1195 )
1196 };
1197 add(Severity::Warning, text);
1198 }
1199 if let Some(scan) = &pool.scan
1200 && scan.starts_with("resilver in progress")
1201 {
1202 let done = scan_percent(scan).unwrap_or_default();
1203 add(
1204 Severity::Warning,
1205 format!("zfs {} resilver {done}", pool.name),
1206 );
1207 }
1208 }
1209 for unit in &snapshot.failed_units {
1210 if !limits.ignored_units.contains(unit) {
1211 add(Severity::Warning, format!("systemd: {unit} failed"));
1212 }
1213 }
1214 for smart in &snapshot.smart {
1215 if let Some(error) = &smart.error {
1216 add(
1217 Severity::Warning,
1218 format!("{}: SMART unreadable ({error})", smart.device),
1219 );
1220 } else if smart.passed == Some(false) {
1221 add(
1222 Severity::Critical,
1223 format!("{}: SMART failed", smart.device),
1224 );
1225 } else if smart.needs_attention() {
1226 add(
1227 Severity::Critical,
1228 format!("{}: SMART warning", smart.device),
1229 );
1230 }
1231 }
1232 for fs in &snapshot.filesystems {
1233 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
1234 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
1235 let name = fs
1236 .mount_points
1237 .first()
1238 .map_or(fs.device.as_str(), String::as_str);
1239 if reached(percent, limits.filesystem_critical_percent) {
1240 add(Severity::Critical, format!("{name} {percent:.0}% full"));
1241 } else if reached(percent, limits.filesystem_warning_percent) {
1242 add(Severity::Warning, format!("{name} {percent:.0}% full"));
1243 }
1244 let soon = limits.filesystem_full_warning_hours * 3600.0;
1245 if let Some(secs) = fs.full_in_secs
1246 && soon > 0.0
1247 && secs as f64 <= soon
1248 {
1249 add(
1250 Severity::Warning,
1251 format!("{name} full in ~{}", format::duration(secs)),
1252 );
1253 }
1254 }
1255 if let Some(text) = oom_alert(&snapshot.memory.oom_kills) {
1256 add(Severity::Critical, text);
1257 }
1258 let memory = &snapshot.memory;
1259 let percent = memory.used_bytes as f64 / memory.total_bytes.max(1) as f64 * 100.0;
1260 if memory.total_bytes > 0 && reached(percent, limits.memory_warning_percent) {
1261 add(Severity::Warning, format!("memory {percent:.0}% used"));
1262 }
1263 if let Some(pressure) = &snapshot.pressure {
1264 let memory = pressure.memory.some_avg60 as f64;
1265 if reached(memory, limits.memory_pressure_warning_percent) {
1266 add(Severity::Warning, format!("memory pressure {memory:.0}%"));
1267 }
1268 let io = pressure.io.full_avg60 as f64;
1269 if reached(io, limits.io_pressure_warning_percent) {
1270 add(Severity::Warning, format!("I/O pressure {io:.0}%"));
1271 }
1272 }
1273 let hottest_cpu = snapshot
1274 .cpu
1275 .temperatures
1276 .iter()
1277 .map(|t| t.celsius)
1278 .chain(snapshot.cpu.per_core_celsius.iter().flatten().copied())
1279 .fold(f32::NAN, f32::max);
1280 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
1281 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
1282 }
1283 for gpu in &snapshot.gpus {
1284 if let Some(t) = gpu
1285 .temperature_c
1286 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
1287 {
1288 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
1289 }
1290 if let (Some(used), Some(total)) = (gpu.memory_used_bytes, gpu.memory_total_bytes)
1291 && total > 0
1292 {
1293 let percent = used as f64 / total as f64 * 100.0;
1294 if reached(percent, limits.gpu_memory_warning_percent) {
1295 add(
1296 Severity::Warning,
1297 format!("GPU {} memory {percent:.0}% used", gpu.index),
1298 );
1299 }
1300 }
1301 let slowdown: Vec<&str> = gpu.slowdown_reasons().collect();
1302 if !slowdown.is_empty() {
1303 add(
1304 Severity::Warning,
1305 format!("GPU {} slowed down ({})", gpu.index, slowdown.join(", ")),
1306 );
1307 }
1308 if let Some(ecc) = gpu.ecc.filter(|e| e.uncorrected > 0) {
1309 add(
1310 Severity::Critical,
1311 format!(
1312 "GPU {}: {} uncorrected ECC errors",
1313 gpu.index, ecc.uncorrected
1314 ),
1315 );
1316 }
1317 }
1318 for (device, celsius) in drive_temperatures(snapshot) {
1319 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
1320 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
1321 }
1322 }
1323 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
1325 alerts
1326}
1327
1328fn oom_alert(kills: &[OomKill]) -> Option<String> {
1332 let mut names: Vec<&str> = Vec::new();
1333 for name in kills
1334 .iter()
1335 .rev()
1336 .filter_map(|k| k.likely_process.as_deref())
1337 {
1338 if !names.contains(&name) {
1339 names.push(name);
1340 }
1341 }
1342 let likely = if names.is_empty() {
1343 String::new()
1344 } else {
1345 format!(" (likely {})", names.join(", "))
1346 };
1347 match kills.len() {
1348 0 => None,
1349 1 => Some(match names.first() {
1350 Some(name) => format!("OOM killer stopped {name} (likely)"),
1351 None => "OOM killer stopped a process".to_owned(),
1352 }),
1353 n => Some(format!("OOM killer stopped {n} processes{likely}")),
1354 }
1355}
1356
1357fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
1360 let mut drives: Vec<(String, f32)> = snapshot
1361 .smart
1362 .iter()
1363 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
1364 .collect();
1365 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
1366 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
1368 if drives.iter().any(|(d, _)| d == device) {
1369 continue;
1370 }
1371 let composite = chip
1372 .temperatures
1373 .iter()
1374 .find(|t| t.label == "Composite")
1375 .or(chip.temperatures.first());
1376 if let Some(t) = composite {
1377 drives.push((device.to_owned(), t.celsius));
1378 }
1379 }
1380 drives
1381}
1382
1383fn gpu_usage_by_pid(snapshot: &Snapshot) -> HashMap<u32, (Option<f32>, Option<u64>)> {
1385 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
1386 for gpu in &snapshot.gpus {
1387 for p in &gpu.processes {
1388 let entry = gpu_usage.entry(p.pid).or_default();
1389 if let Some(sm) = p.sm_percent {
1390 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
1391 }
1392 if let Some(mem) = p.memory_bytes {
1393 entry.1 = Some(entry.1.unwrap_or(0) + mem);
1394 }
1395 }
1396 }
1397 gpu_usage
1398}
1399
1400#[derive(Debug, Clone, PartialEq)]
1402pub struct ContainerUsage {
1403 pub name: String,
1404 pub processes: usize,
1405 pub cpu_percent: f32,
1406 pub memory_bytes: u64,
1407 pub gpu_percent: Option<f32>,
1409 pub vram_bytes: Option<u64>,
1410}
1411
1412pub fn container_usage(snapshot: &Snapshot) -> Vec<ContainerUsage> {
1415 let gpu_usage = gpu_usage_by_pid(snapshot);
1416 let mut by_name: HashMap<&str, ContainerUsage> = HashMap::new();
1417 for process in &snapshot.processes {
1418 let Some(name) = process.container.as_deref() else {
1419 continue;
1420 };
1421 let usage = by_name.entry(name).or_insert_with(|| ContainerUsage {
1422 name: name.to_owned(),
1423 processes: 0,
1424 cpu_percent: 0.0,
1425 memory_bytes: 0,
1426 gpu_percent: None,
1427 vram_bytes: None,
1428 });
1429 usage.processes += 1;
1430 usage.cpu_percent += process.cpu_percent;
1431 usage.memory_bytes += process.memory_bytes;
1432 if let Some((gpu, vram)) = gpu_usage.get(&process.pid) {
1433 if let Some(gpu) = gpu {
1434 usage.gpu_percent = Some(usage.gpu_percent.unwrap_or(0.0) + gpu);
1435 }
1436 if let Some(vram) = vram {
1437 usage.vram_bytes = Some(usage.vram_bytes.unwrap_or(0) + vram);
1438 }
1439 }
1440 }
1441 let mut list: Vec<ContainerUsage> = by_name.into_values().collect();
1442 list.sort_by(|a, b| {
1443 b.cpu_percent
1444 .total_cmp(&a.cpu_percent)
1445 .then(b.memory_bytes.cmp(&a.memory_bytes))
1446 .then(a.name.cmp(&b.name))
1447 });
1448 list
1449}
1450
1451#[derive(Debug, Clone, Copy)]
1453pub struct ProcessQuery<'q> {
1454 pub sort_key: SortKey,
1456 pub descending: bool,
1458 pub filter: &'q str,
1460 pub show_kernel_threads: bool,
1462 pub tree: bool,
1465}
1466
1467pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
1469 let gpu_usage = gpu_usage_by_pid(snapshot);
1470
1471 let filter = query.filter.to_lowercase();
1472 let matches = |p: &ProcessInfo| {
1473 filter.is_empty()
1474 || p.name.to_lowercase().contains(&filter)
1475 || p.command.to_lowercase().contains(&filter)
1476 || p.user.to_lowercase().contains(&filter)
1477 || p.container
1478 .as_ref()
1479 .is_some_and(|c| c.to_lowercase().contains(&filter))
1480 || p.pid.to_string() == filter
1481 };
1482 let candidates = snapshot
1483 .processes
1484 .iter()
1485 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
1486 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
1487 with_ancestors(candidates.collect(), matches)
1488 } else {
1489 candidates.filter(|p| matches(p)).collect()
1490 };
1491 let mut rows: Vec<ProcessRow<'a>> = shown
1492 .into_iter()
1493 .map(|info| {
1494 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
1495 ProcessRow {
1496 info,
1497 gpu_percent,
1498 vram_bytes,
1499 tree_prefix: String::new(),
1500 }
1501 })
1502 .collect();
1503
1504 let key = query.sort_key;
1505 rows.sort_by(|a, b| {
1506 let ordering = match key {
1507 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
1508 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
1509 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
1510 SortKey::User => a.info.user.cmp(&b.info.user),
1511 SortKey::Container => match (&a.info.container, &b.info.container) {
1512 (Some(a), Some(b)) => a.cmp(b),
1513 (Some(_), None) => std::cmp::Ordering::Less,
1514 (None, Some(_)) => std::cmp::Ordering::Greater,
1515 (None, None) => std::cmp::Ordering::Equal,
1516 },
1517 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
1518 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
1519 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
1520 SortKey::Gpu => a
1521 .gpu_percent
1522 .unwrap_or(-1.0)
1523 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
1524 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
1525 }
1526 .then(a.info.pid.cmp(&b.info.pid));
1528 if query.descending {
1529 ordering.reverse()
1530 } else {
1531 ordering
1532 }
1533 });
1534 if query.tree { into_tree(rows) } else { rows }
1535}
1536
1537fn with_ancestors(
1540 processes: Vec<&ProcessInfo>,
1541 matches: impl Fn(&ProcessInfo) -> bool,
1542) -> Vec<&ProcessInfo> {
1543 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
1544 let mut keep: HashSet<u32> = HashSet::new();
1545 for process in processes.iter().filter(|p| matches(p)) {
1546 let mut current = Some(*process);
1547 while let Some(p) = current
1549 && keep.insert(p.pid)
1550 {
1551 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
1552 }
1553 }
1554 processes
1555 .into_iter()
1556 .filter(|p| keep.contains(&p.pid))
1557 .collect()
1558}
1559
1560fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
1564 let index: HashMap<u32, usize> = rows
1565 .iter()
1566 .enumerate()
1567 .map(|(i, row)| (row.info.pid, i))
1568 .collect();
1569 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
1570 let mut roots = Vec::new();
1571 for (i, row) in rows.iter().enumerate() {
1572 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
1573 Some(&parent) if parent != i => children[parent].push(i),
1574 _ => roots.push(i),
1575 }
1576 }
1577 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
1578 let mut visited = vec![false; rows.len()];
1579 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
1581 for start in starts {
1582 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
1584 while let Some((i, lead, last)) = stack.pop() {
1585 if std::mem::replace(&mut visited[i], true) {
1586 continue;
1587 }
1588 let (prefix, child_lead) = match last {
1589 None => (String::new(), String::new()),
1590 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
1591 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
1592 };
1593 order.push((i, prefix));
1594 let kids = &children[i];
1595 for (k, &child) in kids.iter().enumerate().rev() {
1596 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
1597 }
1598 }
1599 }
1600 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
1601 order
1602 .into_iter()
1603 .filter_map(|(i, prefix)| {
1604 let mut row = rows[i].take()?;
1605 row.tree_prefix = prefix;
1606 Some(row)
1607 })
1608 .collect()
1609}
1610
1611pub fn disk_rate(process: &ProcessInfo) -> f64 {
1613 process.read_bytes_per_sec + process.write_bytes_per_sec
1614}
1615
1616pub fn disk_rate_text(process: &ProcessInfo) -> String {
1619 let rate = disk_rate(process);
1620 if rate > 0.0 {
1621 format::rate(rate)
1622 } else {
1623 String::new()
1624 }
1625}
1626
1627pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
1630
1631pub fn lhm_help(error: &str) -> String {
1633 format!(
1634 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
1635 Start it and turn on Options → Remote Web Server → Run.\n\
1636 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
1637 {error}"
1638 )
1639}
1640
1641pub fn signal_name(signal: ProcessSignal) -> &'static str {
1644 match signal {
1645 ProcessSignal::Terminate => "SIGTERM",
1646 ProcessSignal::Kill => "SIGKILL",
1647 ProcessSignal::Stop => "SIGSTOP",
1648 ProcessSignal::Continue => "SIGCONT",
1649 ProcessSignal::Nice(_) => "renice",
1650 }
1651}
1652
1653pub fn action_text(signal: ProcessSignal) -> String {
1655 match signal {
1656 ProcessSignal::Nice(nice) => format!("nice {nice}"),
1657 other => signal_name(other).to_owned(),
1658 }
1659}
1660
1661pub const NICE_STEP: i32 = 5;
1663
1664pub fn nice_step(process: &ProcessInfo, later: bool) -> Option<i32> {
1667 let current = process.nice.unwrap_or(0);
1668 let next = if later {
1669 (current + NICE_STEP).min(19)
1670 } else {
1671 (current - NICE_STEP).max(-20)
1672 };
1673 (next != current).then_some(next)
1674}
1675
1676pub fn is_stopped(process: &ProcessInfo) -> bool {
1678 process.state == "Stopped" || process.state == "Tracing"
1679}
1680
1681pub fn is_virtual_interface(name: &str) -> bool {
1684 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
1685}
1686
1687pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
1689 snapshot
1690 .networks
1691 .iter()
1692 .filter(|n| !is_virtual_interface(&n.name))
1693 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
1694 .or_else(|| snapshot.networks.first())
1695 .map(|n| n.name.clone())
1696}
1697
1698#[cfg(test)]
1699mod tests {
1700 use super::*;
1701 use crate::snapshot::{EccErrors, GpuProcess, NetworkStats};
1702
1703 #[test]
1704 fn process_details_rows() {
1705 let mut snapshot = Snapshot::default();
1706 let mut shell = process(20, "zsh", 0.0, false);
1707 shell.parent_pid = Some(1);
1708 let mut python = process(30, "python", 99.5, false);
1709 python.parent_pid = Some(20);
1710 python.command = "python train.py --epochs 10".to_owned();
1711 python.container = Some("trainer".to_owned());
1712 python.run_time_secs = 3_660;
1713 snapshot.processes = vec![process(1, "systemd", 0.0, false), shell, python];
1714 snapshot.gpus = vec![GpuStats {
1715 index: 0,
1716 processes: vec![GpuProcess {
1717 pid: 30,
1718 memory_bytes: Some(2 << 30),
1719 sm_percent: Some(97.0),
1720 }],
1721 ..Default::default()
1722 }];
1723 let now = SystemTime::now();
1724 let rows = process_details(&snapshot, 30, now).unwrap();
1725 let get = |label: &str| {
1726 rows.iter()
1727 .find(|(l, _)| *l == label)
1728 .map(|(_, v)| v.as_str())
1729 };
1730 assert_eq!(get("Program"), Some("python (pid 30)"));
1731 assert_eq!(get("Parents"), Some("systemd (1) › zsh (20)"));
1732 assert_eq!(get("Children"), None);
1733 assert_eq!(get("Container"), Some("trainer"));
1734 assert!(get("Running").unwrap().starts_with("1h01m (since "));
1735 assert_eq!(get("GPU"), Some("GPU 0 97%, 2.0G VRAM"));
1736 assert_eq!(get("Command"), Some("python train.py --epochs 10"));
1737 let shell = process_details(&snapshot, 20, now).unwrap();
1738 assert!(shell.contains(&("Children", "1".to_owned())));
1739 assert!(process_details(&snapshot, 99, now).is_none());
1740 }
1741
1742 #[test]
1743 fn attention_ranks() {
1744 let alert = |severity| Alert {
1745 severity,
1746 text: String::new(),
1747 };
1748 assert_eq!(attention_rank(&[], false, false), 2);
1749 assert_eq!(attention_rank(&[alert(Severity::Warning)], false, false), 1);
1750 assert_eq!(attention_rank(&[], false, true), 1);
1751 assert_eq!(
1752 attention_rank(
1753 &[alert(Severity::Warning), alert(Severity::Critical)],
1754 false,
1755 false
1756 ),
1757 0
1758 );
1759 assert_eq!(attention_rank(&[], true, false), 0);
1760 }
1761
1762 #[test]
1763 fn gpu_notes_and_pcie() {
1764 let mut gpu = GpuStats {
1765 throttle_reasons: vec!["power cap".to_owned()],
1766 ..Default::default()
1767 };
1768 assert_eq!(gpu_notes(&gpu), [("power cap".to_owned(), None)]);
1769 gpu.throttle_reasons.push("thermal".to_owned());
1770 gpu.ecc = Some(EccErrors {
1771 corrected: 3,
1772 uncorrected: 0,
1773 });
1774 assert_eq!(
1775 gpu_notes(&gpu),
1776 [
1777 ("slowed: thermal".to_owned(), Some(Severity::Warning)),
1778 ("ECC 3 corrected".to_owned(), None)
1779 ]
1780 );
1781 let full = PcieLink {
1782 generation: 4,
1783 width: 16,
1784 max_generation: 4,
1785 max_width: 16,
1786 };
1787 assert_eq!(pcie_text(&full), "PCIe gen 4 x16");
1788 let idle = PcieLink {
1789 generation: 1,
1790 width: 8,
1791 ..full
1792 };
1793 assert!(pcie_text(&idle).starts_with("PCIe gen 1 x8 (up to gen 4 x16;"));
1794 }
1795
1796 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
1797 ProcessInfo {
1798 pid,
1799 name: name.to_owned(),
1800 cpu_percent: cpu,
1801 kernel_thread: kernel,
1802 ..Default::default()
1803 }
1804 }
1805
1806 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
1807 ProcessQuery {
1808 sort_key,
1809 descending: sort_key.descending_by_default(),
1810 filter: "",
1811 show_kernel_threads: false,
1812 tree: false,
1813 }
1814 }
1815
1816 #[test]
1817 fn history_keeps_newest_samples() {
1818 let mut history = History::default();
1819 for i in 0..(HISTORY_LEN + 5) {
1820 history.push(i as f64);
1821 }
1822 assert_eq!(
1823 history.last(2),
1824 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
1825 );
1826 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
1827 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
1828 }
1829
1830 #[test]
1831 fn graph_spans_spread_samples_over_the_width() {
1832 let second = Duration::from_secs(1);
1833 let mut history = History::default();
1834 for i in 0..120 {
1835 history.push(i as f64);
1836 }
1837 assert_eq!(
1839 history.graph(3, GraphSpan::Fit, second),
1840 vec![117.0, 118.0, 119.0]
1841 );
1842 let minute = history.graph(30, GraphSpan::Secs(60), second);
1844 assert_eq!(minute.len(), 30);
1845 assert_eq!(minute[0], 60.5);
1846 assert_eq!(minute[29], 118.5);
1847 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1849 assert_eq!(stretched.len(), 120);
1850 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1851 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1852 assert_eq!(stretched[119], 119.0);
1853 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1854 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1856 assert_eq!(hour.len(), 10);
1857 assert_eq!(hour[0], 5.5);
1858 assert!(
1859 History::default()
1860 .graph(10, GraphSpan::Secs(60), second)
1861 .is_empty()
1862 );
1863 }
1864
1865 #[test]
1866 fn long_spans_draw_from_the_averages() {
1867 let second = Duration::from_secs(1);
1868 let mut history = History::default();
1869 for i in 0..7200 {
1871 history.push(if i < 3600 { 0.0 } else { 100.0 });
1872 }
1873 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1875 assert_eq!(six.len(), 30);
1876 assert_eq!((six[0], six[29]), (0.0, 100.0));
1877 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1879 assert!(hour.iter().all(|v| *v == 100.0));
1880 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1882 history.push(i as f64);
1883 }
1884 assert_eq!(history.coarse.len(), COARSE_LEN);
1885 }
1886
1887 #[test]
1888 fn graph_span_choices_cycle() {
1889 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1890 .take(6)
1891 .map(GraphSpan::label)
1892 .collect();
1893 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1894 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1895 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1896 assert_eq!(
1897 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1898 GraphSpan::Secs(300)
1899 );
1900 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1901 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1903 }
1904
1905 #[test]
1906 fn rows_filter_sort_and_join_gpu_usage() {
1907 let snapshot = Snapshot {
1908 processes: vec![
1909 process(10, "bash", 5.0, false),
1910 process(2, "kworker/0:1", 50.0, true),
1911 process(30, "python", 1.0, false),
1912 ],
1913 gpus: vec![GpuStats {
1914 processes: vec![GpuProcess {
1915 pid: 30,
1916 memory_bytes: Some(1 << 30),
1917 sm_percent: Some(70.0),
1918 }],
1919 ..Default::default()
1920 }],
1921 ..Default::default()
1922 };
1923 let pids = |q: &ProcessQuery| {
1924 process_rows(&snapshot, q)
1925 .iter()
1926 .map(|r| r.info.pid)
1927 .collect::<Vec<_>>()
1928 };
1929 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1930 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1931 assert_eq!(
1932 pids(&ProcessQuery {
1933 show_kernel_threads: true,
1934 ..query(SortKey::Cpu)
1935 }),
1936 vec![2, 10, 30]
1937 );
1938 assert_eq!(
1939 pids(&ProcessQuery {
1940 filter: "PYT",
1941 ..query(SortKey::Pid)
1942 }),
1943 vec![30]
1944 );
1945 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1946 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1947 assert_eq!(rows[1].gpu_percent, Some(70.0));
1948 }
1949
1950 #[test]
1951 fn since_notes_say_how_long_and_since_when() {
1952 let now = SystemTime::now();
1953 let ago = |secs: u64| now - Duration::from_secs(secs);
1954 assert!(since_note(ago(42), now).starts_with("42s (since "));
1955 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1956 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1957 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1958 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1960 }
1961
1962 #[test]
1963 fn tree_puts_children_under_parents() {
1964 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1965 parent_pid: Some(parent),
1966 ..process(pid, name, cpu, false)
1967 };
1968 let snapshot = Snapshot {
1969 processes: vec![
1970 process(1, "init", 0.0, false),
1971 child(10, 1, "sshd", 1.0),
1972 child(11, 10, "bash", 2.0),
1973 child(12, 11, "python", 50.0),
1974 child(20, 1, "cron", 3.0),
1975 child(30, 999, "orphan", 4.0),
1977 child(40, 41, "loop-a", 0.0),
1979 child(41, 40, "loop-b", 0.0),
1980 ],
1981 ..Default::default()
1982 };
1983 let tree = |filter| {
1984 process_rows(
1985 &snapshot,
1986 &ProcessQuery {
1987 filter,
1988 tree: true,
1989 ..query(SortKey::Cpu)
1990 },
1991 )
1992 .iter()
1993 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1994 .collect::<Vec<_>>()
1995 };
1996 assert_eq!(
1998 tree(""),
1999 vec![
2000 "orphan",
2001 "init",
2002 "├ cron",
2003 "└ sshd",
2004 " └ bash",
2005 " └ python",
2006 "loop-b",
2007 "└ loop-a",
2008 ]
2009 );
2010 assert_eq!(
2012 tree("pyth"),
2013 vec!["init", "└ sshd", " └ bash", " └ python"]
2014 );
2015 }
2016
2017 #[test]
2018 fn disk_column_sums_reads_and_writes() {
2019 let reader = ProcessInfo {
2020 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
2021 ..process(10, "reader", 0.0, false)
2022 };
2023 let writer = ProcessInfo {
2024 read_bytes_per_sec: 1024.0 * 1024.0,
2025 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
2026 ..process(20, "writer", 0.0, false)
2027 };
2028 let idle = process(30, "idle", 0.0, false);
2029 assert_eq!(disk_rate_text(&writer), "5.0M/s");
2030 assert_eq!(disk_rate_text(&idle), "");
2031 let snapshot = Snapshot {
2032 processes: vec![idle, reader, writer],
2033 ..Default::default()
2034 };
2035 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
2036 .iter()
2037 .map(|r| r.info.pid)
2038 .collect();
2039 assert_eq!(pids, vec![20, 10, 30]);
2040 }
2041
2042 #[test]
2043 fn metric_history_records_and_switches_interfaces() {
2044 let snapshot = Snapshot {
2045 networks: vec![
2046 NetworkStats {
2047 name: "enp9s0".to_owned(),
2048 rx_total_bytes: 10,
2049 rx_bytes_per_sec: 100.0,
2050 ..Default::default()
2051 },
2052 NetworkStats {
2053 name: "wlp7s0".to_owned(),
2054 tx_bytes_per_sec: 5.0,
2055 ..Default::default()
2056 },
2057 ],
2058 gpus: vec![GpuStats {
2059 utilization_percent: Some(40.0),
2060 ..Default::default()
2061 }],
2062 ..Default::default()
2063 };
2064 let mut history = MetricHistory::default();
2065 history.record(&snapshot);
2066 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
2067 assert_eq!(history.net_rx.last(1), vec![100.0]);
2068 assert_eq!(history.gpus[0].last(1), vec![40.0]);
2069 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
2070 assert!(history.net_rx.last(10).is_empty());
2072 history.record(&snapshot);
2073 assert_eq!(history.net_tx.last(1), vec![5.0]);
2074 }
2075
2076 #[test]
2077 fn alerts_flag_storage_problems_first() {
2078 use crate::snapshot::{
2079 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
2080 ZfsPool,
2081 };
2082 let snapshot = Snapshot {
2083 filesystems: vec![FilesystemUsage {
2084 mount_points: vec!["/data".to_owned()],
2085 total_bytes: 100,
2086 available_bytes: 8,
2087 ..Default::default()
2088 }],
2089 raid: RaidStats {
2090 btrfs: vec![BtrfsFilesystem {
2091 mount_points: vec!["/".to_owned()],
2092 device_stats: vec![BtrfsDeviceStats {
2093 devid: 1,
2094 corruption_errs: 2,
2095 ..Default::default()
2096 }],
2097 ..Default::default()
2098 }],
2099 md: vec![MdArray {
2100 name: "md0".to_owned(),
2101 active: true,
2102 level: "raid1".to_owned(),
2103 expected_devices: Some(2),
2104 working_devices: Some(1),
2105 ..Default::default()
2106 }],
2107 zfs: vec![
2108 ZfsPool {
2109 name: "tank".to_owned(),
2110 state: "DEGRADED".to_owned(),
2111 ..Default::default()
2112 },
2113 ZfsPool {
2114 name: "cold".to_owned(),
2115 state: "OFFLINE".to_owned(),
2116 ..Default::default()
2117 },
2118 ZfsPool {
2119 name: "fast".to_owned(),
2120 state: "ONLINE".to_owned(),
2121 ..Default::default()
2122 },
2123 ],
2124 },
2125 smart: vec![SmartDevice {
2126 device: "sda".to_owned(),
2127 passed: Some(true),
2128 ..Default::default()
2129 }],
2130 ..Default::default()
2131 };
2132 let alerts = alerts(&snapshot);
2133 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
2134 assert_eq!(
2135 texts,
2136 vec![
2137 "btrfs /: 2 I/O errors (devid 1)",
2138 "md0 (raid1) degraded",
2139 "zfs tank DEGRADED",
2140 "zfs cold OFFLINE",
2141 "/data 92% full"
2142 ]
2143 );
2144 assert_eq!(alerts[0].severity, Severity::Critical);
2145 assert_eq!(alerts[2].severity, Severity::Critical);
2146 assert_eq!(alerts[3].severity, Severity::Warning);
2147 assert!(super::alerts(&Snapshot::default()).is_empty());
2148 }
2149
2150 #[test]
2151 fn event_times_are_as_short_as_is_clear() {
2152 let now = SystemTime::now();
2153 let ms = |t: SystemTime| {
2154 t.duration_since(SystemTime::UNIX_EPOCH)
2155 .unwrap()
2156 .as_millis() as u64
2157 };
2158 assert_eq!(event_time(ms(now), now).len(), 8);
2160 let week_ago = now - Duration::from_secs(10 * 86_400);
2161 assert!(event_time(ms(week_ago), now).contains(' '));
2162 assert_ne!(event_time(ms(week_ago), now).len(), 8);
2163 }
2164
2165 #[test]
2166 fn snoozing_a_raised_alert_logs_no_clearing() {
2167 let disk = Alert {
2168 severity: Severity::Warning,
2169 text: "sdc: SMART warning".to_owned(),
2170 };
2171 let mut log = AlertLog::default();
2172 assert_eq!(log.update("nas", std::slice::from_ref(&disk), 1).len(), 1);
2173 assert!(
2175 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 2)
2176 .is_empty()
2177 );
2178 assert!(
2179 log.update_snoozed("nas", &[], std::slice::from_ref(&disk), 3)
2180 .is_empty()
2181 );
2182 let events = log.update_snoozed("nas", std::slice::from_ref(&disk), &[], 4);
2184 assert_eq!(events.len(), 1);
2185 assert_eq!(events[0].change, AlertChange::Raised);
2186 assert_eq!(
2188 log.update_snoozed("nas", &[], &[], 5)[0].change,
2189 AlertChange::Cleared
2190 );
2191 }
2192
2193 #[test]
2194 fn alert_history_survives_a_restart() {
2195 let tmp = tempfile::tempdir().unwrap();
2196 let path = tmp.path().join("state/alerts-tui.jsonl");
2197 let degraded = Alert {
2198 severity: Severity::Critical,
2199 text: "md0 (raid1) degraded".to_owned(),
2200 };
2201 let full = Alert {
2202 severity: Severity::Warning,
2203 text: "/srv 92% full".to_owned(),
2204 };
2205 let mut log = AlertLog::persistent(path.clone());
2206 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
2207 log.update("nas", std::slice::from_ref(°raded), 2_000);
2208 assert_eq!(log.events().count(), 3);
2209
2210 let mut log = AlertLog::persistent(path.clone());
2213 assert_eq!(log.events().count(), 3);
2214 assert_eq!(log.unread(), (0, None));
2215 assert!(
2216 log.update("nas", std::slice::from_ref(°raded), 3_000)
2217 .is_empty()
2218 );
2219 assert_eq!(log.events().count(), 3);
2220 let changes = log.update("nas", &[], 4_000);
2221 assert_eq!(changes.len(), 1);
2222 assert_eq!(changes[0].alert, degraded);
2223 let last = log.events().last().unwrap();
2224 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
2225 assert_eq!(AlertLog::persistent(path.clone()).events().count(), 4);
2226
2227 let exited = Alert {
2230 severity: Severity::Warning,
2231 text: "train.py (pid 7) exited after 3h".to_owned(),
2232 };
2233 let mut log = AlertLog::persistent(path.clone());
2234 log.record("nas", exited.clone(), 5_000);
2235 assert_eq!(log.unread(), (1, Some(Severity::Warning)));
2236 let mut log = AlertLog::persistent(path);
2237 assert!(log.update("nas", &[], 6_000).is_empty());
2238 let last = log.events().last().unwrap();
2239 assert_eq!((last.change, &last.alert), (AlertChange::Notice, &exited));
2240 }
2241
2242 #[test]
2243 fn zfs_devices_data_errors_and_scans() {
2244 use crate::snapshot::{RaidStats, ZfsDevice, ZfsPool};
2245 let device = |name: &str, depth, state: &str, checksum| ZfsDevice {
2246 name: name.to_owned(),
2247 depth,
2248 state: state.to_owned(),
2249 checksum_errors: checksum,
2250 ..Default::default()
2251 };
2252 let snapshot = Snapshot {
2253 raid: RaidStats {
2254 zfs: vec![ZfsPool {
2255 name: "tank".to_owned(),
2256 state: "DEGRADED".to_owned(),
2257 devices: vec![
2258 device("raidz1-0", 0, "DEGRADED", 0),
2259 device("sda", 1, "ONLINE", 491),
2260 device("sdb", 1, "OFFLINE", 0),
2261 device("sdc", 1, "ONLINE", 0),
2262 ],
2263 scan: Some(
2264 "resilver in progress since Sat Oct 31 19:33:41 2026 · 42.12% done"
2265 .to_owned(),
2266 ),
2267 data_errors: Some(3),
2268 ..Default::default()
2269 }],
2270 ..Default::default()
2271 },
2272 ..Default::default()
2273 };
2274 let texts: Vec<String> = alerts(&snapshot).into_iter().map(|a| a.text).collect();
2275 for expected in [
2276 "zfs tank DEGRADED",
2277 "zfs tank: 3 data errors",
2278 "zfs tank: sda has 491 errors",
2279 "zfs tank: sdb OFFLINE",
2280 "zfs tank resilver 42.1%",
2281 ] {
2282 assert!(
2283 texts.iter().any(|t| t == expected),
2284 "{expected} in {texts:?}"
2285 );
2286 }
2287
2288 let summary = |s| zfs_scan_summary(s);
2289 assert_eq!(
2290 summary("scrub repaired 0B in 00:00:00 with 0 errors on Sat Oct 31 19:33:21 2026"),
2291 ("scrub Oct 31, ok".to_owned(), None)
2292 );
2293 assert_eq!(
2294 summary("scrub repaired 27.7M in 00:00:00 with 0 errors on Sat Oct 31 19:33:41 2026"),
2295 (
2296 "scrub Oct 31, repaired 27.7M".to_owned(),
2297 Some(Severity::Warning)
2298 )
2299 );
2300 assert_eq!(
2301 summary("scrub repaired 22K in 00:00:00 with 442 errors on Sat Oct 31 19:33:54 2026").1,
2302 Some(Severity::Critical)
2303 );
2304 assert_eq!(
2305 summary("resilvered 36K in 00:00:00 with 0 errors on Sat Oct 31 19:33:54 2026").0,
2306 "resilver Oct 31, ok"
2307 );
2308 assert_eq!(
2309 summary("scrub paused since Sat Oct 31 19:34:09 2026 · 0B repaired, 75.76% done").0,
2310 "scrub paused 75.8%"
2311 );
2312 assert_eq!(summary("none requested").0, "no scrub yet");
2313 }
2314
2315 #[test]
2316 fn cards_skip_gpus_without_readings() {
2317 let snapshot = Snapshot {
2318 gpus: vec![
2319 GpuStats {
2320 index: 0,
2321 name: "Intel UHD Graphics".to_owned(),
2322 temperature_c: Some(50.0),
2323 ..Default::default()
2324 },
2325 GpuStats {
2326 index: 1,
2327 utilization_percent: Some(3.0),
2328 ..Default::default()
2329 },
2330 ],
2331 ..Default::default()
2332 };
2333 let shown: Vec<u32> = card_gpus(&snapshot, IntegratedGpu::Compact)
2334 .map(|g| g.index)
2335 .collect();
2336 assert_eq!(shown, [1]);
2337 }
2338
2339 #[test]
2340 fn integrated_gpus_step_aside_for_a_discrete_one() {
2341 let gpu = |index, integrated| GpuStats {
2342 index,
2343 integrated,
2344 utilization_percent: Some(1.0),
2345 memory_used_bytes: Some(1 << 30),
2346 ..Default::default()
2347 };
2348 let desktop = Snapshot {
2349 gpus: vec![gpu(0, false), gpu(1, false), gpu(2, true)],
2350 ..Default::default()
2351 };
2352 let rows = |snapshot, mode| -> Vec<(usize, GpuRow)> {
2353 panel_gpus(snapshot, mode)
2354 .into_iter()
2355 .map(|(i, _, row)| (i, row))
2356 .collect()
2357 };
2358 assert_eq!(
2359 rows(&desktop, IntegratedGpu::Compact),
2360 [(0, GpuRow::Full), (1, GpuRow::Full), (2, GpuRow::Compact)]
2361 );
2362 assert_eq!(
2363 rows(&desktop, IntegratedGpu::Hide),
2364 [(0, GpuRow::Full), (1, GpuRow::Full)]
2365 );
2366 assert_eq!(rows(&desktop, IntegratedGpu::Full).len(), 3);
2367 let cards =
2368 |snapshot, mode| -> Vec<u32> { card_gpus(snapshot, mode).map(|g| g.index).collect() };
2369 assert_eq!(cards(&desktop, IntegratedGpu::Compact), [0, 1]);
2370 assert_eq!(cards(&desktop, IntegratedGpu::Full), [0, 1, 2]);
2371
2372 let laptop = Snapshot {
2374 gpus: vec![gpu(0, true)],
2375 ..Default::default()
2376 };
2377 assert_eq!(rows(&laptop, IntegratedGpu::Hide), [(0, GpuRow::Full)]);
2378 assert_eq!(cards(&laptop, IntegratedGpu::Compact), [0]);
2379 }
2380
2381 #[test]
2382 fn containers_add_up_their_processes() {
2383 use crate::snapshot::GpuProcess;
2384 let process = |pid, container: Option<&str>, cpu, mem| ProcessInfo {
2385 pid,
2386 container: container.map(str::to_owned),
2387 cpu_percent: cpu,
2388 memory_bytes: mem,
2389 ..Default::default()
2390 };
2391 let snapshot = Snapshot {
2392 processes: vec![
2393 process(1, None, 50.0, 100),
2394 process(2, Some("db"), 3.0, 500),
2395 process(3, Some("web"), 10.0, 200),
2396 process(4, Some("web"), 5.0, 300),
2397 ],
2398 gpus: vec![GpuStats {
2399 processes: vec![GpuProcess {
2400 pid: 3,
2401 sm_percent: Some(40.0),
2402 memory_bytes: Some(1 << 30),
2403 }],
2404 ..Default::default()
2405 }],
2406 ..Default::default()
2407 };
2408 let usage = container_usage(&snapshot);
2409 assert_eq!(usage.len(), 2, "processes outside containers are left out");
2410 assert_eq!(usage[0].name, "web");
2411 assert_eq!(usage[0].processes, 2);
2412 assert_eq!(usage[0].cpu_percent, 15.0);
2413 assert_eq!(usage[0].memory_bytes, 500);
2414 assert_eq!(usage[0].gpu_percent, Some(40.0));
2415 assert_eq!(usage[0].vram_bytes, Some(1 << 30));
2416 assert_eq!(usage[1].gpu_percent, None);
2417 assert!(container_usage(&Snapshot::default()).is_empty());
2418 }
2419
2420 #[test]
2421 fn processes_match_by_name_or_file() {
2422 let process = ProcessInfo {
2423 name: "python3".to_owned(),
2424 command: "/usr/bin/python3 /home/me/jobs/train.py --epochs 3".to_owned(),
2425 ..Default::default()
2426 };
2427 assert!(process_matches(&process, "python3"));
2428 assert!(process_matches(&process, "train.py"));
2429 assert!(process_matches(&process, " train.py "));
2430 assert!(process_matches(&process, "/home/me/jobs/train.py"));
2431 assert!(!process_matches(&process, "train"));
2432 assert!(!process_matches(&process, "epochs"));
2433 assert!(!process_matches(&process, ""));
2434 }
2435
2436 #[test]
2437 fn exit_watch_reports_exits_it_can_tell() {
2438 let process = |pid: u32, name: &str| ProcessInfo {
2439 pid,
2440 name: name.to_owned(),
2441 run_time_secs: 3_600,
2442 ..Default::default()
2443 };
2444 let mut watch = ExitWatch::default();
2445 assert!(watch.toggle(&process(7, "train.py"), 10_000_000));
2446 assert!(watch.toggle(&process(8, "make"), 10_000_000));
2447 assert!(!watch.toggle(&process(8, "make"), 10_000_000));
2448 assert_eq!(watch.pids(), [7]);
2449
2450 let mut snapshot = Snapshot::default();
2453 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2454 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2455 snapshot.processes = vec![process(7, "train.py")];
2457 assert_eq!(watch.state(&snapshot, 7), WatchState::Unknown);
2458
2459 snapshot.watched_pids = vec![7];
2460 snapshot.processes = vec![process(7, "train.py")];
2461 assert_eq!(watch.state(&snapshot, 7), WatchState::Running);
2462 assert!(watch.check(&snapshot, 10_000_000).is_empty());
2463
2464 snapshot.processes.clear();
2466 assert_eq!(watch.state(&snapshot, 7), WatchState::Exited);
2467 let alerts = watch.check(&snapshot, 10_000_000 + 1_800_000);
2468 assert_eq!(alerts.len(), 1);
2469 assert_eq!(alerts[0].text, "train.py (pid 7) exited after 1h30m");
2470 assert_eq!(watch.pids(), Vec::<u32>::new(), "reported once");
2471
2472 watch.toggle(&process(9, "rsync"), 0);
2474 snapshot.processes = vec![process(9, "bash")];
2475 assert_eq!(watch.check(&snapshot, 0).len(), 1);
2476
2477 let python = process(12, "python3");
2479 assert!(watch.watch_as(&python, Some("train.py"), 0));
2480 assert!(!watch.watch_as(&python, Some("train.py"), 0), "once");
2481 snapshot.processes.clear();
2482 snapshot.watched_pids = vec![12];
2483 assert_eq!(
2484 watch.check(&snapshot, 60_000)[0].text,
2485 "train.py (pid 12) exited after 01:00"
2486 );
2487 }
2488
2489 #[test]
2490 fn failed_units_warn_unless_ignored() {
2491 let snapshot = Snapshot {
2492 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
2493 ..Default::default()
2494 };
2495 let limits = AlertsConfig {
2496 ignored_units: vec!["wait-online.service".to_owned()],
2497 ..Default::default()
2498 };
2499 let alerts = alerts_with(&snapshot, &limits);
2500 assert_eq!(alerts.len(), 1);
2501 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
2502 assert_eq!(alerts[0].severity, Severity::Warning);
2503 }
2504
2505 #[test]
2506 fn version_notes_only_for_other_versions() {
2507 let mut snapshot = Snapshot::default();
2508 assert_eq!(version_note(&snapshot), None);
2509 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
2510 assert_eq!(version_note(&snapshot), None);
2511 snapshot.host.rigtop_version = "0.1.0".to_owned();
2512 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
2513 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
2514 }
2515
2516 #[test]
2517 fn alert_log_records_alerts_appearing_and_clearing() {
2518 let alert = |severity, text: &str| Alert {
2519 severity,
2520 text: text.to_owned(),
2521 };
2522 let full = alert(Severity::Warning, "/data 91% full");
2523 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
2524 let mut log = AlertLog::default();
2525 log.update("nas", std::slice::from_ref(&full), 1_000);
2526 log.update(
2528 "nas",
2529 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
2530 2_000,
2531 );
2532 log.update("zefat", &[], 2_000);
2533 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
2534 log.mark_read();
2535 log.update("nas", std::slice::from_ref(&errors), 3_000);
2536 assert_eq!(log.unread(), (0, None));
2537 let summary: Vec<(u64, AlertChange, &str)> = log
2538 .events()
2539 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
2540 .collect();
2541 assert_eq!(
2542 summary,
2543 [
2544 (1_000, AlertChange::Raised, "/data 91% full"),
2545 (
2546 2_000,
2547 AlertChange::Raised,
2548 "btrfs /: 2 I/O errors (nvme1n1)"
2549 ),
2550 (3_000, AlertChange::Cleared, "/data 92% full"),
2551 ]
2552 );
2553 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
2554 log.update(
2555 "flappy",
2556 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
2557 i,
2558 );
2559 }
2560 assert_eq!(log.events().count(), ALERT_LOG_LEN);
2561 }
2562
2563 #[test]
2564 fn filesystems_filling_up_warn() {
2565 use crate::snapshot::FilesystemUsage;
2566 let filling = |full_in_secs| Snapshot {
2567 filesystems: vec![FilesystemUsage {
2568 mount_points: vec!["/srv".to_owned()],
2569 total_bytes: 100,
2570 available_bytes: 50,
2571 full_in_secs,
2572 ..Default::default()
2573 }],
2574 ..Default::default()
2575 };
2576 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<String> {
2577 alerts_with(snapshot, limits)
2578 .into_iter()
2579 .map(|a| a.text)
2580 .collect()
2581 };
2582 let defaults = AlertsConfig::default();
2583 assert_eq!(
2584 texts(&filling(Some(5 * 3600)), &defaults),
2585 ["/srv full in ~5h00m"]
2586 );
2587 assert!(texts(&filling(Some(30 * 3600)), &defaults).is_empty());
2588 assert!(texts(&filling(None), &defaults).is_empty());
2589 let off = AlertsConfig {
2590 filesystem_full_warning_hours: 0.0,
2591 ..Default::default()
2592 };
2593 assert!(texts(&filling(Some(60)), &off).is_empty());
2594 }
2595
2596 #[test]
2597 fn alert_thresholds_come_from_the_configuration() {
2598 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
2599 let temp = |label: &str, celsius| Temperature {
2600 label: label.to_owned(),
2601 celsius,
2602 ..Default::default()
2603 };
2604 let mut snapshot = Snapshot {
2605 filesystems: vec![FilesystemUsage {
2606 mount_points: vec!["/data".to_owned()],
2607 total_bytes: 100,
2608 available_bytes: 8,
2609 ..Default::default()
2610 }],
2611 gpus: vec![GpuStats {
2612 index: 0,
2613 temperature_c: Some(80.0),
2614 ..Default::default()
2615 }],
2616 smart: vec![SmartDevice {
2617 device: "nvme0".to_owned(),
2618 temperature_c: Some(72.0),
2619 ..Default::default()
2620 }],
2621 sensors: vec![
2623 SensorChip {
2624 key: "nvme:nvme0".to_owned(),
2625 name: "nvme".to_owned(),
2626 temperatures: vec![temp("Composite", 71.0)],
2627 ..Default::default()
2628 },
2629 SensorChip {
2630 key: "nvme:nvme1".to_owned(),
2631 name: "nvme".to_owned(),
2632 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
2633 ..Default::default()
2634 },
2635 ],
2636 ..Default::default()
2637 };
2638 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
2639 let texts_of = |snapshot: &Snapshot| -> Vec<String> {
2640 alerts_with(snapshot, &AlertsConfig::default())
2641 .into_iter()
2642 .map(|a| a.text)
2643 .collect()
2644 };
2645 let texts = |limits: &AlertsConfig| -> Vec<String> {
2646 alerts_with(&snapshot, limits)
2647 .into_iter()
2648 .map(|a| a.text)
2649 .collect()
2650 };
2651 assert_eq!(
2652 texts(&AlertsConfig::default()),
2653 [
2654 "/data 92% full",
2655 "CPU at 97°C",
2656 "nvme0 at 72°C",
2657 "nvme1 at 74°C"
2658 ]
2659 );
2660 let custom = AlertsConfig {
2661 filesystem_warning_percent: 0.0,
2662 cpu_warning_celsius: 0.0,
2663 gpu_warning_celsius: 75.0,
2664 drive_warning_celsius: 73.0,
2665 ..Default::default()
2666 };
2667 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
2668
2669 let mut gpu = snapshot.gpus[0].clone();
2671 gpu.throttle_reasons = vec!["power cap".to_owned()];
2672 let mut capped = snapshot.clone();
2673 capped.gpus = vec![gpu.clone()];
2674 assert!(!texts_of(&capped).iter().any(|t| t.contains("slowed")));
2675 gpu.throttle_reasons.push("hw thermal".to_owned());
2676 gpu.ecc = Some(EccErrors {
2677 corrected: 5,
2678 uncorrected: 2,
2679 });
2680 capped.gpus = vec![gpu];
2681 let alerts = alerts_with(&capped, &AlertsConfig::default());
2682 assert_eq!(alerts[0].text, "GPU 0: 2 uncorrected ECC errors");
2683 assert_eq!(alerts[0].severity, Severity::Critical);
2684 assert!(
2685 alerts
2686 .iter()
2687 .any(|a| a.text == "GPU 0 slowed down (hw thermal)")
2688 );
2689 let critical = AlertsConfig {
2690 filesystem_critical_percent: 90.0,
2691 ..custom
2692 };
2693 assert_eq!(
2694 alerts_with(&snapshot, &critical)[0],
2695 Alert {
2696 severity: Severity::Critical,
2697 text: "/data 92% full".to_owned()
2698 }
2699 );
2700 }
2701
2702 #[test]
2703 fn pressure_alerts_and_readings() {
2704 use crate::snapshot::PressureLevels;
2705 let levels = |some: f32, full: f32| PressureLevels {
2706 some_avg10: some,
2707 some_avg60: some,
2708 full_avg10: full,
2709 full_avg60: full,
2710 };
2711 let mut snapshot = Snapshot {
2712 pressure: Some(Pressure {
2713 cpu: levels(40.0, 0.0),
2714 memory: levels(0.2, 0.1),
2715 io: levels(80.0, 12.0),
2716 }),
2717 ..Default::default()
2718 };
2719 let limits = AlertsConfig::default();
2720 assert!(alerts_with(&snapshot, &limits).is_empty());
2722 let names = |snapshot: &Snapshot| -> Vec<(&'static str, String, bool)> {
2723 pressure_items(snapshot.pressure.as_ref().unwrap(), &limits)
2724 .into_iter()
2725 .map(|i| (i.name, i.value, i.warn))
2726 .collect()
2727 };
2728 assert_eq!(
2730 names(&snapshot),
2731 [
2732 ("cpu", "40%".to_owned(), false),
2733 ("io", "80%".to_owned(), false)
2734 ]
2735 );
2736
2737 snapshot.pressure = Some(Pressure {
2738 cpu: levels(0.0, 0.0),
2739 memory: levels(25.0, 4.0),
2740 io: levels(90.0, 45.0),
2741 });
2742 let texts: Vec<String> = alerts_with(&snapshot, &limits)
2743 .into_iter()
2744 .map(|a| a.text)
2745 .collect();
2746 assert_eq!(texts, ["memory pressure 25%", "I/O pressure 45%"]);
2747 assert_eq!(
2748 names(&snapshot),
2749 [
2750 ("mem", "25%".to_owned(), true),
2751 ("io", "90%".to_owned(), true)
2752 ]
2753 );
2754 assert!(
2755 pressure_details(snapshot.pressure.as_ref().unwrap())
2756 .contains("io: some 90% / 90%, full 45% / 45%")
2757 );
2758 }
2759
2760 #[test]
2761 fn memory_alerts() {
2762 use crate::snapshot::{MemoryStats, OomKill};
2763 let kill = |name: Option<&str>| OomKill {
2764 at_ms: 1,
2765 likely_process: name.map(str::to_owned),
2766 };
2767 let mut snapshot = Snapshot {
2768 memory: MemoryStats {
2769 total_bytes: 100,
2770 used_bytes: 93,
2771 oom_kills: vec![kill(Some("python3"))],
2772 ..Default::default()
2773 },
2774 gpus: vec![GpuStats {
2775 index: 1,
2776 memory_used_bytes: Some(99),
2777 memory_total_bytes: Some(100),
2778 ..Default::default()
2779 }],
2780 ..Default::default()
2781 };
2782 let texts = |snapshot: &Snapshot, limits: &AlertsConfig| -> Vec<(Severity, String)> {
2783 alerts_with(snapshot, limits)
2784 .into_iter()
2785 .map(|a| (a.severity, a.text))
2786 .collect()
2787 };
2788 assert_eq!(
2790 texts(&snapshot, &AlertsConfig::default()),
2791 [
2792 (
2793 Severity::Critical,
2794 "OOM killer stopped python3 (likely)".to_owned()
2795 ),
2796 (Severity::Warning, "memory 93% used".to_owned()),
2797 ]
2798 );
2799 let vram = AlertsConfig {
2800 memory_warning_percent: 0.0,
2801 gpu_memory_warning_percent: 98.0,
2802 ..Default::default()
2803 };
2804 snapshot.memory.oom_kills = vec![kill(Some("bash")), kill(None), kill(Some("python3"))];
2805 assert_eq!(
2806 texts(&snapshot, &vram),
2807 [
2808 (
2809 Severity::Critical,
2810 "OOM killer stopped 3 processes (likely python3, bash)".to_owned()
2811 ),
2812 (Severity::Warning, "GPU 1 memory 99% used".to_owned()),
2813 ]
2814 );
2815 snapshot.memory.oom_kills = vec![kill(None)];
2816 assert_eq!(texts(&snapshot, &vram)[0].1, "OOM killer stopped a process");
2817 }
2818
2819 #[test]
2820 fn interface_helpers() {
2821 assert!(is_virtual_interface("veth5a74356"));
2822 assert!(!is_virtual_interface("enp9s0"));
2823 let snapshot = Snapshot {
2824 networks: vec![
2825 NetworkStats {
2826 name: "docker0".to_owned(),
2827 rx_total_bytes: 1 << 40,
2828 ..Default::default()
2829 },
2830 NetworkStats {
2831 name: "enp9s0".to_owned(),
2832 rx_total_bytes: 1 << 30,
2833 ..Default::default()
2834 },
2835 ],
2836 ..Default::default()
2837 };
2838 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
2839 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
2840 }
2841}