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