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