1use std::collections::{HashMap, HashSet, VecDeque};
7use std::fs::{self, OpenOptions};
8use std::io::Write;
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime};
11
12use chrono::{DateTime, Local};
13use serde::{Deserialize, Serialize};
14
15use crate::config::AlertsConfig;
16use crate::format;
17use crate::snapshot::{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 Memory,
199 Cpu,
200 Disk,
202 Gpu,
203 Vram,
204}
205
206impl SortKey {
207 pub const ALL: [SortKey; 9] = [
209 SortKey::Pid,
210 SortKey::Name,
211 SortKey::Threads,
212 SortKey::User,
213 SortKey::Memory,
214 SortKey::Cpu,
215 SortKey::Disk,
216 SortKey::Gpu,
217 SortKey::Vram,
218 ];
219
220 pub fn descending_by_default(self) -> bool {
222 !matches!(self, SortKey::Pid | SortKey::Name | SortKey::User)
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct ProcessRow<'a> {
229 pub info: &'a ProcessInfo,
230 pub gpu_percent: Option<f32>,
232 pub vram_bytes: Option<u64>,
234 pub tree_prefix: String,
237}
238
239#[derive(Debug, Default, Clone)]
241pub struct MetricHistory {
242 pub cpu: History,
244 pub memory: History,
246 pub gpus: Vec<History>,
248 pub net_rx: History,
250 pub net_tx: History,
252 pub net_interface: Option<String>,
254}
255
256impl MetricHistory {
257 pub fn record(&mut self, snapshot: &Snapshot) {
259 self.cpu.push(snapshot.cpu.total_percent as f64);
260 let memory = &snapshot.memory;
261 if memory.total_bytes > 0 {
262 self.memory
263 .push(memory.used_bytes as f64 / memory.total_bytes as f64 * 100.0);
264 }
265 self.gpus.resize_with(snapshot.gpus.len(), History::default);
266 for (history, gpu) in self.gpus.iter_mut().zip(&snapshot.gpus) {
267 history.push(gpu.utilization_percent.unwrap_or(0.0) as f64);
268 }
269 if self.net_interface.is_none() {
270 self.net_interface = default_interface(snapshot);
271 }
272 let (rx, tx) = self
273 .net_interface
274 .as_deref()
275 .and_then(|name| snapshot.networks.iter().find(|n| n.name == name))
276 .map(|n| (n.rx_bytes_per_sec, n.tx_bytes_per_sec))
277 .unwrap_or((0.0, 0.0));
278 self.net_rx.push(rx);
279 self.net_tx.push(tx);
280 }
281
282 pub fn select_interface(&mut self, name: &str) {
284 if self.net_interface.as_deref() != Some(name) {
285 self.net_interface = Some(name.to_owned());
286 self.net_rx = History::default();
288 self.net_tx = History::default();
289 }
290 }
291
292 pub fn next_interface(&mut self, snapshot: &Snapshot) -> Option<String> {
295 let names: Vec<&str> = snapshot.networks.iter().map(|n| n.name.as_str()).collect();
296 if names.is_empty() {
297 return None;
298 }
299 let current = self
300 .net_interface
301 .as_deref()
302 .and_then(|name| names.iter().position(|n| *n == name));
303 let next = names[current.map_or(0, |i| (i + 1) % names.len())].to_owned();
304 self.select_interface(&next);
305 Some(next)
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
311#[serde(rename_all = "lowercase")]
312pub enum Severity {
313 Warning,
315 Critical,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Alert {
322 pub severity: Severity,
323 pub text: String,
324}
325
326pub fn alert_key(alert: &Alert) -> String {
330 let mut key = format!("{:?}:", alert.severity);
331 let mut in_number = false;
332 for c in alert.text.chars() {
333 if c.is_ascii_digit() || (in_number && c == '.') {
334 if !in_number {
335 key.push('#');
336 }
337 in_number = true;
338 } else {
339 key.push(c);
340 in_number = false;
341 }
342 }
343 key
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "lowercase")]
349pub enum AlertChange {
350 Raised,
351 Cleared,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct AlertEvent {
357 pub at_ms: u64,
359 pub host: String,
360 pub change: AlertChange,
361 pub alert: Alert,
363}
364
365const ALERT_LOG_LEN: usize = 500;
367
368#[derive(Debug, Default)]
371pub struct AlertLog {
372 events: VecDeque<AlertEvent>,
374 active: HashMap<String, Vec<Alert>>,
376 unread: (usize, Option<Severity>),
378 file: Option<PathBuf>,
380}
381
382pub fn alert_log_path(front_end: &str) -> Option<PathBuf> {
386 dirs::state_dir()
387 .or_else(dirs::data_local_dir)
388 .map(|dir| dir.join("rigtop").join(format!("alerts-{front_end}.jsonl")))
389}
390
391impl AlertLog {
392 pub fn persistent(path: PathBuf) -> Self {
397 let mut log = Self::default();
398 let loaded: Vec<AlertEvent> = fs::read_to_string(&path)
399 .map(|text| {
400 text.lines()
401 .filter_map(|line| serde_json::from_str(line).ok())
402 .collect()
403 })
404 .unwrap_or_default();
405 let keep = loaded.len().saturating_sub(ALERT_LOG_LEN);
406 for event in &loaded {
407 let active = log.active.entry(event.host.clone()).or_default();
408 let key = alert_key(&event.alert);
409 active.retain(|a| alert_key(a) != key);
410 if event.change == AlertChange::Raised {
411 active.push(event.alert.clone());
412 }
413 }
414 log.events = loaded.into_iter().skip(keep).collect();
415 if keep > ALERT_LOG_LEN {
417 let text: String = log
418 .events
419 .iter()
420 .filter_map(|e| serde_json::to_string(e).ok())
421 .map(|line| line + "\n")
422 .collect();
423 let tmp = path.with_extension("jsonl.tmp");
424 let _ = fs::write(&tmp, text).and_then(|()| fs::rename(&tmp, &path));
425 }
426 log.file = Some(path);
427 log
428 }
429
430 fn save(&self, event: &AlertEvent) {
432 let Some(path) = &self.file else {
433 return;
434 };
435 let Ok(line) = serde_json::to_string(event) else {
436 return;
437 };
438 if let Some(dir) = path.parent() {
439 let _ = fs::create_dir_all(dir);
440 }
441 let _ = OpenOptions::new()
443 .create(true)
444 .append(true)
445 .open(path)
446 .and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
447 }
448
449 pub fn update(&mut self, host: &str, alerts: &[Alert], at_ms: u64) {
451 let previous = self.active.remove(host).unwrap_or_default();
452 let keys = |list: &[Alert]| -> HashSet<String> { list.iter().map(alert_key).collect() };
453 let (before, now) = (keys(&previous), keys(alerts));
454 let raised = alerts.iter().filter(|a| !before.contains(&alert_key(a)));
455 let cleared = previous.iter().filter(|a| !now.contains(&alert_key(a)));
456 let mut changes: Vec<(AlertChange, &Alert)> = raised
457 .map(|a| (AlertChange::Raised, a))
458 .chain(cleared.map(|a| (AlertChange::Cleared, a)))
459 .collect();
460 changes.sort_by_key(|(change, _)| *change == AlertChange::Raised);
462 for (change, alert) in changes {
463 if change == AlertChange::Raised {
464 self.unread.0 += 1;
465 self.unread.1 = self.unread.1.max(Some(alert.severity));
466 }
467 if self.events.len() == ALERT_LOG_LEN {
468 self.events.pop_front();
469 }
470 let event = AlertEvent {
471 at_ms,
472 host: host.to_owned(),
473 change,
474 alert: alert.clone(),
475 };
476 self.save(&event);
477 self.events.push_back(event);
478 }
479 self.active.insert(host.to_owned(), alerts.to_vec());
480 }
481
482 pub fn events(&self) -> impl DoubleEndedIterator<Item = &AlertEvent> {
484 self.events.iter()
485 }
486
487 pub fn unread(&self) -> (usize, Option<Severity>) {
489 self.unread
490 }
491
492 pub fn mark_read(&mut self) {
494 self.unread = (0, None);
495 }
496}
497
498pub fn event_time(at_ms: u64, now: SystemTime) -> String {
501 let at: DateTime<Local> = (SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms)).into();
502 let today: DateTime<Local> = now.into();
503 let days = (today.date_naive() - at.date_naive()).num_days();
504 if days == 0 {
505 at.format("%H:%M:%S").to_string()
506 } else if (1..6).contains(&days) {
507 at.format("%a %H:%M").to_string()
508 } else {
509 at.format("%b %d %H:%M").to_string()
510 }
511}
512
513pub fn since_note(since: SystemTime, now: SystemTime) -> String {
516 let secs = now.duration_since(since).map_or(0, |d| d.as_secs());
517 let span = match secs {
518 0..60 => format!("{secs}s"),
519 60..3_600 => format!("{}m", secs / 60),
520 3_600..86_400 => format!("{}h{:02}m", secs / 3_600, secs % 3_600 / 60),
521 _ => format!("{}d{:02}h", secs / 86_400, secs % 86_400 / 3_600),
522 };
523 let at: DateTime<Local> = since.into();
524 let today: DateTime<Local> = now.into();
525 let clock = if at.date_naive() == today.date_naive() {
526 at.format("%H:%M")
527 } else if secs < 6 * 86_400 {
528 at.format("%a %H:%M")
529 } else {
530 at.format("%b %d %H:%M")
531 };
532 format!("{span} (since {clock})")
533}
534
535pub fn version_note(snapshot: &Snapshot) -> Option<String> {
538 let version = &snapshot.host.rigtop_version;
539 (!version.is_empty() && version != env!("CARGO_PKG_VERSION"))
540 .then(|| format!("agent {version}"))
541}
542
543pub fn version_hint(snapshot: &Snapshot) -> String {
545 format!(
546 "this host runs rigtop {}, this machine {}: install the same version on both \
547 for every reading",
548 snapshot.host.rigtop_version,
549 env!("CARGO_PKG_VERSION")
550 )
551}
552
553pub fn alerts(snapshot: &Snapshot) -> Vec<Alert> {
555 alerts_with(snapshot, &AlertsConfig::default())
556}
557
558pub fn alerts_with(snapshot: &Snapshot, limits: &AlertsConfig) -> Vec<Alert> {
562 let reached = |value: f64, limit: f64| limit > 0.0 && value >= limit;
564 let mut alerts = Vec::new();
565 let mut add = |severity, text: String| alerts.push(Alert { severity, text });
566 for fs in &snapshot.raid.btrfs {
567 let name = fs
568 .mount_points
569 .first()
570 .map_or(fs.fsid.as_str(), String::as_str);
571 let missing = fs.device_stats.iter().filter(|d| d.missing).count();
572 if missing > 0 {
573 add(
574 Severity::Critical,
575 format!("btrfs {name}: {missing} device(s) missing"),
576 );
577 }
578 let errors: u64 = fs.device_stats.iter().map(|d| d.total_errors()).sum();
579 if errors > 0 {
580 let devices: Vec<String> = fs
582 .device_stats
583 .iter()
584 .filter(|d| d.total_errors() > 0)
585 .map(|d| d.label())
586 .collect();
587 add(
588 Severity::Critical,
589 format!("btrfs {name}: {errors} I/O errors ({})", devices.join(", ")),
590 );
591 }
592 if let Some(scrub) = &fs.scrub {
593 if scrub.uncorrectable > 0 {
594 add(
595 Severity::Critical,
596 format!(
597 "btrfs {name}: scrub found {} uncorrectable errors",
598 scrub.uncorrectable
599 ),
600 );
601 } else if scrub.errors > 0 {
602 add(
603 Severity::Warning,
604 format!("btrfs {name}: scrub corrected {} errors", scrub.corrected),
605 );
606 }
607 }
608 }
609 for md in &snapshot.raid.md {
610 if md.active && md.is_degraded() {
611 add(
612 Severity::Critical,
613 format!("{} ({}) degraded", md.name, md.level),
614 );
615 } else if !md.active {
616 add(Severity::Warning, format!("{} inactive", md.name));
617 }
618 if let Some(sync) = &md.sync {
619 add(
620 Severity::Warning,
621 format!("{} {} {:.0}%", md.name, sync.action, sync.percent),
622 );
623 }
624 }
625 for pool in &snapshot.raid.zfs {
626 if pool.is_failing() {
627 add(
628 Severity::Critical,
629 format!("zfs {} {}", pool.name, pool.state),
630 );
631 } else if !pool.is_online() {
632 add(
633 Severity::Warning,
634 format!("zfs {} {}", pool.name, pool.state),
635 );
636 }
637 }
638 for unit in &snapshot.failed_units {
639 if !limits.ignored_units.contains(unit) {
640 add(Severity::Warning, format!("systemd: {unit} failed"));
641 }
642 }
643 for smart in &snapshot.smart {
644 if let Some(error) = &smart.error {
645 add(
646 Severity::Warning,
647 format!("{}: SMART unreadable ({error})", smart.device),
648 );
649 } else if smart.passed == Some(false) {
650 add(
651 Severity::Critical,
652 format!("{}: SMART failed", smart.device),
653 );
654 } else if smart.needs_attention() {
655 add(
656 Severity::Critical,
657 format!("{}: SMART warning", smart.device),
658 );
659 }
660 }
661 for fs in &snapshot.filesystems {
662 let used = fs.total_bytes.saturating_sub(fs.available_bytes);
663 let percent = used as f64 / fs.total_bytes.max(1) as f64 * 100.0;
664 let name = fs
665 .mount_points
666 .first()
667 .map_or(fs.device.as_str(), String::as_str);
668 if reached(percent, limits.filesystem_critical_percent) {
669 add(Severity::Critical, format!("{name} {percent:.0}% full"));
670 } else if reached(percent, limits.filesystem_warning_percent) {
671 add(Severity::Warning, format!("{name} {percent:.0}% full"));
672 }
673 }
674 let hottest_cpu = snapshot
675 .cpu
676 .temperatures
677 .iter()
678 .map(|t| t.celsius)
679 .fold(f32::NAN, f32::max);
680 if reached(hottest_cpu as f64, limits.cpu_warning_celsius as f64) {
681 add(Severity::Warning, format!("CPU at {hottest_cpu:.0}°C"));
682 }
683 for gpu in &snapshot.gpus {
684 if let Some(t) = gpu
685 .temperature_c
686 .filter(|t| reached(*t as f64, limits.gpu_warning_celsius as f64))
687 {
688 add(Severity::Warning, format!("GPU {} at {t:.0}°C", gpu.index));
689 }
690 }
691 for (device, celsius) in drive_temperatures(snapshot) {
692 if reached(celsius as f64, limits.drive_warning_celsius as f64) {
693 add(Severity::Warning, format!("{device} at {celsius:.0}°C"));
694 }
695 }
696 alerts.sort_by_key(|a| std::cmp::Reverse(a.severity));
698 alerts
699}
700
701fn drive_temperatures(snapshot: &Snapshot) -> Vec<(String, f32)> {
704 let mut drives: Vec<(String, f32)> = snapshot
705 .smart
706 .iter()
707 .filter_map(|s| Some((s.device.clone(), s.temperature_c?)))
708 .collect();
709 for chip in snapshot.sensors.iter().filter(|c| c.name == "nvme") {
710 let device = chip.key.split_once(':').map_or(chip.key.as_str(), |k| k.1);
712 if drives.iter().any(|(d, _)| d == device) {
713 continue;
714 }
715 let composite = chip
716 .temperatures
717 .iter()
718 .find(|t| t.label == "Composite")
719 .or(chip.temperatures.first());
720 if let Some(t) = composite {
721 drives.push((device.to_owned(), t.celsius));
722 }
723 }
724 drives
725}
726
727#[derive(Debug, Clone, Copy)]
729pub struct ProcessQuery<'q> {
730 pub sort_key: SortKey,
732 pub descending: bool,
734 pub filter: &'q str,
736 pub show_kernel_threads: bool,
738 pub tree: bool,
741}
742
743pub fn process_rows<'a>(snapshot: &'a Snapshot, query: &ProcessQuery<'_>) -> Vec<ProcessRow<'a>> {
745 let mut gpu_usage: HashMap<u32, (Option<f32>, Option<u64>)> = HashMap::new();
747 for gpu in &snapshot.gpus {
748 for p in &gpu.processes {
749 let entry = gpu_usage.entry(p.pid).or_default();
750 if let Some(sm) = p.sm_percent {
751 entry.0 = Some(entry.0.map_or(sm, |v| v.max(sm)));
752 }
753 if let Some(mem) = p.memory_bytes {
754 entry.1 = Some(entry.1.unwrap_or(0) + mem);
755 }
756 }
757 }
758
759 let filter = query.filter.to_lowercase();
760 let matches = |p: &ProcessInfo| {
761 filter.is_empty()
762 || p.name.to_lowercase().contains(&filter)
763 || p.command.to_lowercase().contains(&filter)
764 || p.user.to_lowercase().contains(&filter)
765 || p.pid.to_string() == filter
766 };
767 let candidates = snapshot
768 .processes
769 .iter()
770 .filter(|p| query.show_kernel_threads || !p.kernel_thread);
771 let shown: Vec<&ProcessInfo> = if query.tree && !filter.is_empty() {
772 with_ancestors(candidates.collect(), matches)
773 } else {
774 candidates.filter(|p| matches(p)).collect()
775 };
776 let mut rows: Vec<ProcessRow<'a>> = shown
777 .into_iter()
778 .map(|info| {
779 let (gpu_percent, vram_bytes) = gpu_usage.get(&info.pid).copied().unwrap_or_default();
780 ProcessRow {
781 info,
782 gpu_percent,
783 vram_bytes,
784 tree_prefix: String::new(),
785 }
786 })
787 .collect();
788
789 let key = query.sort_key;
790 rows.sort_by(|a, b| {
791 let ordering = match key {
792 SortKey::Pid => a.info.pid.cmp(&b.info.pid),
793 SortKey::Name => a.info.name.to_lowercase().cmp(&b.info.name.to_lowercase()),
794 SortKey::Threads => a.info.threads.cmp(&b.info.threads),
795 SortKey::User => a.info.user.cmp(&b.info.user),
796 SortKey::Memory => a.info.memory_bytes.cmp(&b.info.memory_bytes),
797 SortKey::Cpu => a.info.cpu_percent.total_cmp(&b.info.cpu_percent),
798 SortKey::Disk => disk_rate(a.info).total_cmp(&disk_rate(b.info)),
799 SortKey::Gpu => a
800 .gpu_percent
801 .unwrap_or(-1.0)
802 .total_cmp(&b.gpu_percent.unwrap_or(-1.0)),
803 SortKey::Vram => a.vram_bytes.cmp(&b.vram_bytes),
804 }
805 .then(a.info.pid.cmp(&b.info.pid));
807 if query.descending {
808 ordering.reverse()
809 } else {
810 ordering
811 }
812 });
813 if query.tree { into_tree(rows) } else { rows }
814}
815
816fn with_ancestors(
819 processes: Vec<&ProcessInfo>,
820 matches: impl Fn(&ProcessInfo) -> bool,
821) -> Vec<&ProcessInfo> {
822 let by_pid: HashMap<u32, &ProcessInfo> = processes.iter().map(|p| (p.pid, *p)).collect();
823 let mut keep: HashSet<u32> = HashSet::new();
824 for process in processes.iter().filter(|p| matches(p)) {
825 let mut current = Some(*process);
826 while let Some(p) = current
828 && keep.insert(p.pid)
829 {
830 current = p.parent_pid.and_then(|pid| by_pid.get(&pid).copied());
831 }
832 }
833 processes
834 .into_iter()
835 .filter(|p| keep.contains(&p.pid))
836 .collect()
837}
838
839fn into_tree(rows: Vec<ProcessRow<'_>>) -> Vec<ProcessRow<'_>> {
843 let index: HashMap<u32, usize> = rows
844 .iter()
845 .enumerate()
846 .map(|(i, row)| (row.info.pid, i))
847 .collect();
848 let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
849 let mut roots = Vec::new();
850 for (i, row) in rows.iter().enumerate() {
851 match row.info.parent_pid.and_then(|pid| index.get(&pid)) {
852 Some(&parent) if parent != i => children[parent].push(i),
853 _ => roots.push(i),
854 }
855 }
856 let mut order: Vec<(usize, String)> = Vec::with_capacity(rows.len());
857 let mut visited = vec![false; rows.len()];
858 let starts: Vec<usize> = roots.into_iter().chain(0..rows.len()).collect();
860 for start in starts {
861 let mut stack: Vec<(usize, String, Option<bool>)> = vec![(start, String::new(), None)];
863 while let Some((i, lead, last)) = stack.pop() {
864 if std::mem::replace(&mut visited[i], true) {
865 continue;
866 }
867 let (prefix, child_lead) = match last {
868 None => (String::new(), String::new()),
869 Some(true) => (format!("{lead}└ "), format!("{lead} ")),
870 Some(false) => (format!("{lead}├ "), format!("{lead}│ ")),
871 };
872 order.push((i, prefix));
873 let kids = &children[i];
874 for (k, &child) in kids.iter().enumerate().rev() {
875 stack.push((child, child_lead.clone(), Some(k + 1 == kids.len())));
876 }
877 }
878 }
879 let mut rows: Vec<Option<ProcessRow<'_>>> = rows.into_iter().map(Some).collect();
880 order
881 .into_iter()
882 .filter_map(|(i, prefix)| {
883 let mut row = rows[i].take()?;
884 row.tree_prefix = prefix;
885 Some(row)
886 })
887 .collect()
888}
889
890pub fn disk_rate(process: &ProcessInfo) -> f64 {
892 process.read_bytes_per_sec + process.write_bytes_per_sec
893}
894
895pub fn disk_rate_text(process: &ProcessInfo) -> String {
898 let rate = disk_rate(process);
899 if rate > 0.0 {
900 format::rate(rate)
901 } else {
902 String::new()
903 }
904}
905
906pub const LHM_HINT: &str = "temperatures need LibreHardwareMonitor";
909
910pub fn lhm_help(error: &str) -> String {
912 format!(
913 "Windows needs LibreHardwareMonitor for temperatures, fans and CPU power.\n\
914 Start it and turn on Options → Remote Web Server → Run.\n\
915 ([lhm] url = \"\" in the configuration hides this note.)\n\n\
916 {error}"
917 )
918}
919
920pub fn signal_name(signal: ProcessSignal) -> &'static str {
922 match signal {
923 ProcessSignal::Terminate => "SIGTERM",
924 ProcessSignal::Kill => "SIGKILL",
925 }
926}
927
928pub fn is_virtual_interface(name: &str) -> bool {
931 VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
932}
933
934pub fn default_interface(snapshot: &Snapshot) -> Option<String> {
936 snapshot
937 .networks
938 .iter()
939 .filter(|n| !is_virtual_interface(&n.name))
940 .max_by_key(|n| n.rx_total_bytes + n.tx_total_bytes)
941 .or_else(|| snapshot.networks.first())
942 .map(|n| n.name.clone())
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::snapshot::{GpuProcess, GpuStats, NetworkStats};
949
950 fn process(pid: u32, name: &str, cpu: f32, kernel: bool) -> ProcessInfo {
951 ProcessInfo {
952 pid,
953 name: name.to_owned(),
954 cpu_percent: cpu,
955 kernel_thread: kernel,
956 ..Default::default()
957 }
958 }
959
960 fn query(sort_key: SortKey) -> ProcessQuery<'static> {
961 ProcessQuery {
962 sort_key,
963 descending: sort_key.descending_by_default(),
964 filter: "",
965 show_kernel_threads: false,
966 tree: false,
967 }
968 }
969
970 #[test]
971 fn history_keeps_newest_samples() {
972 let mut history = History::default();
973 for i in 0..(HISTORY_LEN + 5) {
974 history.push(i as f64);
975 }
976 assert_eq!(
977 history.last(2),
978 vec![(HISTORY_LEN + 3) as f64, (HISTORY_LEN + 4) as f64]
979 );
980 assert_eq!(history.last(HISTORY_LEN * 2).len(), HISTORY_LEN);
981 assert_eq!(history.max_of_last(3), (HISTORY_LEN + 4) as f64);
982 }
983
984 #[test]
985 fn graph_spans_spread_samples_over_the_width() {
986 let second = Duration::from_secs(1);
987 let mut history = History::default();
988 for i in 0..120 {
989 history.push(i as f64);
990 }
991 assert_eq!(
993 history.graph(3, GraphSpan::Fit, second),
994 vec![117.0, 118.0, 119.0]
995 );
996 let minute = history.graph(30, GraphSpan::Secs(60), second);
998 assert_eq!(minute.len(), 30);
999 assert_eq!(minute[0], 60.5);
1000 assert_eq!(minute[29], 118.5);
1001 let stretched = history.graph(120, GraphSpan::Secs(60), Duration::from_secs(2));
1003 assert_eq!(stretched.len(), 120);
1004 assert_eq!(&stretched[..2], &[90.0, 90.0]);
1005 assert_eq!(&stretched[2..4], &[90.125, 90.375]);
1006 assert_eq!(stretched[119], 119.0);
1007 assert!(stretched.windows(2).all(|w| w[0] <= w[1]));
1008 let hour = history.graph(300, GraphSpan::Secs(3600), second);
1010 assert_eq!(hour.len(), 10);
1011 assert_eq!(hour[0], 5.5);
1012 assert!(
1013 History::default()
1014 .graph(10, GraphSpan::Secs(60), second)
1015 .is_empty()
1016 );
1017 }
1018
1019 #[test]
1020 fn long_spans_draw_from_the_averages() {
1021 let second = Duration::from_secs(1);
1022 let mut history = History::default();
1023 for i in 0..7200 {
1025 history.push(if i < 3600 { 0.0 } else { 100.0 });
1026 }
1027 let six = history.graph(90, GraphSpan::Secs(6 * 3600), second);
1029 assert_eq!(six.len(), 30);
1030 assert_eq!((six[0], six[29]), (0.0, 100.0));
1031 let hour = history.graph(60, GraphSpan::Secs(3600), second);
1033 assert!(hour.iter().all(|v| *v == 100.0));
1034 for i in 0..(COARSE_LEN * COARSE_FACTOR + 500) {
1036 history.push(i as f64);
1037 }
1038 assert_eq!(history.coarse.len(), COARSE_LEN);
1039 }
1040
1041 #[test]
1042 fn graph_span_choices_cycle() {
1043 let labels: Vec<String> = std::iter::successors(Some(GraphSpan::Fit), |s| Some(s.next()))
1044 .take(6)
1045 .map(GraphSpan::label)
1046 .collect();
1047 assert_eq!(labels, ["fit", "1m", "5m", "15m", "1h", "6h"]);
1048 assert_eq!(GraphSpan::Secs(86_400).label(), "24h");
1049 assert_eq!(GraphSpan::Secs(86_400).next(), GraphSpan::Fit);
1050 assert_eq!(
1051 GraphSpan::from_secs(GraphSpan::Secs(300).as_secs()),
1052 GraphSpan::Secs(300)
1053 );
1054 assert_eq!(GraphSpan::from_secs(0), GraphSpan::Fit);
1055 assert_eq!(GraphSpan::Secs(7).next(), GraphSpan::Fit);
1057 }
1058
1059 #[test]
1060 fn rows_filter_sort_and_join_gpu_usage() {
1061 let snapshot = Snapshot {
1062 processes: vec![
1063 process(10, "bash", 5.0, false),
1064 process(2, "kworker/0:1", 50.0, true),
1065 process(30, "python", 1.0, false),
1066 ],
1067 gpus: vec![GpuStats {
1068 processes: vec![GpuProcess {
1069 pid: 30,
1070 memory_bytes: Some(1 << 30),
1071 sm_percent: Some(70.0),
1072 }],
1073 ..Default::default()
1074 }],
1075 ..Default::default()
1076 };
1077 let pids = |q: &ProcessQuery| {
1078 process_rows(&snapshot, q)
1079 .iter()
1080 .map(|r| r.info.pid)
1081 .collect::<Vec<_>>()
1082 };
1083 assert_eq!(pids(&query(SortKey::Cpu)), vec![10, 30]);
1084 assert_eq!(pids(&query(SortKey::Vram)), vec![30, 10]);
1085 assert_eq!(
1086 pids(&ProcessQuery {
1087 show_kernel_threads: true,
1088 ..query(SortKey::Cpu)
1089 }),
1090 vec![2, 10, 30]
1091 );
1092 assert_eq!(
1093 pids(&ProcessQuery {
1094 filter: "PYT",
1095 ..query(SortKey::Pid)
1096 }),
1097 vec![30]
1098 );
1099 let rows = process_rows(&snapshot, &query(SortKey::Pid));
1100 assert_eq!(rows[1].vram_bytes, Some(1 << 30));
1101 assert_eq!(rows[1].gpu_percent, Some(70.0));
1102 }
1103
1104 #[test]
1105 fn since_notes_say_how_long_and_since_when() {
1106 let now = SystemTime::now();
1107 let ago = |secs: u64| now - Duration::from_secs(secs);
1108 assert!(since_note(ago(42), now).starts_with("42s (since "));
1109 assert!(since_note(ago(5 * 60), now).starts_with("5m (since "));
1110 assert!(since_note(ago(3 * 3600 + 7 * 60), now).starts_with("3h07m (since "));
1111 assert!(since_note(ago(2 * 86_400 + 3 * 3600), now).starts_with("2d03h (since "));
1112 assert!(since_note(now + Duration::from_secs(5), now).starts_with("0s"));
1114 }
1115
1116 #[test]
1117 fn tree_puts_children_under_parents() {
1118 let child = |pid: u32, parent: u32, name: &str, cpu: f32| ProcessInfo {
1119 parent_pid: Some(parent),
1120 ..process(pid, name, cpu, false)
1121 };
1122 let snapshot = Snapshot {
1123 processes: vec![
1124 process(1, "init", 0.0, false),
1125 child(10, 1, "sshd", 1.0),
1126 child(11, 10, "bash", 2.0),
1127 child(12, 11, "python", 50.0),
1128 child(20, 1, "cron", 3.0),
1129 child(30, 999, "orphan", 4.0),
1131 child(40, 41, "loop-a", 0.0),
1133 child(41, 40, "loop-b", 0.0),
1134 ],
1135 ..Default::default()
1136 };
1137 let tree = |filter| {
1138 process_rows(
1139 &snapshot,
1140 &ProcessQuery {
1141 filter,
1142 tree: true,
1143 ..query(SortKey::Cpu)
1144 },
1145 )
1146 .iter()
1147 .map(|r| format!("{}{}", r.tree_prefix, r.info.name))
1148 .collect::<Vec<_>>()
1149 };
1150 assert_eq!(
1152 tree(""),
1153 vec![
1154 "orphan",
1155 "init",
1156 "├ cron",
1157 "└ sshd",
1158 " └ bash",
1159 " └ python",
1160 "loop-b",
1161 "└ loop-a",
1162 ]
1163 );
1164 assert_eq!(
1166 tree("pyth"),
1167 vec!["init", "└ sshd", " └ bash", " └ python"]
1168 );
1169 }
1170
1171 #[test]
1172 fn disk_column_sums_reads_and_writes() {
1173 let reader = ProcessInfo {
1174 read_bytes_per_sec: 3.0 * 1024.0 * 1024.0,
1175 ..process(10, "reader", 0.0, false)
1176 };
1177 let writer = ProcessInfo {
1178 read_bytes_per_sec: 1024.0 * 1024.0,
1179 write_bytes_per_sec: 4.0 * 1024.0 * 1024.0,
1180 ..process(20, "writer", 0.0, false)
1181 };
1182 let idle = process(30, "idle", 0.0, false);
1183 assert_eq!(disk_rate_text(&writer), "5.0M/s");
1184 assert_eq!(disk_rate_text(&idle), "");
1185 let snapshot = Snapshot {
1186 processes: vec![idle, reader, writer],
1187 ..Default::default()
1188 };
1189 let pids: Vec<u32> = process_rows(&snapshot, &query(SortKey::Disk))
1190 .iter()
1191 .map(|r| r.info.pid)
1192 .collect();
1193 assert_eq!(pids, vec![20, 10, 30]);
1194 }
1195
1196 #[test]
1197 fn metric_history_records_and_switches_interfaces() {
1198 let snapshot = Snapshot {
1199 networks: vec![
1200 NetworkStats {
1201 name: "enp9s0".to_owned(),
1202 rx_total_bytes: 10,
1203 rx_bytes_per_sec: 100.0,
1204 ..Default::default()
1205 },
1206 NetworkStats {
1207 name: "wlp7s0".to_owned(),
1208 tx_bytes_per_sec: 5.0,
1209 ..Default::default()
1210 },
1211 ],
1212 gpus: vec![GpuStats {
1213 utilization_percent: Some(40.0),
1214 ..Default::default()
1215 }],
1216 ..Default::default()
1217 };
1218 let mut history = MetricHistory::default();
1219 history.record(&snapshot);
1220 assert_eq!(history.net_interface.as_deref(), Some("enp9s0"));
1221 assert_eq!(history.net_rx.last(1), vec![100.0]);
1222 assert_eq!(history.gpus[0].last(1), vec![40.0]);
1223 assert_eq!(history.next_interface(&snapshot).as_deref(), Some("wlp7s0"));
1224 assert!(history.net_rx.last(10).is_empty());
1226 history.record(&snapshot);
1227 assert_eq!(history.net_tx.last(1), vec![5.0]);
1228 }
1229
1230 #[test]
1231 fn alerts_flag_storage_problems_first() {
1232 use crate::snapshot::{
1233 BtrfsDeviceStats, BtrfsFilesystem, FilesystemUsage, MdArray, RaidStats, SmartDevice,
1234 ZfsPool,
1235 };
1236 let snapshot = Snapshot {
1237 filesystems: vec![FilesystemUsage {
1238 mount_points: vec!["/data".to_owned()],
1239 total_bytes: 100,
1240 available_bytes: 8,
1241 ..Default::default()
1242 }],
1243 raid: RaidStats {
1244 btrfs: vec![BtrfsFilesystem {
1245 mount_points: vec!["/".to_owned()],
1246 device_stats: vec![BtrfsDeviceStats {
1247 devid: 1,
1248 corruption_errs: 2,
1249 ..Default::default()
1250 }],
1251 ..Default::default()
1252 }],
1253 md: vec![MdArray {
1254 name: "md0".to_owned(),
1255 active: true,
1256 level: "raid1".to_owned(),
1257 expected_devices: Some(2),
1258 working_devices: Some(1),
1259 ..Default::default()
1260 }],
1261 zfs: vec![
1262 ZfsPool {
1263 name: "tank".to_owned(),
1264 state: "DEGRADED".to_owned(),
1265 },
1266 ZfsPool {
1267 name: "cold".to_owned(),
1268 state: "OFFLINE".to_owned(),
1269 },
1270 ZfsPool {
1271 name: "fast".to_owned(),
1272 state: "ONLINE".to_owned(),
1273 },
1274 ],
1275 },
1276 smart: vec![SmartDevice {
1277 device: "sda".to_owned(),
1278 passed: Some(true),
1279 ..Default::default()
1280 }],
1281 ..Default::default()
1282 };
1283 let alerts = alerts(&snapshot);
1284 let texts: Vec<&str> = alerts.iter().map(|a| a.text.as_str()).collect();
1285 assert_eq!(
1286 texts,
1287 vec![
1288 "btrfs /: 2 I/O errors (devid 1)",
1289 "md0 (raid1) degraded",
1290 "zfs tank DEGRADED",
1291 "zfs cold OFFLINE",
1292 "/data 92% full"
1293 ]
1294 );
1295 assert_eq!(alerts[0].severity, Severity::Critical);
1296 assert_eq!(alerts[2].severity, Severity::Critical);
1297 assert_eq!(alerts[3].severity, Severity::Warning);
1298 assert!(super::alerts(&Snapshot::default()).is_empty());
1299 }
1300
1301 #[test]
1302 fn event_times_are_as_short_as_is_clear() {
1303 let now = SystemTime::now();
1304 let ms = |t: SystemTime| {
1305 t.duration_since(SystemTime::UNIX_EPOCH)
1306 .unwrap()
1307 .as_millis() as u64
1308 };
1309 assert_eq!(event_time(ms(now), now).len(), 8);
1311 let week_ago = now - Duration::from_secs(10 * 86_400);
1312 assert!(event_time(ms(week_ago), now).contains(' '));
1313 assert_ne!(event_time(ms(week_ago), now).len(), 8);
1314 }
1315
1316 #[test]
1317 fn alert_history_survives_a_restart() {
1318 let tmp = tempfile::tempdir().unwrap();
1319 let path = tmp.path().join("state/alerts-tui.jsonl");
1320 let degraded = Alert {
1321 severity: Severity::Critical,
1322 text: "md0 (raid1) degraded".to_owned(),
1323 };
1324 let full = Alert {
1325 severity: Severity::Warning,
1326 text: "/srv 92% full".to_owned(),
1327 };
1328 let mut log = AlertLog::persistent(path.clone());
1329 log.update("nas", &[degraded.clone(), full.clone()], 1_000);
1330 log.update("nas", std::slice::from_ref(°raded), 2_000);
1331 assert_eq!(log.events().count(), 3);
1332
1333 let mut log = AlertLog::persistent(path.clone());
1336 assert_eq!(log.events().count(), 3);
1337 assert_eq!(log.unread(), (0, None));
1338 log.update("nas", std::slice::from_ref(°raded), 3_000);
1339 assert_eq!(log.events().count(), 3);
1340 log.update("nas", &[], 4_000);
1341 let last = log.events().last().unwrap();
1342 assert_eq!((last.change, last.at_ms), (AlertChange::Cleared, 4_000));
1343 assert_eq!(AlertLog::persistent(path).events().count(), 4);
1344 }
1345
1346 #[test]
1347 fn failed_units_warn_unless_ignored() {
1348 let snapshot = Snapshot {
1349 failed_units: vec!["nginx.service".to_owned(), "wait-online.service".to_owned()],
1350 ..Default::default()
1351 };
1352 let limits = AlertsConfig {
1353 ignored_units: vec!["wait-online.service".to_owned()],
1354 ..Default::default()
1355 };
1356 let alerts = alerts_with(&snapshot, &limits);
1357 assert_eq!(alerts.len(), 1);
1358 assert_eq!(alerts[0].text, "systemd: nginx.service failed");
1359 assert_eq!(alerts[0].severity, Severity::Warning);
1360 }
1361
1362 #[test]
1363 fn version_notes_only_for_other_versions() {
1364 let mut snapshot = Snapshot::default();
1365 assert_eq!(version_note(&snapshot), None);
1366 snapshot.host.rigtop_version = env!("CARGO_PKG_VERSION").to_owned();
1367 assert_eq!(version_note(&snapshot), None);
1368 snapshot.host.rigtop_version = "0.1.0".to_owned();
1369 assert_eq!(version_note(&snapshot).as_deref(), Some("agent 0.1.0"));
1370 assert!(version_hint(&snapshot).contains("rigtop 0.1.0"));
1371 }
1372
1373 #[test]
1374 fn alert_log_records_alerts_appearing_and_clearing() {
1375 let alert = |severity, text: &str| Alert {
1376 severity,
1377 text: text.to_owned(),
1378 };
1379 let full = alert(Severity::Warning, "/data 91% full");
1380 let errors = alert(Severity::Critical, "btrfs /: 2 I/O errors (nvme1n1)");
1381 let mut log = AlertLog::default();
1382 log.update("nas", std::slice::from_ref(&full), 1_000);
1383 log.update(
1385 "nas",
1386 &[alert(Severity::Warning, "/data 92% full"), errors.clone()],
1387 2_000,
1388 );
1389 log.update("zefat", &[], 2_000);
1390 assert_eq!(log.unread(), (2, Some(Severity::Critical)));
1391 log.mark_read();
1392 log.update("nas", std::slice::from_ref(&errors), 3_000);
1393 assert_eq!(log.unread(), (0, None));
1394 let summary: Vec<(u64, AlertChange, &str)> = log
1395 .events()
1396 .map(|e| (e.at_ms, e.change, e.alert.text.as_str()))
1397 .collect();
1398 assert_eq!(
1399 summary,
1400 [
1401 (1_000, AlertChange::Raised, "/data 91% full"),
1402 (
1403 2_000,
1404 AlertChange::Raised,
1405 "btrfs /: 2 I/O errors (nvme1n1)"
1406 ),
1407 (3_000, AlertChange::Cleared, "/data 92% full"),
1408 ]
1409 );
1410 for i in 0..(ALERT_LOG_LEN as u64 + 10) {
1411 log.update(
1412 "flappy",
1413 &[alert(Severity::Warning, if i % 2 == 0 { "a" } else { "b" })],
1414 i,
1415 );
1416 }
1417 assert_eq!(log.events().count(), ALERT_LOG_LEN);
1418 }
1419
1420 #[test]
1421 fn alert_thresholds_come_from_the_configuration() {
1422 use crate::snapshot::{FilesystemUsage, GpuStats, SensorChip, SmartDevice, Temperature};
1423 let temp = |label: &str, celsius| Temperature {
1424 label: label.to_owned(),
1425 celsius,
1426 ..Default::default()
1427 };
1428 let mut snapshot = Snapshot {
1429 filesystems: vec![FilesystemUsage {
1430 mount_points: vec!["/data".to_owned()],
1431 total_bytes: 100,
1432 available_bytes: 8,
1433 ..Default::default()
1434 }],
1435 gpus: vec![GpuStats {
1436 index: 0,
1437 temperature_c: Some(80.0),
1438 ..Default::default()
1439 }],
1440 smart: vec![SmartDevice {
1441 device: "nvme0".to_owned(),
1442 temperature_c: Some(72.0),
1443 ..Default::default()
1444 }],
1445 sensors: vec![
1447 SensorChip {
1448 key: "nvme:nvme0".to_owned(),
1449 name: "nvme".to_owned(),
1450 temperatures: vec![temp("Composite", 71.0)],
1451 ..Default::default()
1452 },
1453 SensorChip {
1454 key: "nvme:nvme1".to_owned(),
1455 name: "nvme".to_owned(),
1456 temperatures: vec![temp("Sensor 1", 90.0), temp("Composite", 74.0)],
1457 ..Default::default()
1458 },
1459 ],
1460 ..Default::default()
1461 };
1462 snapshot.cpu.temperatures = vec![temp("Tctl", 97.0), temp("Tccd1", 90.0)];
1463 let texts = |limits: &AlertsConfig| -> Vec<String> {
1464 alerts_with(&snapshot, limits)
1465 .into_iter()
1466 .map(|a| a.text)
1467 .collect()
1468 };
1469 assert_eq!(
1470 texts(&AlertsConfig::default()),
1471 [
1472 "/data 92% full",
1473 "CPU at 97°C",
1474 "nvme0 at 72°C",
1475 "nvme1 at 74°C"
1476 ]
1477 );
1478 let custom = AlertsConfig {
1479 filesystem_warning_percent: 0.0,
1480 cpu_warning_celsius: 0.0,
1481 gpu_warning_celsius: 75.0,
1482 drive_warning_celsius: 73.0,
1483 ..Default::default()
1484 };
1485 assert_eq!(texts(&custom), ["GPU 0 at 80°C", "nvme1 at 74°C"]);
1486 let critical = AlertsConfig {
1487 filesystem_critical_percent: 90.0,
1488 ..custom
1489 };
1490 assert_eq!(
1491 alerts_with(&snapshot, &critical)[0],
1492 Alert {
1493 severity: Severity::Critical,
1494 text: "/data 92% full".to_owned()
1495 }
1496 );
1497 }
1498
1499 #[test]
1500 fn interface_helpers() {
1501 assert!(is_virtual_interface("veth5a74356"));
1502 assert!(!is_virtual_interface("enp9s0"));
1503 let snapshot = Snapshot {
1504 networks: vec![
1505 NetworkStats {
1506 name: "docker0".to_owned(),
1507 rx_total_bytes: 1 << 40,
1508 ..Default::default()
1509 },
1510 NetworkStats {
1511 name: "enp9s0".to_owned(),
1512 rx_total_bytes: 1 << 30,
1513 ..Default::default()
1514 },
1515 ],
1516 ..Default::default()
1517 };
1518 assert_eq!(default_interface(&snapshot).as_deref(), Some("enp9s0"));
1519 assert_eq!(signal_name(ProcessSignal::Kill), "SIGKILL");
1520 }
1521}