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