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