rigtop_core/snapshot.rs
1//! Serializable snapshot of everything rigtop displays.
2//!
3//! A [`Snapshot`] is the single unit of data exchanged between a data source
4//! (local collectors, or a remote `rigtop --agent` over SSH) and a front-end
5//! (TUI or GUI). Every type here derives `Serialize`/`Deserialize` so that the
6//! same structure can be streamed over the wire unchanged.
7//!
8//! Conventions:
9//! - Sizes are in bytes, rates in bytes per second.
10//! - Temperatures are in degrees Celsius, power in watts, fan speed in RPM.
11//! - Percentages are in the range `0.0..=100.0` (per-core values are not summed).
12//! - Values that a platform or device cannot provide are `None` (or an empty
13//! list) rather than zero, so that front-ends can hide them.
14//! - Every struct is `#[serde(default)]`: fields missing from the input take
15//! their default, so a client can read snapshots from an older or newer
16//! `rigtop --agent` as long as fields are only added.
17
18use serde::{Deserialize, Serialize};
19
20/// One full measurement of a host.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[serde(default)]
23pub struct Snapshot {
24 /// Wall-clock time of the measurement, in milliseconds since the Unix epoch.
25 pub timestamp_ms: u64,
26 /// Static-ish information about the host.
27 pub host: HostInfo,
28 /// CPU load, frequency and temperature.
29 pub cpu: CpuStats,
30 /// Physical memory and swap.
31 pub memory: MemoryStats,
32 /// Discrete and integrated GPUs that could be queried.
33 pub gpus: Vec<GpuStats>,
34 /// Raw hardware sensor chips (temperatures and fans), grouped by chip.
35 pub sensors: Vec<SensorChip>,
36 /// Power draw summary.
37 pub power: PowerStats,
38 /// I/O activity per physical block device.
39 pub disks: Vec<DiskIo>,
40 /// Capacity of mounted filesystems.
41 pub filesystems: Vec<FilesystemUsage>,
42 /// Software RAID state (btrfs and mdadm).
43 pub raid: RaidStats,
44 /// SMART health per drive, from files written by a privileged timer.
45 pub smart: Vec<SmartDevice>,
46 /// Network interface traffic.
47 pub networks: Vec<NetworkStats>,
48 /// Process list. May be truncated by the source (see [`Snapshot::truncate_processes`]).
49 pub processes: Vec<ProcessInfo>,
50 /// Total number of processes on the host, even if `processes` was truncated.
51 pub process_count: usize,
52 /// Windows: why LibreHardwareMonitor, the source of temperatures, fans
53 /// and CPU power, could not be read. `None` when it was read, when it is
54 /// disabled in the configuration, and on other platforms.
55 pub lhm_error: Option<String>,
56}
57
58impl Snapshot {
59 /// Keeps only the `limit` processes with the highest CPU usage.
60 ///
61 /// Used to reduce the payload when a host is shown in an overview rather
62 /// than in detail. Kernel threads are dropped first: the overview does not
63 /// show them, and busy `kworker`s would otherwise take places in the top
64 /// `limit` and leave the list short. `process_count` keeps the original
65 /// total.
66 pub fn truncate_processes(&mut self, limit: usize) {
67 self.processes.retain(|p| !p.kernel_thread);
68 if self.processes.len() <= limit {
69 return;
70 }
71 // Partial sort is enough: only the top `limit` entries matter.
72 self.processes
73 .select_nth_unstable_by(limit, |a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
74 self.processes.truncate(limit);
75 self.processes
76 .sort_unstable_by(|a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
77 }
78}
79
80/// Host identification and uptime.
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82#[serde(default)]
83pub struct HostInfo {
84 /// Host name as reported by the OS.
85 pub hostname: String,
86 /// Human readable OS name and version (e.g. "Linux 26.04 Ubuntu").
87 pub os: String,
88 /// Kernel version string.
89 pub kernel: String,
90 /// Seconds since boot.
91 pub uptime_secs: u64,
92 /// Version of the rigtop that took the snapshot (the agent's, for a
93 /// remote host). Agents before 0.9.1 leave it out; the remote source
94 /// fills it in from the agent's greeting.
95 pub rigtop_version: String,
96}
97
98/// CPU-wide and per-core statistics.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100#[serde(default)]
101pub struct CpuStats {
102 /// Brand string of the first CPU (e.g. "AMD Ryzen 9 9950X3D 16-Core Processor").
103 pub model: String,
104 /// Usage across all logical cores.
105 pub total_percent: f32,
106 /// Usage of each logical core, in OS order.
107 pub per_core_percent: Vec<f32>,
108 /// Current frequency of each logical core in MHz.
109 pub per_core_mhz: Vec<u64>,
110 /// 1, 5 and 15 minute load averages; `None` on platforms without them
111 /// (Windows).
112 pub load_average: Option<[f64; 3]>,
113 /// CPU temperature readings, e.g. `Tctl`, `Tccd1`, `Tccd2` on AMD or
114 /// `Package id 0`, `Core 0`.. on Intel.
115 pub temperatures: Vec<Temperature>,
116}
117
118/// Physical memory and swap usage.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120#[serde(default)]
121pub struct MemoryStats {
122 /// Installed (visible) memory.
123 pub total_bytes: u64,
124 /// Memory in use by processes (total minus available).
125 pub used_bytes: u64,
126 /// Memory that can be allocated without swapping.
127 pub available_bytes: u64,
128 /// Completely unused memory.
129 pub free_bytes: u64,
130 /// Page cache and reclaimable slab, where the platform reports it.
131 pub cached_bytes: Option<u64>,
132 /// Total swap space.
133 pub swap_total_bytes: u64,
134 /// Swap space in use.
135 pub swap_used_bytes: u64,
136}
137
138/// A single GPU.
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140#[serde(default)]
141pub struct GpuStats {
142 /// Position in the GPU list: NVIDIA GPUs first (their NVML index),
143 /// then AMD and Intel ones.
144 pub index: u32,
145 /// Vendor name ("NVIDIA", "AMD", ...).
146 pub vendor: String,
147 /// Marketing name of the device.
148 pub name: String,
149 /// Integrated graphics (an APU or Intel iGPU), whose power is part of
150 /// the CPU package and not reported here.
151 pub integrated: bool,
152 /// Core utilization (unknown for Intel without privileges).
153 pub utilization_percent: Option<f32>,
154 /// Memory controller utilization.
155 pub memory_utilization_percent: Option<f32>,
156 /// VRAM in use.
157 pub memory_used_bytes: Option<u64>,
158 /// Total VRAM.
159 pub memory_total_bytes: Option<u64>,
160 /// Core temperature.
161 pub temperature_c: Option<f32>,
162 /// Current board power draw.
163 pub power_w: Option<f32>,
164 /// Enforced power limit.
165 pub power_limit_w: Option<f32>,
166 /// Fan speed as a percentage of the maximum (first fan).
167 pub fan_percent: Option<f32>,
168 /// Graphics clock in MHz.
169 pub clock_mhz: Option<u32>,
170 /// Processes currently using this GPU.
171 pub processes: Vec<GpuProcess>,
172}
173
174/// GPU usage attributed to one process.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[serde(default)]
177pub struct GpuProcess {
178 /// OS process id.
179 pub pid: u32,
180 /// VRAM used by the process, if the driver reports it.
181 pub memory_bytes: Option<u64>,
182 /// Streaming multiprocessor (core) utilization by the process.
183 pub sm_percent: Option<f32>,
184}
185
186/// A hardware monitoring chip (a Linux hwmon device) and its readings.
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188#[serde(default)]
189pub struct SensorChip {
190 /// Stable identifier, `"<driver name>:<device name>"`
191 /// (e.g. `"nct6686:nct6683.2592"`, `"nvme:nvme0"`).
192 ///
193 /// Unlike `hwmonN`, this survives reboots, so it is used as the key for
194 /// user-defined labels.
195 pub key: String,
196 /// Driver-provided chip name (e.g. `"k10temp"`, `"nvme"`, `"spd5118"`).
197 pub name: String,
198 /// Temperature channels.
199 pub temperatures: Vec<Temperature>,
200 /// Fan channels.
201 pub fans: Vec<Fan>,
202}
203
204/// One temperature channel.
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[serde(default)]
207pub struct Temperature {
208 /// Stable key `"<chip key>/temp<N>"` used to look up user-defined names.
209 /// Empty for readings that do not come from a hwmon chip.
210 pub key: String,
211 /// Driver label (e.g. `"Tccd1"`), or a generic `"temp<N>"` when none exists.
212 pub label: String,
213 /// Current reading.
214 pub celsius: f32,
215 /// High threshold, if exposed.
216 pub max_celsius: Option<f32>,
217 /// Critical threshold, if exposed.
218 pub critical_celsius: Option<f32>,
219}
220
221/// One fan channel.
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223#[serde(default)]
224pub struct Fan {
225 /// Stable key `"<chip key>/fan<N>"` used to look up user-defined names.
226 pub key: String,
227 /// Driver label, or a generic `"fan<N>"` when none exists.
228 pub label: String,
229 /// Current speed (0 when stopped or not connected).
230 pub rpm: u32,
231}
232
233/// Power draw summary.
234///
235/// There is no portable way to measure whole-system power without external
236/// hardware, so the total is the sum of the CPU package and discrete GPUs,
237/// which dominate consumption on typical workstations.
238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239#[serde(default)]
240pub struct PowerStats {
241 /// CPU package power (RAPL on Linux). `None` before the second sample or
242 /// when RAPL is unavailable.
243 pub cpu_package_w: Option<f32>,
244 /// Sum of power reported by discrete GPUs.
245 pub gpus_w: Option<f32>,
246 /// Power an AMD APU's graphics driver reports (amdgpu `PPT`). Per the
247 /// kernel documentation it covers the whole SoC, CPU cores included, so
248 /// it overlaps the CPU package and only stands in for it when RAPL cannot
249 /// be read (see [`PowerStats::cpu_or_soc_w`]).
250 pub igpu_w: Option<f32>,
251 /// `cpu_package_w` (or `igpu_w` without it) `+ gpus_w`, counting only the
252 /// parts that are available.
253 pub total_w: Option<f32>,
254 /// Laptop battery, if there is one.
255 pub battery: Option<BatteryStats>,
256}
257
258/// Below this, an APU reading is not whole-SoC power: desktop Ryzen CPUs
259/// with a small iGPU report only a few milliwatts there, while a laptop
260/// APU's SoC never idles under a watt.
261const MIN_SOC_W: f32 = 1.0;
262
263impl PowerStats {
264 /// The CPU figure to show: `("CPU", RAPL)`, else `("SoC", APU PPT)`.
265 pub fn cpu_or_soc_w(&self) -> (&'static str, Option<f32>) {
266 match (self.cpu_package_w, self.igpu_w) {
267 (Some(w), _) => ("CPU", Some(w)),
268 (None, Some(w)) if w >= MIN_SOC_W => ("SoC", Some(w)),
269 _ => ("CPU", None),
270 }
271 }
272}
273
274/// Charge and power of the system's batteries (summed when there are
275/// several), from `/sys/class/power_supply` on Linux.
276#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
277#[serde(default)]
278pub struct BatteryStats {
279 /// Charge in percent.
280 pub percent: Option<f32>,
281 /// Kernel status: `Charging`, `Discharging`, `Full`, `Not charging`...
282 pub status: String,
283 /// Charge or discharge power (not signed; see `status`).
284 pub power_w: Option<f32>,
285}
286
287impl BatteryStats {
288 /// Short form for status lines: `72% -12W` while discharging, `+20W`
289 /// while charging.
290 pub fn summary(&self) -> String {
291 let percent = self
292 .percent
293 .map_or("--%".to_owned(), |p| format!("{p:.0}%"));
294 let sign = match self.status.as_str() {
295 "Discharging" => "-",
296 "Charging" => "+",
297 _ => return percent,
298 };
299 match self.power_w {
300 Some(w) if w >= 0.5 => format!("{percent} {sign}{w:.0}W"),
301 _ => percent,
302 }
303 }
304}
305
306/// I/O activity of a block device.
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308#[serde(default)]
309pub struct DiskIo {
310 /// Kernel device name (e.g. `"nvme0n1"`, `"sda"`).
311 pub name: String,
312 /// Read throughput.
313 pub read_bytes_per_sec: f64,
314 /// Write throughput.
315 pub write_bytes_per_sec: f64,
316 /// Fraction of time the device had I/O in flight, where the platform
317 /// reports it (Linux).
318 pub busy_percent: Option<f32>,
319 /// Drive temperature (NVMe composite sensor), if available.
320 pub temperature_c: Option<f32>,
321}
322
323/// Capacity of one filesystem.
324///
325/// Filesystems mounted at several places (e.g. btrfs subvolumes) appear once,
326/// with all mount points listed.
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328#[serde(default)]
329pub struct FilesystemUsage {
330 /// Source device (e.g. `"/dev/nvme1n1p1"`).
331 pub device: String,
332 /// Filesystem type (e.g. `"btrfs"`, `"ext4"`).
333 pub fs_type: String,
334 /// All mount points of this filesystem, shortest first.
335 pub mount_points: Vec<String>,
336 /// Total size.
337 pub total_bytes: u64,
338 /// Space available to unprivileged users.
339 pub available_bytes: u64,
340}
341
342/// Returns true if `device` (a disk such as `"sda"`, or an NVMe controller
343/// such as `"nvme0"`) is the drive behind the block device `disk`
344/// (`"sda"`, `"nvme0n1"`).
345///
346/// `"nvme1"` does not match `"nvme10n1"`: after the controller name only the
347/// namespace suffix (`n<N>`) may follow.
348pub fn device_matches_disk(device: &str, disk: &str) -> bool {
349 match disk.strip_prefix(device) {
350 Some("") => true,
351 Some(rest) => device.starts_with("nvme") && rest.starts_with('n'),
352 None => false,
353 }
354}
355
356/// SMART health of one drive, parsed from `smartctl -j -a` output.
357///
358/// Fields a drive or protocol does not report are `None` (NVMe drives have
359/// no sector reallocation counters, ATA drives no "percentage used").
360#[derive(Debug, Clone, Default, Serialize, Deserialize)]
361#[serde(default)]
362pub struct SmartDevice {
363 /// Kernel device name (`"nvme0"`, `"sda"`). See [`device_matches_disk`].
364 pub device: String,
365 /// Protocol reported by smartctl (`"NVMe"`, `"ATA"`, `"SCSI"`).
366 pub protocol: String,
367 /// Drive model.
368 pub model: String,
369 /// Overall health self-assessment; `None` if smartctl could not tell.
370 pub passed: Option<bool>,
371 /// Current drive temperature.
372 pub temperature_c: Option<f32>,
373 /// Accumulated power-on time.
374 pub power_on_hours: Option<u64>,
375 /// NVMe wear estimate (may exceed 100).
376 pub percentage_used: Option<u32>,
377 /// NVMe spare capacity left.
378 pub available_spare_percent: Option<u32>,
379 /// NVMe spare threshold below which the drive reports a warning.
380 pub available_spare_threshold_percent: Option<u32>,
381 /// NVMe critical warning bit field (0 when healthy).
382 pub critical_warning: Option<u32>,
383 /// NVMe unrecovered data integrity errors.
384 pub media_errors: Option<u64>,
385 /// ATA attribute 5 (Reallocated_Sector_Ct), raw value.
386 pub reallocated_sectors: Option<u64>,
387 /// ATA attribute 197 (Current_Pending_Sector), raw value.
388 pub pending_sectors: Option<u64>,
389 /// ATA attribute 198 (Offline_Uncorrectable), raw value.
390 pub offline_uncorrectable: Option<u64>,
391 /// Total host writes, where the drive reports them.
392 pub data_written_bytes: Option<u64>,
393 /// When smartctl ran, in milliseconds since the Unix epoch. Lets front-ends
394 /// flag stale results (e.g. the timer stopped running).
395 pub collected_at_ms: Option<u64>,
396 /// Error reported by smartctl (e.g. the device could not be opened).
397 pub error: Option<String>,
398}
399
400impl SmartDevice {
401 /// Returns true if anything in the SMART data calls for attention.
402 pub fn needs_attention(&self) -> bool {
403 let positive = |v: Option<u64>| v.is_some_and(|v| v > 0);
404 let spare_low = match (
405 self.available_spare_percent,
406 self.available_spare_threshold_percent,
407 ) {
408 (Some(spare), Some(threshold)) => spare < threshold,
409 _ => false,
410 };
411 self.passed == Some(false)
412 || self.error.is_some()
413 || self.critical_warning.is_some_and(|w| w != 0)
414 || positive(self.media_errors)
415 || positive(self.reallocated_sectors)
416 || positive(self.pending_sectors)
417 || positive(self.offline_uncorrectable)
418 || self.percentage_used.is_some_and(|p| p >= 100)
419 || spare_low
420 }
421}
422
423/// Software RAID state.
424#[derive(Debug, Clone, Default, Serialize, Deserialize)]
425#[serde(default)]
426pub struct RaidStats {
427 /// Mounted btrfs filesystems.
428 pub btrfs: Vec<BtrfsFilesystem>,
429 /// Linux md (mdadm) arrays.
430 pub md: Vec<MdArray>,
431}
432
433/// A btrfs filesystem as seen in `/sys/fs/btrfs/<fsid>`.
434#[derive(Debug, Clone, Default, Serialize, Deserialize)]
435#[serde(default)]
436pub struct BtrfsFilesystem {
437 /// Filesystem UUID.
438 pub fsid: String,
439 /// Filesystem label (may be empty).
440 pub label: String,
441 /// Mount points found in the mount table for this filesystem.
442 pub mount_points: Vec<String>,
443 /// Member block devices (e.g. `"nvme0n1"`, `"nvme1n1p1"`).
444 pub devices: Vec<String>,
445 /// Per-device error counters, keyed by btrfs device id. Each carries its
446 /// device name when it could be looked up (see [`BtrfsDeviceStats::name`]);
447 /// the list is not aligned with `devices`.
448 pub device_stats: Vec<BtrfsDeviceStats>,
449 /// Allocation per block group type (data, metadata, system).
450 pub allocations: Vec<BtrfsAllocation>,
451 /// Result of the most recent scrub, if it is known. Error counters in
452 /// `device_stats` only grow when data is read, so the scrub time tells
453 /// when all data was last verified.
454 pub scrub: Option<BtrfsScrub>,
455}
456
457/// Outcome of the last btrfs scrub (summed over devices).
458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
459#[serde(default)]
460pub struct BtrfsScrub {
461 /// Start time, in milliseconds since the Unix epoch.
462 pub started_at_ms: u64,
463 /// Run time in seconds.
464 pub duration_secs: u64,
465 /// `"finished"`, `"canceled"` or `"unfinished"` (running or interrupted).
466 pub state: String,
467 /// Bytes verified.
468 pub bytes_scrubbed: u64,
469 /// Read, checksum, verify and superblock errors found.
470 pub errors: u64,
471 /// Errors that were repaired from a good copy.
472 pub corrected: u64,
473 /// Errors that could not be repaired.
474 pub uncorrectable: u64,
475}
476
477impl BtrfsFilesystem {
478 /// Returns true if any member is missing or has a non-zero error counter.
479 pub fn has_problems(&self) -> bool {
480 self.device_stats
481 .iter()
482 .any(|d| d.missing || d.total_errors() > 0)
483 }
484}
485
486/// Error counters of one btrfs member device.
487#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488#[serde(default)]
489pub struct BtrfsDeviceStats {
490 /// btrfs device id (1-based).
491 pub devid: u64,
492 /// Device name (e.g. `"nvme0n1"`), from the `BTRFS_IOC_DEV_INFO` ioctl
493 /// on a mount point; empty when unknown (not mounted, device missing).
494 pub name: String,
495 /// Size of the device as btrfs uses it.
496 pub size_bytes: Option<u64>,
497 /// Space allocated on the device.
498 pub used_bytes: Option<u64>,
499 /// Whether the device is missing from the filesystem.
500 pub missing: bool,
501 /// Write I/O errors.
502 pub write_errs: u64,
503 /// Read I/O errors.
504 pub read_errs: u64,
505 /// Flush errors.
506 pub flush_errs: u64,
507 /// Checksum mismatches.
508 pub corruption_errs: u64,
509 /// Generation mismatches (stale data).
510 pub generation_errs: u64,
511}
512
513impl BtrfsDeviceStats {
514 /// Name for display: the device name, else `devid N`.
515 pub fn label(&self) -> String {
516 if self.name.is_empty() {
517 format!("devid {}", self.devid)
518 } else {
519 self.name.clone()
520 }
521 }
522
523 /// Sum of all error counters.
524 pub fn total_errors(&self) -> u64 {
525 self.write_errs
526 + self.read_errs
527 + self.flush_errs
528 + self.corruption_errs
529 + self.generation_errs
530 }
531}
532
533/// Space allocation of one btrfs block group type and RAID profile.
534#[derive(Debug, Clone, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct BtrfsAllocation {
537 /// Block group type: `"data"`, `"metadata"` or `"system"`.
538 pub kind: String,
539 /// RAID profile (e.g. `"single"`, `"raid0"`, `"raid1c3"`).
540 pub profile: String,
541 /// Space allocated to this profile (logical size).
542 pub total_bytes: u64,
543 /// Space used within the allocation (logical size).
544 pub used_bytes: u64,
545}
546
547/// One Linux md array parsed from `/proc/mdstat`.
548#[derive(Debug, Clone, Default, Serialize, Deserialize)]
549#[serde(default)]
550pub struct MdArray {
551 /// Array name (e.g. `"md0"`).
552 pub name: String,
553 /// Whether the array is active.
554 pub active: bool,
555 /// Whether the array is read-only (`active (read-only)` / `(auto-read-only)`).
556 pub read_only: bool,
557 /// RAID level (e.g. `"raid1"`); empty for inactive arrays.
558 pub level: String,
559 /// Member devices.
560 pub members: Vec<MdMember>,
561 /// Number of devices the array should have (`[n/m]` -> n).
562 pub expected_devices: Option<u32>,
563 /// Number of devices currently in sync (`[n/m]` -> m).
564 pub working_devices: Option<u32>,
565 /// Ongoing resync/recovery/reshape/check, if any.
566 pub sync: Option<MdSync>,
567}
568
569impl MdArray {
570 /// Returns true if the array runs with fewer devices than expected or has a failed member.
571 pub fn is_degraded(&self) -> bool {
572 let short = match (self.expected_devices, self.working_devices) {
573 (Some(expected), Some(working)) => working < expected,
574 _ => false,
575 };
576 short || self.members.iter().any(|m| m.faulty)
577 }
578}
579
580/// A member device of an md array.
581#[derive(Debug, Clone, Default, Serialize, Deserialize)]
582#[serde(default)]
583pub struct MdMember {
584 /// Device name (e.g. `"sda1"`).
585 pub name: String,
586 /// Role number within the array.
587 pub role: u32,
588 /// Marked faulty `(F)`.
589 pub faulty: bool,
590 /// Spare device `(S)`.
591 pub spare: bool,
592}
593
594/// Progress of an md background operation.
595#[derive(Debug, Clone, Default, Serialize, Deserialize)]
596#[serde(default)]
597pub struct MdSync {
598 /// Operation name: `"resync"`, `"recovery"`, `"reshape"` or `"check"`.
599 pub action: String,
600 /// Completion percentage.
601 pub percent: f32,
602 /// Estimated minutes until completion.
603 pub finish_minutes: Option<f32>,
604 /// Current speed in KiB/s.
605 pub speed_kib_per_sec: Option<u64>,
606}
607
608/// Traffic of one network interface.
609#[derive(Debug, Clone, Default, Serialize, Deserialize)]
610#[serde(default)]
611pub struct NetworkStats {
612 /// Interface name (e.g. `"enp9s0"`).
613 pub name: String,
614 /// Receive throughput.
615 pub rx_bytes_per_sec: f64,
616 /// Transmit throughput.
617 pub tx_bytes_per_sec: f64,
618 /// Bytes received since the interface came up.
619 pub rx_total_bytes: u64,
620 /// Bytes transmitted since the interface came up.
621 pub tx_total_bytes: u64,
622 /// Negotiated link speed in Mbit/s (wired links), if known.
623 pub link_speed_mbps: Option<u32>,
624 /// Whether the link is up, if the OS reports it.
625 pub link_up: Option<bool>,
626 /// Wi-Fi link details, for wireless interfaces.
627 pub wireless: Option<WirelessLink>,
628}
629
630/// State of a Wi-Fi link.
631#[derive(Debug, Clone, Default, Serialize, Deserialize)]
632#[serde(default)]
633pub struct WirelessLink {
634 /// Current receive bit rate in Mbit/s.
635 pub rx_bitrate_mbps: Option<f32>,
636 /// Current transmit bit rate in Mbit/s.
637 pub tx_bitrate_mbps: Option<f32>,
638 /// Signal strength in dBm.
639 pub signal_dbm: Option<i32>,
640}
641
642/// One process.
643#[derive(Debug, Clone, Default, Serialize, Deserialize)]
644#[serde(default)]
645pub struct ProcessInfo {
646 /// Process id.
647 pub pid: u32,
648 /// Parent process id.
649 pub parent_pid: Option<u32>,
650 /// Short name (executable name).
651 pub name: String,
652 /// Full command line joined by spaces.
653 pub command: String,
654 /// Owner user name (or numeric uid when it cannot be resolved).
655 pub user: String,
656 /// Scheduling state (e.g. "Run", "Sleep", "Zombie").
657 pub state: String,
658 /// CPU usage; 100 means one full core, so multi-threaded processes may exceed 100.
659 pub cpu_percent: f32,
660 /// Resident memory.
661 pub memory_bytes: u64,
662 /// Number of threads, where known.
663 pub threads: Option<u32>,
664 /// Disk read throughput.
665 pub read_bytes_per_sec: f64,
666 /// Disk write throughput.
667 pub write_bytes_per_sec: f64,
668 /// Seconds since the process started.
669 pub run_time_secs: u64,
670 /// Kernel thread (e.g. `kworker`), which front-ends may hide.
671 pub kernel_thread: bool,
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn process(pid: u32, cpu: f32) -> ProcessInfo {
679 ProcessInfo {
680 pid,
681 cpu_percent: cpu,
682 ..Default::default()
683 }
684 }
685
686 #[test]
687 fn cpu_power_falls_back_to_a_laptop_apu_only() {
688 let power = |cpu: Option<f32>, igpu: Option<f32>| PowerStats {
689 cpu_package_w: cpu,
690 igpu_w: igpu,
691 ..Default::default()
692 };
693 assert_eq!(
694 power(Some(30.0), Some(6.0)).cpu_or_soc_w(),
695 ("CPU", Some(30.0))
696 );
697 assert_eq!(power(None, Some(6.0)).cpu_or_soc_w(), ("SoC", Some(6.0)));
698 // A desktop Ryzen iGPU's few milliwatts are not the SoC.
699 assert_eq!(power(None, Some(0.007)).cpu_or_soc_w(), ("CPU", None));
700 assert_eq!(power(None, None).cpu_or_soc_w(), ("CPU", None));
701 }
702
703 #[test]
704 fn truncate_keeps_top_cpu_processes_in_order() {
705 let mut snapshot = Snapshot {
706 processes: vec![
707 process(1, 5.0),
708 process(2, 50.0),
709 process(3, 0.0),
710 process(4, 20.0),
711 ],
712 process_count: 4,
713 ..Default::default()
714 };
715 snapshot.truncate_processes(2);
716 let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
717 assert_eq!(pids, vec![2, 4]);
718 // The total is preserved for display ("2 of 4 processes").
719 assert_eq!(snapshot.process_count, 4);
720 }
721
722 #[test]
723 fn truncate_leaves_out_kernel_threads() {
724 let kworker = |pid: u32, cpu: f32| ProcessInfo {
725 kernel_thread: true,
726 ..process(pid, cpu)
727 };
728 let mut snapshot = Snapshot {
729 processes: vec![
730 kworker(1, 9.0),
731 process(2, 5.0),
732 kworker(3, 8.0),
733 process(4, 1.0),
734 process(5, 0.0),
735 ],
736 process_count: 5,
737 ..Default::default()
738 };
739 snapshot.truncate_processes(3);
740 let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
741 assert_eq!(pids, vec![2, 4, 5], "busy kworkers take no places");
742 assert_eq!(snapshot.process_count, 5);
743 }
744
745 #[test]
746 fn snapshot_round_trips_through_json() {
747 let snapshot = Snapshot {
748 timestamp_ms: 42,
749 processes: vec![process(7, 1.5)],
750 ..Default::default()
751 };
752 let json = serde_json::to_string(&snapshot).unwrap();
753 let back: Snapshot = serde_json::from_str(&json).unwrap();
754 assert_eq!(back.timestamp_ms, 42);
755 assert_eq!(back.processes[0].pid, 7);
756 }
757
758 #[test]
759 fn device_matching_handles_nvme_namespaces() {
760 assert!(device_matches_disk("sda", "sda"));
761 assert!(device_matches_disk("nvme0", "nvme0n1"));
762 assert!(device_matches_disk("nvme1", "nvme1n1"));
763 assert!(!device_matches_disk("nvme1", "nvme10n1"));
764 assert!(!device_matches_disk("sd", "sda"));
765 assert!(!device_matches_disk("sda", "sdb"));
766 }
767
768 #[test]
769 fn smart_attention_flags() {
770 let healthy = SmartDevice {
771 passed: Some(true),
772 media_errors: Some(0),
773 available_spare_percent: Some(100),
774 available_spare_threshold_percent: Some(10),
775 ..Default::default()
776 };
777 assert!(!healthy.needs_attention());
778 let worn = SmartDevice {
779 available_spare_percent: Some(5),
780 ..healthy.clone()
781 };
782 assert!(worn.needs_attention());
783 let reallocated = SmartDevice {
784 reallocated_sectors: Some(8),
785 ..healthy
786 };
787 assert!(reallocated.needs_attention());
788 }
789
790 #[test]
791 fn md_array_degraded_detection() {
792 let mut array = MdArray {
793 expected_devices: Some(2),
794 working_devices: Some(2),
795 ..Default::default()
796 };
797 assert!(!array.is_degraded());
798 array.working_devices = Some(1);
799 assert!(array.is_degraded());
800 }
801}