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