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 /// Its vdevs and devices as `zpool status` lists them, top to bottom
598 /// (empty where `zpool` could not be run, and from agents before 0.26).
599 pub devices: Vec<ZfsDevice>,
600 /// The last or running scrub or resilver ("scrub repaired 0B in
601 /// 00:00:01 with 0 errors on ...", "resilver in progress ... 42.1%
602 /// done"), from the `scan:` line.
603 pub scan: Option<String>,
604 /// Permanent data errors (`errors: N data errors`); `None` unknown.
605 pub data_errors: Option<u64>,
606 /// Size and space in use (`zpool list`).
607 pub size_bytes: Option<u64>,
608 pub allocated_bytes: Option<u64>,
609}
610
611/// One row of a pool's configuration in `zpool status`: a vdev (raidz1-0,
612/// mirror-0) or a device under one.
613#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
614#[serde(default)]
615pub struct ZfsDevice {
616 /// As listed: "raidz1-0", "mirror-1", "ata-WDC_WD80...", "/var/tmp/log1".
617 pub name: String,
618 /// Nesting under the pool: 0 for top-level vdevs, 1 for their devices.
619 pub depth: u8,
620 /// The section it is in: "" for data, else "logs", "cache", "spares",
621 /// "special" or "dedup".
622 pub section: String,
623 /// ONLINE, DEGRADED, OFFLINE, FAULTED, UNAVAIL, REMOVED; AVAIL or
624 /// INUSE for spares.
625 pub state: String,
626 /// Read, write and checksum errors since the last `zpool clear`.
627 pub read_errors: u64,
628 pub write_errors: u64,
629 pub checksum_errors: u64,
630}
631
632impl ZfsDevice {
633 /// All its errors.
634 pub fn errors(&self) -> u64 {
635 self.read_errors + self.write_errors + self.checksum_errors
636 }
637
638 /// Whether its state needs attention (spares waiting are fine).
639 pub fn is_unhealthy(&self) -> bool {
640 !matches!(self.state.as_str(), "ONLINE" | "AVAIL" | "INUSE" | "")
641 }
642
643 /// Whether it is a vdev grouping devices (raidz, mirror, ...) rather
644 /// than a device.
645 pub fn is_group(&self) -> bool {
646 ["raidz", "mirror", "draid", "replacing", "spare-"]
647 .iter()
648 .any(|kind| self.name.starts_with(kind))
649 }
650}
651
652impl ZfsPool {
653 /// The layout of its data vdevs: "raidz1", "mirror", "2 × mirror",
654 /// "stripe" (devices directly under the pool), or `None` when unknown.
655 pub fn layout(&self) -> Option<String> {
656 let top: Vec<&ZfsDevice> = self
657 .devices
658 .iter()
659 .filter(|d| d.depth == 0 && d.section.is_empty())
660 .collect();
661 let first = top.first()?;
662 if !first.is_group() {
663 return Some("stripe".to_owned());
664 }
665 let kind = first
666 .name
667 .rsplit_once('-')
668 .map_or(first.name.as_str(), |(k, _)| k);
669 let same = top.iter().filter(|d| d.name.starts_with(kind)).count();
670 Some(if top.len() > 1 && same == top.len() {
671 format!("{} × {kind}", top.len())
672 } else {
673 kind.to_owned()
674 })
675 }
676
677 /// Whether `zpool status` reported data errors or device errors, which
678 /// a healthy-looking ONLINE state does not show.
679 pub fn has_errors(&self) -> bool {
680 self.data_errors.unwrap_or(0) > 0 || self.devices.iter().any(|d| d.errors() > 0)
681 }
682
683 /// Its devices (not vdevs) with errors or in a bad state.
684 pub fn troubled_devices(&self) -> impl Iterator<Item = &ZfsDevice> {
685 self.devices
686 .iter()
687 .filter(|d| !d.is_group() && (d.errors() > 0 || d.is_unhealthy()))
688 }
689
690 /// Whether the pool is healthy.
691 pub fn is_online(&self) -> bool {
692 self.state == "ONLINE"
693 }
694
695 /// Whether the pool lost redundancy or data is at risk (worse than an
696 /// administratively offline pool).
697 pub fn is_failing(&self) -> bool {
698 matches!(
699 self.state.as_str(),
700 "DEGRADED" | "FAULTED" | "UNAVAIL" | "REMOVED" | "SUSPENDED"
701 )
702 }
703}
704
705/// A btrfs filesystem as seen in `/sys/fs/btrfs/<fsid>`.
706#[derive(Debug, Clone, Default, Serialize, Deserialize)]
707#[serde(default)]
708pub struct BtrfsFilesystem {
709 /// Filesystem UUID.
710 pub fsid: String,
711 /// Filesystem label (may be empty).
712 pub label: String,
713 /// Mount points found in the mount table for this filesystem.
714 pub mount_points: Vec<String>,
715 /// Member block devices (e.g. `"nvme0n1"`, `"nvme1n1p1"`).
716 pub devices: Vec<String>,
717 /// Per-device error counters, keyed by btrfs device id. Each carries its
718 /// device name when it could be looked up (see [`BtrfsDeviceStats::name`]);
719 /// the list is not aligned with `devices`.
720 pub device_stats: Vec<BtrfsDeviceStats>,
721 /// Allocation per block group type (data, metadata, system).
722 pub allocations: Vec<BtrfsAllocation>,
723 /// Result of the most recent scrub, if it is known. Error counters in
724 /// `device_stats` only grow when data is read, so the scrub time tells
725 /// when all data was last verified.
726 pub scrub: Option<BtrfsScrub>,
727}
728
729/// Outcome of the last btrfs scrub (summed over devices).
730#[derive(Debug, Clone, Default, Serialize, Deserialize)]
731#[serde(default)]
732pub struct BtrfsScrub {
733 /// Start time, in milliseconds since the Unix epoch.
734 pub started_at_ms: u64,
735 /// Run time in seconds.
736 pub duration_secs: u64,
737 /// `"finished"`, `"canceled"` or `"unfinished"` (running or interrupted).
738 pub state: String,
739 /// Bytes verified.
740 pub bytes_scrubbed: u64,
741 /// Read, checksum, verify and superblock errors found.
742 pub errors: u64,
743 /// Errors that were repaired from a good copy.
744 pub corrected: u64,
745 /// Errors that could not be repaired.
746 pub uncorrectable: u64,
747}
748
749impl BtrfsFilesystem {
750 /// Returns true if any member is missing or has a non-zero error counter.
751 pub fn has_problems(&self) -> bool {
752 self.device_stats
753 .iter()
754 .any(|d| d.missing || d.total_errors() > 0)
755 }
756}
757
758/// Error counters of one btrfs member device.
759#[derive(Debug, Clone, Default, Serialize, Deserialize)]
760#[serde(default)]
761pub struct BtrfsDeviceStats {
762 /// btrfs device id (1-based).
763 pub devid: u64,
764 /// Device name (e.g. `"nvme0n1"`), from the `BTRFS_IOC_DEV_INFO` ioctl
765 /// on a mount point; empty when unknown (not mounted, device missing).
766 pub name: String,
767 /// Size of the device as btrfs uses it.
768 pub size_bytes: Option<u64>,
769 /// Space allocated on the device.
770 pub used_bytes: Option<u64>,
771 /// Whether the device is missing from the filesystem.
772 pub missing: bool,
773 /// Write I/O errors.
774 pub write_errs: u64,
775 /// Read I/O errors.
776 pub read_errs: u64,
777 /// Flush errors.
778 pub flush_errs: u64,
779 /// Checksum mismatches.
780 pub corruption_errs: u64,
781 /// Generation mismatches (stale data).
782 pub generation_errs: u64,
783}
784
785impl BtrfsDeviceStats {
786 /// Name for display: the device name, else `devid N`.
787 pub fn label(&self) -> String {
788 if self.name.is_empty() {
789 format!("devid {}", self.devid)
790 } else {
791 self.name.clone()
792 }
793 }
794
795 /// Sum of all error counters.
796 pub fn total_errors(&self) -> u64 {
797 self.write_errs
798 + self.read_errs
799 + self.flush_errs
800 + self.corruption_errs
801 + self.generation_errs
802 }
803}
804
805/// Space allocation of one btrfs block group type and RAID profile.
806#[derive(Debug, Clone, Default, Serialize, Deserialize)]
807#[serde(default)]
808pub struct BtrfsAllocation {
809 /// Block group type: `"data"`, `"metadata"` or `"system"`.
810 pub kind: String,
811 /// RAID profile (e.g. `"single"`, `"raid0"`, `"raid1c3"`).
812 pub profile: String,
813 /// Space allocated to this profile (logical size).
814 pub total_bytes: u64,
815 /// Space used within the allocation (logical size).
816 pub used_bytes: u64,
817}
818
819/// One Linux md array parsed from `/proc/mdstat`.
820#[derive(Debug, Clone, Default, Serialize, Deserialize)]
821#[serde(default)]
822pub struct MdArray {
823 /// Array name (e.g. `"md0"`).
824 pub name: String,
825 /// Whether the array is active.
826 pub active: bool,
827 /// Whether the array is read-only (`active (read-only)` / `(auto-read-only)`).
828 pub read_only: bool,
829 /// RAID level (e.g. `"raid1"`); empty for inactive arrays.
830 pub level: String,
831 /// Member devices.
832 pub members: Vec<MdMember>,
833 /// Number of devices the array should have (`[n/m]` -> n).
834 pub expected_devices: Option<u32>,
835 /// Number of devices currently in sync (`[n/m]` -> m).
836 pub working_devices: Option<u32>,
837 /// Ongoing resync/recovery/reshape/check, if any.
838 pub sync: Option<MdSync>,
839}
840
841impl MdArray {
842 /// Returns true if the array runs with fewer devices than expected or has a failed member.
843 pub fn is_degraded(&self) -> bool {
844 let short = match (self.expected_devices, self.working_devices) {
845 (Some(expected), Some(working)) => working < expected,
846 _ => false,
847 };
848 short || self.members.iter().any(|m| m.faulty)
849 }
850}
851
852/// A member device of an md array.
853#[derive(Debug, Clone, Default, Serialize, Deserialize)]
854#[serde(default)]
855pub struct MdMember {
856 /// Device name (e.g. `"sda1"`).
857 pub name: String,
858 /// Role number within the array.
859 pub role: u32,
860 /// Marked faulty `(F)`.
861 pub faulty: bool,
862 /// Spare device `(S)`.
863 pub spare: bool,
864}
865
866/// Progress of an md background operation.
867#[derive(Debug, Clone, Default, Serialize, Deserialize)]
868#[serde(default)]
869pub struct MdSync {
870 /// Operation name: `"resync"`, `"recovery"`, `"reshape"` or `"check"`.
871 pub action: String,
872 /// Completion percentage.
873 pub percent: f32,
874 /// Estimated minutes until completion.
875 pub finish_minutes: Option<f32>,
876 /// Current speed in KiB/s.
877 pub speed_kib_per_sec: Option<u64>,
878}
879
880/// Traffic of one network interface.
881#[derive(Debug, Clone, Default, Serialize, Deserialize)]
882#[serde(default)]
883pub struct NetworkStats {
884 /// Interface name (e.g. `"enp9s0"`).
885 pub name: String,
886 /// Receive throughput.
887 pub rx_bytes_per_sec: f64,
888 /// Transmit throughput.
889 pub tx_bytes_per_sec: f64,
890 /// Bytes received since the interface came up.
891 pub rx_total_bytes: u64,
892 /// Bytes transmitted since the interface came up.
893 pub tx_total_bytes: u64,
894 /// Negotiated link speed in Mbit/s (wired links), if known.
895 pub link_speed_mbps: Option<u32>,
896 /// Whether the link is up, if the OS reports it.
897 pub link_up: Option<bool>,
898 /// Wi-Fi link details, for wireless interfaces.
899 pub wireless: Option<WirelessLink>,
900}
901
902/// State of a Wi-Fi link.
903#[derive(Debug, Clone, Default, Serialize, Deserialize)]
904#[serde(default)]
905pub struct WirelessLink {
906 /// Current receive bit rate in Mbit/s.
907 pub rx_bitrate_mbps: Option<f32>,
908 /// Current transmit bit rate in Mbit/s.
909 pub tx_bitrate_mbps: Option<f32>,
910 /// Signal strength in dBm.
911 pub signal_dbm: Option<i32>,
912}
913
914/// One process.
915#[derive(Debug, Clone, Default, Serialize, Deserialize)]
916#[serde(default)]
917pub struct ProcessInfo {
918 /// Process id.
919 pub pid: u32,
920 /// Parent process id.
921 pub parent_pid: Option<u32>,
922 /// Short name (executable name).
923 pub name: String,
924 /// Full command line joined by spaces.
925 pub command: String,
926 /// Owner user name (or numeric uid when it cannot be resolved).
927 pub user: String,
928 /// Scheduling state (e.g. "Run", "Sleep", "Zombie").
929 pub state: String,
930 /// CPU usage; 100 means one full core, so multi-threaded processes may exceed 100.
931 pub cpu_percent: f32,
932 /// Resident memory.
933 pub memory_bytes: u64,
934 /// Number of threads, where known.
935 pub threads: Option<u32>,
936 /// Nice value, -20 (first) to 19 (last), where known (Linux).
937 pub nice: Option<i32>,
938 /// Disk read throughput.
939 pub read_bytes_per_sec: f64,
940 /// Disk write throughput.
941 pub write_bytes_per_sec: f64,
942 /// Seconds since the process started.
943 pub run_time_secs: u64,
944 /// Kernel thread (e.g. `kworker`), which front-ends may hide.
945 pub kernel_thread: bool,
946 /// The container it runs in (Docker, Podman, ... name, or short id),
947 /// if any.
948 pub container: Option<String>,
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 fn process(pid: u32, cpu: f32) -> ProcessInfo {
956 ProcessInfo {
957 pid,
958 cpu_percent: cpu,
959 ..Default::default()
960 }
961 }
962
963 #[test]
964 fn cpu_power_falls_back_to_a_laptop_apu_only() {
965 let power = |cpu: Option<f32>, igpu: Option<f32>| PowerStats {
966 cpu_package_w: cpu,
967 igpu_w: igpu,
968 ..Default::default()
969 };
970 assert_eq!(
971 power(Some(30.0), Some(6.0)).cpu_or_soc_w(),
972 ("CPU", Some(30.0))
973 );
974 assert_eq!(power(None, Some(6.0)).cpu_or_soc_w(), ("SoC", Some(6.0)));
975 // A desktop Ryzen iGPU's few milliwatts are not the SoC.
976 assert_eq!(power(None, Some(0.007)).cpu_or_soc_w(), ("CPU", None));
977 assert_eq!(power(None, None).cpu_or_soc_w(), ("CPU", None));
978 }
979
980 #[test]
981 fn truncate_keeps_top_cpu_processes_in_order() {
982 let mut snapshot = Snapshot {
983 processes: vec![
984 process(1, 5.0),
985 process(2, 50.0),
986 process(3, 0.0),
987 process(4, 20.0),
988 ],
989 process_count: 4,
990 ..Default::default()
991 };
992 snapshot.truncate_processes(2);
993 let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
994 assert_eq!(pids, vec![2, 4]);
995 // The total is preserved for display ("2 of 4 processes").
996 assert_eq!(snapshot.process_count, 4);
997 }
998
999 #[test]
1000 fn truncate_leaves_out_kernel_threads() {
1001 let kworker = |pid: u32, cpu: f32| ProcessInfo {
1002 kernel_thread: true,
1003 ..process(pid, cpu)
1004 };
1005 let mut snapshot = Snapshot {
1006 processes: vec![
1007 kworker(1, 9.0),
1008 process(2, 5.0),
1009 kworker(3, 8.0),
1010 process(4, 1.0),
1011 process(5, 0.0),
1012 ],
1013 process_count: 5,
1014 ..Default::default()
1015 };
1016 snapshot.truncate_processes(3);
1017 let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
1018 assert_eq!(pids, vec![2, 4, 5], "busy kworkers take no places");
1019 assert_eq!(snapshot.process_count, 5);
1020 }
1021
1022 #[test]
1023 fn truncate_keeps_watched_processes() {
1024 let mut snapshot = Snapshot {
1025 processes: (1..=6).map(|pid| process(pid, pid as f32)).collect(),
1026 watched_pids: vec![1, 99],
1027 ..Default::default()
1028 };
1029 snapshot.truncate_processes(2);
1030 let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
1031 assert_eq!(pids, vec![6, 5, 1]);
1032 }
1033
1034 #[test]
1035 fn snapshot_round_trips_through_json() {
1036 let snapshot = Snapshot {
1037 timestamp_ms: 42,
1038 processes: vec![process(7, 1.5)],
1039 ..Default::default()
1040 };
1041 let json = serde_json::to_string(&snapshot).unwrap();
1042 let back: Snapshot = serde_json::from_str(&json).unwrap();
1043 assert_eq!(back.timestamp_ms, 42);
1044 assert_eq!(back.processes[0].pid, 7);
1045 }
1046
1047 #[test]
1048 fn device_matching_handles_nvme_namespaces() {
1049 assert!(device_matches_disk("sda", "sda"));
1050 assert!(device_matches_disk("nvme0", "nvme0n1"));
1051 assert!(device_matches_disk("nvme1", "nvme1n1"));
1052 assert!(!device_matches_disk("nvme1", "nvme10n1"));
1053 assert!(!device_matches_disk("sd", "sda"));
1054 assert!(!device_matches_disk("sda", "sdb"));
1055 }
1056
1057 #[test]
1058 fn smart_attention_flags() {
1059 let healthy = SmartDevice {
1060 passed: Some(true),
1061 media_errors: Some(0),
1062 available_spare_percent: Some(100),
1063 available_spare_threshold_percent: Some(10),
1064 ..Default::default()
1065 };
1066 assert!(!healthy.needs_attention());
1067 let worn = SmartDevice {
1068 available_spare_percent: Some(5),
1069 ..healthy.clone()
1070 };
1071 assert!(worn.needs_attention());
1072 let reallocated = SmartDevice {
1073 reallocated_sectors: Some(8),
1074 ..healthy
1075 };
1076 assert!(reallocated.needs_attention());
1077 }
1078
1079 #[test]
1080 fn md_array_degraded_detection() {
1081 let mut array = MdArray {
1082 expected_devices: Some(2),
1083 working_devices: Some(2),
1084 ..Default::default()
1085 };
1086 assert!(!array.is_degraded());
1087 array.working_devices = Some(1);
1088 assert!(array.is_degraded());
1089 }
1090}