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