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