1use std::collections::{HashMap, HashSet, VecDeque};
7use std::time::{Duration, SystemTime};
8
9use chrono::{DateTime, Local};
10
11use crate::config::AlertsConfig;
12use crate::format;
13use crate::snapshot::{ProcessInfo, Snapshot};
14use crate::source::ProcessSignal;
15
16pub const HISTORY_LEN: usize = 4096;
20
21const VIRTUAL_IFACE_PREFIXES: &[&str] = &["lo", "docker", "veth", "br-", "virbr"];
24
25pub const COARSE_FACTOR: usize = 60;
28pub const COARSE_LEN: usize = 1440;
30
31#[derive(Debug, Default, Clone)]
34pub struct History {
35 samples: VecDeque<f64>,
36 coarse: VecDeque<f64>,
37 pending: (f64, usize),
39}
40
41impl History {
42 pub fn push(&mut self, value: f64) {
44 if self.samples.len() == HISTORY_LEN {
45 self.samples.pop_front();
46 }
47 self.samples.push_back(value);
48 self.pending.0 += value;
49 self.pending.1 += 1;
50 if self.pending.1 == COARSE_FACTOR {
51 if self.coarse.len() == COARSE_LEN {
52 self.coarse.pop_front();
53 }
54 self.coarse.push_back(self.pending.0 / COARSE_FACTOR as f64);
55 self.pending = (0.0, 0);
56 }
57 }
58
59 pub fn last(&self, n: usize) -> Vec<f64> {
61 let skip = self.samples.len().saturating_sub(n);
62 self.samples.iter().skip(skip).copied().collect()
63 }
64
65 pub fn max_of_last(&self, n: usize) -> f64 {
67 self.last(n).into_iter().fold(0.0, f64::max)
68 }
69
70 pub fn graph(&self, columns: usize, span: GraphSpan, interval: Duration) -> Vec<f64> {
77 let GraphSpan::Secs(secs) = span else {
78 return self.last(columns);
79 };
80 let wanted = ((secs as f64 / interval.as_secs_f64().max(0.001)).round() as usize).max(1);
81 if wanted <= HISTORY_LEN {
82 spread(&self.last(wanted), wanted, columns)
83 } else {
84 let wanted = wanted.div_ceil(COARSE_FACTOR);
86 let skip = self.coarse.len().saturating_sub(wanted);
87 let points: Vec<f64> = self.coarse.iter().skip(skip).copied().collect();
88 spread(&points, wanted, columns)
89 }
90 }
91}
92
93fn spread(samples: &[f64], wanted: usize, columns: usize) -> Vec<f64> {
98 if columns == 0 || samples.is_empty() {
99 return Vec::new();
100 }
101 let shown = (columns as f64 * samples.len() as f64 / wanted.max(1) as f64).round() as usize;
103 let shown = shown.clamp(1, columns);
104 let per_column = samples.len() as f64 / shown as f64;
105 if per_column < 1.0 {
106 let last = samples.len() - 1;
108 return (0..shown)
109 .map(|i| {
110 let at = ((i as f64 + 0.5) * per_column - 0.5).clamp(0.0, last as f64);
111 let (below, frac) = (at.floor() as usize, at.fract());
112 let above = (below + 1).min(last);
113 samples[below] * (1.0 - frac) + samples[above] * frac
114 })
115 .collect();
116 }
117 (0..shown)
118 .map(|i| {
119 let end = (((i + 1) as f64 * per_column) as usize).clamp(1, samples.len());
120 let start = ((i as f64 * per_column) as usize).min(end - 1);
121 let part = &samples[start..end];
122 part.iter().sum::<f64>() / part.len() as f64
123 })
124 .collect()
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum GraphSpan {
130 #[default]
133 Fit,
134 Secs(u64),
136}
137
138impl GraphSpan {
139 pub const CHOICES: [GraphSpan; 7] = [
141 GraphSpan::Fit,
142 GraphSpan::Secs(60),
143 GraphSpan::Secs(5 * 60),
144 GraphSpan::Secs(15 * 60),
145 GraphSpan::Secs(60 * 60),
146 GraphSpan::Secs(6 * 60 * 60),
147 GraphSpan::Secs(24 * 60 * 60),
148 ];
149
150 pub fn next(self) -> Self {
152 let index = Self::CHOICES
153 .iter()
154 .position(|c| *c == self)
155 .map_or(0, |i| i + 1);
156 Self::CHOICES[index % Self::CHOICES.len()]
157 }
158
159 pub fn label(self) -> String {
161 match self {
162 GraphSpan::Fit => "fit".to_owned(),
163 GraphSpan::Secs(secs) if secs > 0 && secs % 3600 == 0 => format!("{}h", secs / 3600),
164 GraphSpan::Secs(secs) if secs > 0 && secs % 60 == 0 => format!("{}m", secs / 60),
165 GraphSpan::Secs(secs) => format!("{secs}s"),
166 }
167 }
168
169 pub fn as_secs(self) -> u64 {
171 match self {
172 GraphSpan::Fit => 0,
173 GraphSpan::Secs(secs) => secs,
174 }
175 }
176
177 pub fn from_secs(secs: u64) -> Self {
179 if secs == 0 {
180 GraphSpan::Fit
181 } else {
182 GraphSpan::Secs(secs)
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum SortKey {
190 Pid,
191 Name,
192 Threads,
193 User,
194 Memory,
195 Cpu,
196 Disk,
198 Gpu,
199 Vram,
200}
201
202impl SortKey {
203 pub const ALL: [SortKey; 9] = [
205 SortKey::Pid,
206 SortKey::Name,
207 SortKey::Threads,
208 SortKey::User,
209 SortKey::Memory,
210 SortKey::Cpu,
211 SortKey::Disk,
212 SortKey::Gpu,
213 SortKey::Vram,
214 ];
215
216 pub fn descending_by_default(self) -> bool {
218 !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
219 }
220}
221
222#[derive(Debug, Clone)]
224pub struct ProcessRow<'a> {
225 pub info: &'a ProcessInfo,
226 pub gpu_percent: Option<f32>,
228 pub vram_bytes: Option<u64>,
230 pub tree_prefix: String,
233}
234
235#[derive(Debug, Default, Clone)]
237pub struct MetricHistory {
238 pub cpu: History,
240 pub memory: History,
242 pub gpus: Vec<History>,
244 pub net_rx: History,
246 pub net_tx: History,
248 pub net_interface: Option<String>,
250}
251
252impl MetricHistory {
253 pub fn record(&mut self, snapshot: &Snapshot) {
255 self.cpu.push(snapshot.cpu.total_percent as f64);
256 let memory = &snapshot.memory;
257 if memory.total_bytes > 0 {
258 self.memory
259 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
260 }
261 self.gpus.resize_with(snapshot.gpus.len(), History::default);
262 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
263 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
264 }
265 if self.net_interface.is_none() {
266 self.net_interface = default_interface(snapshot);
267 }
268 let (rx, tx) = self
269 .net_interface
270 .as_deref()
271 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
272 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
273 .unwrap_or((0.0, 0.0));
274 self.net_rx.push(rx);
275 self.net_tx.push(tx);
276 }
277
278 pub fn select_interface(&mut self, name: &str) {
280 if self.net_interface.as_deref() != Some(name) {
281 self.net_interface = Some(name.to_owned());
282 self.net_rx = History::default();
284 self.net_tx = History::default();
285 }
286 }
287
288 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
291 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
292 if names.is_empty() {
293 return None;
294 }
295 let current = self
296 .net_interface
297 .as_deref()
298 .and_then(|name| names.iter().position(|n| *n == name));
299 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
300 self.select_interface(&next);
301 Some(next)
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
307pub enum Severity {
308 Warning,
310 Critical,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct Alert {
317 pub severity: Severity,
318 pub text: String,
319}
320
321pub fn alert_key(alert: &Alert) -> String {
325 let mut key = format!("{:?}:", alert.severity);
326 let mut in_number = false;
327 for c in alert.text.chars() {
328 if c.is_ascii_digit() || (in_number && c == '.') {
329 if !in_number {
330 key.push('#');
331 }
332 in_number = true;
333 } else {
334 key.push(c);
335 in_number = false;
336 }
337 }
338 key
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum AlertChange {
344 Raised,
345 Cleared,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct AlertEvent {
351 pub at_ms: u64,
353 pub host: String,
354 pub change: AlertChange,
355 pub alert: Alert,
357}
358
359const ALERT_LOG_LEN: usize = 500;
361
362#[derive(Debug, Default)]
365pub struct AlertLog {
366 events: VecDeque<AlertEvent>,
368 active: HashMap<String, Vec<Alert>>,
370 unread: (usize, Option<Severity>),
372}
373
374impl AlertLog {
375 pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) {
377 let previous = self.active.remove(host).unwrap_or_default();
378 let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
379 let (before, now) = (keys(&previous), keys(alerts));
380 let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
381 let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
382 let mut changes: Vec<(AlertChange, &Alert)> = raised
383 .map(|a| (AlertChange::Raised, a))
384 .chain(cleared.map(|a| (AlertChange::Cleared, a)))
385 .collect();
386 changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
388 for (change, alert) in changes {
389 if change == AlertChange::Raised {
390 self.unread.0 += 1;
391 self.unread.1 = self.unread.1.max(Some(alert.severity));
392 }
393 if self.events.len() == ALERT_LOG_LEN {
394 self.events.pop_front();
395 }
396 self.events.push_back(AlertEvent {
397 at_ms,
398 host: host.to_owned(),
399 change,
400 alert: alert.clone(),
401 });
402 }
403 self.active.insert(host.to_owned(), alerts.to_vec());
404 }
405
406 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
408 self.events.iter()
409 }
410
411 pub fn unread(&self) -> (usize, Option<Severity>) {
413 self.unread
414 }
415
416 pub fn mark_read(&mut self) {
418 self.unread = (0, None);
419 }
420}
421
422pub fn since_note(since: SystemTime, now: SystemTime) -> String {
425 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
426 let span = match secs {
427 0..60 => format!("{secs}s"),
428 60..3_600 => format!("{}m", secs / 60),
429 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
430 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
431 };
432 let at: DateTime<Local> = since.into();
433 let today: DateTime<Local> = now.into();
434 let clock = if at.date_naive() == today.date_naive() {
435 at.format("%H:%M")
436 } else if secs < 6 * 86_400 {
437 at.format("%a %H:%M")
438 } else {
439 at.format("%b %d %H:%M")
440 };
441 format!("{span} (since {clock})")
442}
443
444pub fn version_note(snapshot: &Snapshot) -> Option<String> {
447 let version = &snapshot.host.rigtop_version;
448 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
449 .then(|| format!("agent {version}"))
450}
451
452pub fn version_hint(snapshot: &Snapshot) -> String {
454 format!(
455 "this host runs rigtop {}, this machine {}: install the same version on both \
456 for every reading",
457 snapshot.host.rigtop_version,
458 env!("CARGO_PKG_VERSION")
459 )
460}
461
462pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
464 alerts_with(snapshot, &AlertsConfig::default())
465}
466
467pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
471 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
473 let mut alerts = Vec::new();
474 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
475 for fs in &snapshot.raid.btrfs {
476 let name = fs
477 .mount_points
478 .first()
479 .map_or(fs.fsid.as_str(), String::as_str);
480 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
481 if missing > 0 {
482 add(
483 Severity::Critical,
484 format!("btrfs {name}: {missing} device(s) missing"),
485 );
486 }
487 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
488 if errors > 0 {
489 let devices: Vec<String> = fs
491 .device_stats
492 .iter()
493 .filter(|d| d.total_errors() > 0)
494 .map(|d| d.label())
495 .collect();
496 add(
497 Severity::Critical,
498 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
499 );
500 }
501 if let Some(scrub) = &fs.scrub {
502 if scrub.uncorrectable > 0 {
503 add(
504 Severity::Critical,
505 format!(
506 "btrfs {name}: scrub found {} uncorrectable errors",
507 scrub.uncorrectable
508 ),
509 );
510 } else if scrub.errors > 0 {
511 add(
512 Severity::Warning,
513 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
514 );
515 }
516 }
517 }
518 for md in &snapshot.raid.md {
519 if md.active && md.is_degraded() {
520 add(
521 Severity::Critical,
522 format!("{} ({}) degraded", md.name, md.level),
523 );
524 } else if !md.active {
525 add(Severity::Warning, format!("{} inactive", md.name));
526 }
527 if let Some(sync) = &md.sync {
528 add(
529 Severity::Warning,
530 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
531 );
532 }
533 }
534 for smart in &snapshot.smart {
535 if let Some(error) = &smart.error {
536 add(
537 Severity::Warning,
538 format!("{}: SMART unreadable ({error})", smart.device),
539 );
540 } else if smart.passed == Some(false) {
541 add(
542 Severity::Critical,
543 format!("{}: SMART failed", smart.device),
544 );
545 } else if smart.needs_attention() {
546 add(
547 Severity::Critical,
548 format!("{}: SMART warning", smart.device),
549 );
550 }
551 }
552 for fs in &snapshot.filesystems {
553 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
554 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
555 let name = fs
556 .mount_points
557 .first()
558 .map_or(fs.device.as_str(), String::as_str);
559 if reached(percent, limits.filesystem_critical_percent) {
560 add(Severity::Critical, format!("{name} {percent:.0}% full"));
561 } else if reached(percent, limits.filesystem_warning_percent) {
562 add(Severity::Warning, format!("{name} {percent:.0}% full"));
563 }
564 }
565 let hottest_cpu = snapshot
566 .cpu
567 .temperatures
568 .iter()
569 .map(|t| t.celsius)
570 .fold(f32::NAN, f32::max);
571 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
572 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
573 }
574 for gpu in &snapshot.gpus {
575 if let Some(t) = gpu
576 .temperature_c
577 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
578 {
579 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
580 }
581 }
582 for (device, celsius) in drive_temperatures(snapshot) {
583 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
584 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
585 }
586 }
587 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
589 alerts
590}
591
592fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
595 let mut drives: Vec<(String, f32)> = snapshot
596 .smart
597 .iter()
598 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
599 .collect();
600 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
601 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
603 if drives.iter().any(|(d, _)| d == device) {
604 continue;
605 }
606 let composite = chip
607 .temperatures
608 .iter()
609 .find(|t| t.label == "Composite")
610 .or(chip.temperatures.first());
611 if let Some(t) = composite {
612 drives.push((device.to_owned(), t.celsius));
613 }
614 }
615 drives
616}
617
618#[derive(Debug, Clone, Copy)]
620pub struct ProcessQuery<'q> {
621 pub sort_key: SortKey,
623 pub descending: bool,
625 pub filter: &'q str,
627 pub show_kernel_threads: bool,
629 pub tree: bool,
632}
633
634pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
636 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
638 for gpu in &snapshot.gpus {
639 for p in &gpu.processes {
640 let entry = gpu_usage.entry(p.pid).or_default();
641 if let Some(sm) = p.sm_percent {
642 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
643 }
644 if let Some(mem) = p.memory_bytes {
645 entry.1 = Some(entry.1.unwrap_or(0) + mem);
646 }
647 }
648 }
649
650 let filter = query.filter.to_lowercase();
651 let matches = |p: &ProcessInfo| {
652 filter.is_empty()
653 || p.name.to_lowercase().contains(&filter)
654 || p.command.to_lowercase().contains(&filter)
655 || p.user.to_lowercase().contains(&filter)
656 || p.pid.to_string() == filter
657 };
658 let candidates = snapshot
659 .processes
660 .iter()
661 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
662 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
663 with_ancestors(candidates.collect(), matches)
664 } else {
665 candidates.filter(|p| matches(p)).collect()
666 };
667 let mut rows: Vec<ProcessRow<'a>> = shown
668 .into_iter()
669 .map(|info| {
670 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
671 ProcessRow {
672 info,
673 gpu_percent,
674 vram_bytes,
675 tree_prefix: String::new(),
676 }
677 })
678 .collect();
679
680 let key = query.sort_key;
681 rows.sort_by(|a, b| {
682 let ordering = match key {
683 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
684 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
685 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
686 SortKey::User => a.info.user.cmp(&b.info.user),
687 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
688 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
689 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
690 SortKey::Gpu => a
691 .gpu_percent
692 .unwrap_or(-1.0)
693 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
694 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
695 }
696 .then(a.info.pid.cmp(&b.info.pid));
698 if query.descending {
699 ordering.reverse()
700 } else {
701 ordering
702 }
703 });
704 if query.tree { into_tree(rows) } else { rows }
705}
706
707fn with_ancestors(
710 processes: Vec<&ProcessInfo>,
711 matches: impl Fn(&ProcessInfo) -> bool,
712) -> Vec<&ProcessInfo> {
713 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
714 let mut keep: HashSet<u32> = HashSet::new();
715 for process in processes.iter().filter(|p| matches(p)) {
716 let mut current = Some(*process);
717 while let Some(p) = current
719 && keep.insert(p.pid)
720 {
721 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
722 }
723 }
724 processes
725 .into_iter()
726 .filter(|p| keep.contains(&p.pid))
727 .collect()
728}
729
730fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
734 let index: HashMap<u32, usize> = rows
735 .iter()
736 .enumerate()
737 .map(|(i, row)| (row.info.pid, i))
738 .collect();
739 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
740 let mut roots = Vec::new();
741 for (i, row) in rows.iter().enumerate() {
742 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
743 Some(&parent) if parent != i => children[parent].push(i),
744 _ => roots.push(i),
745 }
746 }
747 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
748 let mut visited = vec![false; rows.len()];
749 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
751 for start in starts {
752 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
754 while let Some((i, lead, last)) = stack.pop() {
755 if std::mem::replace(&mut visited[i], true) {
756 continue;
757 }
758 let (prefix, child_lead) = match last {
759 None => (String::new(), String::new()),
760 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
761 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
762 };
763 order.push((i, prefix));
764 let kids = &children[i];
765 for (k, &child) in kids.iter().enumerate().rev() {
766 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
767 }
768 }
769 }
770 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
771 order
772 .into_iter()
773 .filter_map(|(i, prefix)| {
774 let mut row = rows[i].take()?;
775 row.tree_prefix = prefix;
776 Some(row)
777 })
778 .collect()
779}
780
781pub fn disk_rate(process: &ProcessInfo) -> f64 {
783 process.read_bytes_per_sec + process.write_bytes_per_sec
784}
785
786pub fn disk_rate_text(process: &ProcessInfo) -> String {
789 let rate = disk_rate(process);
790 if rate > 0.0 {
791 format::rate(rate)
792 } else {
793 String::new()
794 }
795}
796
797pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
800
801pub fn lhm_help(error: &str) -> String {
803 format!(
804 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
805 Start it and turn on Options → Remote Web Server → Run.\n\
806 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
807 {error}"
808 )
809}
810
811pub fn signal_name(signal: ProcessSignal) -> &'static str {
813 match signal {
814 ProcessSignal::Terminate => "SIGTERM",
815 ProcessSignal::Kill => "SIGKILL",
816 }
817}
818
819pub fn is_virtual_interface(name: &str) -> bool {
822 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
823}
824
825pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
827 snapshot
828 .networks
829 .iter()
830 .filter(|n| !is_virtual_interface(&n.name))
831 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
832 .or_else(|| snapshot.networks.first())
833 .map(|n| n.name.clone())
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
840
841 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
842 ProcessInfo {
843 pid,
844 name: name.to_owned(),
845 cpu_percent: cpu,
846 kernel_thread: kernel,
847 ..Default::default()
848 }
849 }
850
851 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
852 ProcessQuery {
853 sort_key,
854 descending: sort_key.descending_by_default(),
855 filter: "",
856 show_kernel_threads: false,
857 tree: false,
858 }
859 }
860
861 #[test]
862 fn history_keeps_newest_samples() {
863 let mut history = History::default();
864 for i in 0..(HISTORY_LEN + 5) {
865 history.push(i as f64);
866 }
867 assert_eq!(
868 history.last(2),
869 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
870 );
871 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
872 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
873 }
874
875 #[test]
876 fn graph_spans_spread_samples_over_the_width() {
877 let second = Duration::from_secs(1);
878 let mut history = History::default();
879 for i in 0..120 {
880 history.push(i as f64);
881 }
882 assert_eq!(
884 history.graph(3, GraphSpan::Fit, second),
885 vec![117.0, 118.0, 119.0]
886 );
887 let minute = history.graph(30, GraphSpan::Secs(60), second);
889 assert_eq!(minute.len(), 30);
890 assert_eq!(minute[0], 60.5);
891 assert_eq!(minute[29], 118.5);
892 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
894 assert_eq!(stretched.len(), 120);
895 assert_eq!(&stretched[..2], &[90.0, 90.0]);
896 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
897 assert_eq!(stretched[119], 119.0);
898 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
899 let hour = history.graph(300, GraphSpan::Secs(3600), second);
901 assert_eq!(hour.len(), 10);
902 assert_eq!(hour[0], 5.5);
903 assert!(
904 History::default()
905 .graph(10, GraphSpan::Secs(60), second)
906 .is_empty()
907 );
908 }
909
910 #[test]
911 fn long_spans_draw_from_the_averages() {
912 let second = Duration::from_secs(1);
913 let mut history = History::default();
914 for i in 0..7200 {
916 history.push(if i < 3600 { 0.0 } else { 100.0 });
917 }
918 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
920 assert_eq!(six.len(), 30);
921 assert_eq!((six[0], six[29]), (0.0, 100.0));
922 let hour = history.graph(60, GraphSpan::Secs(3600), second);
924 assert!(hour.iter().all(|v| *v == 100.0));
925 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
927 history.push(i as f64);
928 }
929 assert_eq!(history.coarse.len(), COARSE_LEN);
930 }
931
932 #[test]
933 fn graph_span_choices_cycle() {
934 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
935 .take(6)
936 .map(GraphSpan::label)
937 .collect();
938 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
939 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
940 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
941 assert_eq!(
942 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
943 GraphSpan::Secs(300)
944 );
945 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
946 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
948 }
949
950 #[test]
951 fn rows_filter_sort_and_join_gpu_usage() {
952 let snapshot = Snapshot {
953 processes: vec![
954 process(10, "bash", 5.0, false),
955 process(2, "kworker/0:1", 50.0, true),
956 process(30, "python", 1.0, false),
957 ],
958 gpus: vec![GpuStats {
959 processes: vec![GpuProcess {
960 pid: 30,
961 memory_bytes: Some(1 << 30),
962 sm_percent: Some(70.0),
963 }],
964 ..Default::default()
965 }],
966 ..Default::default()
967 };
968 let pids = |q: &ProcessQuery| {
969 process_rows(&snapshot, q)
970 .iter()
971 .map(|r| r.info.pid)
972 .collect::<Vec<_>>()
973 };
974 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
975 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
976 assert_eq!(
977 pids(&ProcessQuery {
978 show_kernel_threads: true,
979 ..query(SortKey::Cpu)
980 }),
981 vec![2, 10, 30]
982 );
983 assert_eq!(
984 pids(&ProcessQuery {
985 filter: "PYT",
986 ..query(SortKey::Pid)
987 }),
988 vec![30]
989 );
990 let rows = process_rows(&snapshot, &query(SortKey::Pid));
991 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
992 assert_eq!(rows[1].gpu_percent, Some(70.0));
993 }
994
995 #[test]
996 fn since_notes_say_how_long_and_since_when() {
997 let now = SystemTime::now();
998 let ago = |secs: u64| now - Duration::from_secs(secs);
999 assert!(since_note(ago(42), now).starts_with("42s (since "));
1000 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1001 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1002 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1003 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1005 }
1006
1007 #[test]
1008 fn tree_puts_children_under_parents() {
1009 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1010 parent_pid: Some(parent),
1011 ..process(pid, name, cpu, false)
1012 };
1013 let snapshot = Snapshot {
1014 processes: vec![
1015 process(1, "init", 0.0, false),
1016 child(10, 1, "sshd", 1.0),
1017 child(11, 10, "bash", 2.0),
1018 child(12, 11, "python", 50.0),
1019 child(20, 1, "cron", 3.0),
1020 child(30, 999, "orphan", 4.0),
1022 child(40, 41, "loop-a", 0.0),
1024 child(41, 40, "loop-b", 0.0),
1025 ],
1026 ..Default::default()
1027 };
1028 let tree = |filter| {
1029 process_rows(
1030 &snapshot,
1031 &ProcessQuery {
1032 filter,
1033 tree: true,
1034 ..query(SortKey::Cpu)
1035 },
1036 )
1037 .iter()
1038 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1039 .collect::<Vec<_>>()
1040 };
1041 assert_eq!(
1043 tree(""),
1044 vec![
1045 "orphan",
1046 "init",
1047 "├ cron",
1048 "└ sshd",
1049 " └ bash",
1050 " └ python",
1051 "loop-b",
1052 "└ loop-a",
1053 ]
1054 );
1055 assert_eq!(
1057 tree("pyth"),
1058 vec!["init", "└ sshd", " └ bash", " └ python"]
1059 );
1060 }
1061
1062 #[test]
1063 fn disk_column_sums_reads_and_writes() {
1064 let reader = ProcessInfo {
1065 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1066 ..process(10, "reader", 0.0, false)
1067 };
1068 let writer = ProcessInfo {
1069 read_bytes_per_sec: 1024.0 * 1024.0,
1070 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1071 ..process(20, "writer", 0.0, false)
1072 };
1073 let idle = process(30, "idle", 0.0, false);
1074 assert_eq!(disk_rate_text(&writer), "5.0M/s");
1075 assert_eq!(disk_rate_text(&idle), "");
1076 let snapshot = Snapshot {
1077 processes: vec![idle, reader, writer],
1078 ..Default::default()
1079 };
1080 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1081 .iter()
1082 .map(|r| r.info.pid)
1083 .collect();
1084 assert_eq!(pids, vec![20, 10, 30]);
1085 }
1086
1087 #[test]
1088 fn metric_history_records_and_switches_interfaces() {
1089 let snapshot = Snapshot {
1090 networks: vec![
1091 NetworkStats {
1092 name: "enp9s0".to_owned(),
1093 rx_total_bytes: 10,
1094 rx_bytes_per_sec: 100.0,
1095 ..Default::default()
1096 },
1097 NetworkStats {
1098 name: "wlp7s0".to_owned(),
1099 tx_bytes_per_sec: 5.0,
1100 ..Default::default()
1101 },
1102 ],
1103 gpus: vec![GpuStats {
1104 utilization_percent: Some(40.0),
1105 ..Default::default()
1106 }],
1107 ..Default::default()
1108 };
1109 let mut history = MetricHistory::default();
1110 history.record(&snapshot);
1111 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1112 assert_eq!(history.net_rx.last(1), vec![100.0]);
1113 assert_eq!(history.gpus[0].last(1), vec![40.0]);
1114 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1115 assert!(history.net_rx.last(10).is_empty());
1117 history.record(&snapshot);
1118 assert_eq!(history.net_tx.last(1), vec![5.0]);
1119 }
1120
1121 #[test]
1122 fn alerts_flag_storage_problems_first() {
1123 use crate::snapshot::{
1124 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1125 };
1126 let snapshot = Snapshot {
1127 filesystems: vec![FilesystemUsage {
1128 mount_points: vec!["/data".to_owned()],
1129 total_bytes: 100,
1130 available_bytes: 8,
1131 ..Default::default()
1132 }],
1133 raid: RaidStats {
1134 btrfs: vec![BtrfsFilesystem {
1135 mount_points: vec!["/".to_owned()],
1136 device_stats: vec![BtrfsDeviceStats {
1137 devid: 1,
1138 corruption_errs: 2,
1139 ..Default::default()
1140 }],
1141 ..Default::default()
1142 }],
1143 md: vec![MdArray {
1144 name: "md0".to_owned(),
1145 active: true,
1146 level: "raid1".to_owned(),
1147 expected_devices: Some(2),
1148 working_devices: Some(1),
1149 ..Default::default()
1150 }],
1151 },
1152 smart: vec![SmartDevice {
1153 device: "sda".to_owned(),
1154 passed: Some(true),
1155 ..Default::default()
1156 }],
1157 ..Default::default()
1158 };
1159 let alerts = alerts(&snapshot);
1160 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1161 assert_eq!(
1162 texts,
1163 vec![
1164 "btrfs /: 2 I/O errors (devid 1)",
1165 "md0 (raid1) degraded",
1166 "/data 92% full"
1167 ]
1168 );
1169 assert_eq!(alerts[0].severity, Severity::Critical);
1170 assert_eq!(alerts[2].severity, Severity::Warning);
1171 assert!(super::alerts(&Snapshot::default()).is_empty());
1172 }
1173
1174 #[test]
1175 fn version_notes_only_for_other_versions() {
1176 let mut snapshot = Snapshot::default();
1177 assert_eq!(version_note(&snapshot), None);
1178 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1179 assert_eq!(version_note(&snapshot), None);
1180 snapshot.host.rigtop_version = "0.1.0".to_owned();
1181 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1182 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1183 }
1184
1185 #[test]
1186 fn alert_log_records_alerts_appearing_and_clearing() {
1187 let alert = |severity, text: &str| Alert {
1188 severity,
1189 text: text.to_owned(),
1190 };
1191 let full = alert(Severity::Warning, "/data 91% full");
1192 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1193 let mut log = AlertLog::default();
1194 log.update("nas", std::slice::from_ref(&full), 1_000);
1195 log.update(
1197 "nas",
1198 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1199 2_000,
1200 );
1201 log.update("zefat", &[], 2_000);
1202 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1203 log.mark_read();
1204 log.update("nas", std::slice::from_ref(&errors), 3_000);
1205 assert_eq!(log.unread(), (0, None));
1206 let summary: Vec<(u64, AlertChange, &str)> = log
1207 .events()
1208 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1209 .collect();
1210 assert_eq!(
1211 summary,
1212 [
1213 (1_000, AlertChange::Raised, "/data 91% full"),
1214 (
1215 2_000,
1216 AlertChange::Raised,
1217 "btrfs /: 2 I/O errors (nvme1n1)"
1218 ),
1219 (3_000, AlertChange::Cleared, "/data 92% full"),
1220 ]
1221 );
1222 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1223 log.update(
1224 "flappy",
1225 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1226 i,
1227 );
1228 }
1229 assert_eq!(log.events().count(), ALERT_LOG_LEN);
1230 }
1231
1232 #[test]
1233 fn alert_thresholds_come_from_the_configuration() {
1234 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1235 let temp = |label: &str, celsius| Temperature {
1236 label: label.to_owned(),
1237 celsius,
1238 ..Default::default()
1239 };
1240 let mut snapshot = Snapshot {
1241 filesystems: vec![FilesystemUsage {
1242 mount_points: vec!["/data".to_owned()],
1243 total_bytes: 100,
1244 available_bytes: 8,
1245 ..Default::default()
1246 }],
1247 gpus: vec![GpuStats {
1248 index: 0,
1249 temperature_c: Some(80.0),
1250 ..Default::default()
1251 }],
1252 smart: vec![SmartDevice {
1253 device: "nvme0".to_owned(),
1254 temperature_c: Some(72.0),
1255 ..Default::default()
1256 }],
1257 sensors: vec![
1259 SensorChip {
1260 key: "nvme:nvme0".to_owned(),
1261 name: "nvme".to_owned(),
1262 temperatures: vec![temp("Composite", 71.0)],
1263 ..Default::default()
1264 },
1265 SensorChip {
1266 key: "nvme:nvme1".to_owned(),
1267 name: "nvme".to_owned(),
1268 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1269 ..Default::default()
1270 },
1271 ],
1272 ..Default::default()
1273 };
1274 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1275 let texts = |limits: &AlertsConfig| -> Vec<String> {
1276 alerts_with(&snapshot, limits)
1277 .into_iter()
1278 .map(|a| a.text)
1279 .collect()
1280 };
1281 assert_eq!(
1282 texts(&AlertsConfig::default()),
1283 [
1284 "/data 92% full",
1285 "CPU at 97°C",
1286 "nvme0 at 72°C",
1287 "nvme1 at 74°C"
1288 ]
1289 );
1290 let custom = AlertsConfig {
1291 filesystem_warning_percent: 0.0,
1292 cpu_warning_celsius: 0.0,
1293 gpu_warning_celsius: 75.0,
1294 drive_warning_celsius: 73.0,
1295 ..Default::default()
1296 };
1297 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1298 let critical = AlertsConfig {
1299 filesystem_critical_percent: 90.0,
1300 ..custom
1301 };
1302 assert_eq!(
1303 alerts_with(&snapshot, &critical)[0],
1304 Alert {
1305 severity: Severity::Critical,
1306 text: "/data 92% full".to_owned()
1307 }
1308 );
1309 }
1310
1311 #[test]
1312 fn interface_helpers() {
1313 assert!(is_virtual_interface("veth5a74356"));
1314 assert!(!is_virtual_interface("enp9s0"));
1315 let snapshot = Snapshot {
1316 networks: vec![
1317 NetworkStats {
1318 name: "docker0".to_owned(),
1319 rx_total_bytes: 1 << 40,
1320 ..Default::default()
1321 },
1322 NetworkStats {
1323 name: "enp9s0".to_owned(),
1324 rx_total_bytes: 1 << 30,
1325 ..Default::default()
1326 },
1327 ],
1328 ..Default::default()
1329 };
1330 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1331 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1332 }
1333}