Skip to main content

rigtop_core/
snapshot.rs

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