Skip to main content

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