use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Snapshot {
pub timestamp_ms: u64,
pub host: HostInfo,
pub cpu: CpuStats,
pub memory: MemoryStats,
pub gpus: Vec<GpuStats>,
pub sensors: Vec<SensorChip>,
pub power: PowerStats,
pub disks: Vec<DiskIo>,
pub filesystems: Vec<FilesystemUsage>,
pub raid: RaidStats,
pub smart: Vec<SmartDevice>,
pub networks: Vec<NetworkStats>,
pub processes: Vec<ProcessInfo>,
pub process_count: usize,
pub lhm_error: Option<String>,
}
impl Snapshot {
pub fn truncate_processes(&mut self, limit: usize) {
self.processes.retain(|p| !p.kernel_thread);
if self.processes.len() <= limit {
return;
}
self.processes
.select_nth_unstable_by(limit, |a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
self.processes.truncate(limit);
self.processes
.sort_unstable_by(|a, b| b.cpu_percent.total_cmp(&a.cpu_percent));
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HostInfo {
pub hostname: String,
pub os: String,
pub kernel: String,
pub uptime_secs: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CpuStats {
pub model: String,
pub total_percent: f32,
pub per_core_percent: Vec<f32>,
pub per_core_mhz: Vec<u64>,
pub load_average: Option<[f64; 3]>,
pub temperatures: Vec<Temperature>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryStats {
pub total_bytes: u64,
pub used_bytes: u64,
pub available_bytes: u64,
pub free_bytes: u64,
pub cached_bytes: Option<u64>,
pub swap_total_bytes: u64,
pub swap_used_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GpuStats {
pub index: u32,
pub vendor: String,
pub name: String,
pub integrated: bool,
pub utilization_percent: Option<f32>,
pub memory_utilization_percent: Option<f32>,
pub memory_used_bytes: Option<u64>,
pub memory_total_bytes: Option<u64>,
pub temperature_c: Option<f32>,
pub power_w: Option<f32>,
pub power_limit_w: Option<f32>,
pub fan_percent: Option<f32>,
pub clock_mhz: Option<u32>,
pub processes: Vec<GpuProcess>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GpuProcess {
pub pid: u32,
pub memory_bytes: Option<u64>,
pub sm_percent: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SensorChip {
pub key: String,
pub name: String,
pub temperatures: Vec<Temperature>,
pub fans: Vec<Fan>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Temperature {
pub key: String,
pub label: String,
pub celsius: f32,
pub max_celsius: Option<f32>,
pub critical_celsius: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Fan {
pub key: String,
pub label: String,
pub rpm: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PowerStats {
pub cpu_package_w: Option<f32>,
pub gpus_w: Option<f32>,
pub igpu_w: Option<f32>,
pub total_w: Option<f32>,
pub battery: Option<BatteryStats>,
}
const MIN_SOC_W: f32 = 1.0;
impl PowerStats {
pub fn cpu_or_soc_w(&self) -> (&'static str, Option<f32>) {
match (self.cpu_package_w, self.igpu_w) {
(Some(w), _) => ("CPU", Some(w)),
(None, Some(w)) if w >= MIN_SOC_W => ("SoC", Some(w)),
_ => ("CPU", None),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BatteryStats {
pub percent: Option<f32>,
pub status: String,
pub power_w: Option<f32>,
}
impl BatteryStats {
pub fn summary(&self) -> String {
let percent = self
.percent
.map_or("--%".to_owned(), |p| format!("{p:.0}%"));
let sign = match self.status.as_str() {
"Discharging" => "-",
"Charging" => "+",
_ => return percent,
};
match self.power_w {
Some(w) if w >= 0.5 => format!("{percent} {sign}{w:.0}W"),
_ => percent,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DiskIo {
pub name: String,
pub read_bytes_per_sec: f64,
pub write_bytes_per_sec: f64,
pub busy_percent: Option<f32>,
pub temperature_c: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FilesystemUsage {
pub device: String,
pub fs_type: String,
pub mount_points: Vec<String>,
pub total_bytes: u64,
pub available_bytes: u64,
}
pub fn device_matches_disk(device: &str, disk: &str) -> bool {
match disk.strip_prefix(device) {
Some("") => true,
Some(rest) => device.starts_with("nvme") && rest.starts_with('n'),
None => false,
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SmartDevice {
pub device: String,
pub protocol: String,
pub model: String,
pub passed: Option<bool>,
pub temperature_c: Option<f32>,
pub power_on_hours: Option<u64>,
pub percentage_used: Option<u32>,
pub available_spare_percent: Option<u32>,
pub available_spare_threshold_percent: Option<u32>,
pub critical_warning: Option<u32>,
pub media_errors: Option<u64>,
pub reallocated_sectors: Option<u64>,
pub pending_sectors: Option<u64>,
pub offline_uncorrectable: Option<u64>,
pub data_written_bytes: Option<u64>,
pub collected_at_ms: Option<u64>,
pub error: Option<String>,
}
impl SmartDevice {
pub fn needs_attention(&self) -> bool {
let positive = |v: Option<u64>| v.is_some_and(|v| v > 0);
let spare_low = match (
self.available_spare_percent,
self.available_spare_threshold_percent,
) {
(Some(spare), Some(threshold)) => spare < threshold,
_ => false,
};
self.passed == Some(false)
|| self.error.is_some()
|| self.critical_warning.is_some_and(|w| w != 0)
|| positive(self.media_errors)
|| positive(self.reallocated_sectors)
|| positive(self.pending_sectors)
|| positive(self.offline_uncorrectable)
|| self.percentage_used.is_some_and(|p| p >= 100)
|| spare_low
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RaidStats {
pub btrfs: Vec<BtrfsFilesystem>,
pub md: Vec<MdArray>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BtrfsFilesystem {
pub fsid: String,
pub label: String,
pub mount_points: Vec<String>,
pub devices: Vec<String>,
pub device_stats: Vec<BtrfsDeviceStats>,
pub allocations: Vec<BtrfsAllocation>,
pub scrub: Option<BtrfsScrub>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BtrfsScrub {
pub started_at_ms: u64,
pub duration_secs: u64,
pub state: String,
pub bytes_scrubbed: u64,
pub errors: u64,
pub corrected: u64,
pub uncorrectable: u64,
}
impl BtrfsFilesystem {
pub fn has_problems(&self) -> bool {
self.device_stats
.iter()
.any(|d| d.missing || d.total_errors() > 0)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BtrfsDeviceStats {
pub devid: u64,
pub name: String,
pub size_bytes: Option<u64>,
pub used_bytes: Option<u64>,
pub missing: bool,
pub write_errs: u64,
pub read_errs: u64,
pub flush_errs: u64,
pub corruption_errs: u64,
pub generation_errs: u64,
}
impl BtrfsDeviceStats {
pub fn label(&self) -> String {
if self.name.is_empty() {
format!("devid {}", self.devid)
} else {
self.name.clone()
}
}
pub fn total_errors(&self) -> u64 {
self.write_errs
+ self.read_errs
+ self.flush_errs
+ self.corruption_errs
+ self.generation_errs
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BtrfsAllocation {
pub kind: String,
pub profile: String,
pub total_bytes: u64,
pub used_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MdArray {
pub name: String,
pub active: bool,
pub read_only: bool,
pub level: String,
pub members: Vec<MdMember>,
pub expected_devices: Option<u32>,
pub working_devices: Option<u32>,
pub sync: Option<MdSync>,
}
impl MdArray {
pub fn is_degraded(&self) -> bool {
let short = match (self.expected_devices, self.working_devices) {
(Some(expected), Some(working)) => working < expected,
_ => false,
};
short || self.members.iter().any(|m| m.faulty)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MdMember {
pub name: String,
pub role: u32,
pub faulty: bool,
pub spare: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MdSync {
pub action: String,
pub percent: f32,
pub finish_minutes: Option<f32>,
pub speed_kib_per_sec: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkStats {
pub name: String,
pub rx_bytes_per_sec: f64,
pub tx_bytes_per_sec: f64,
pub rx_total_bytes: u64,
pub tx_total_bytes: u64,
pub link_speed_mbps: Option<u32>,
pub link_up: Option<bool>,
pub wireless: Option<WirelessLink>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WirelessLink {
pub rx_bitrate_mbps: Option<f32>,
pub tx_bitrate_mbps: Option<f32>,
pub signal_dbm: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProcessInfo {
pub pid: u32,
pub parent_pid: Option<u32>,
pub name: String,
pub command: String,
pub user: String,
pub state: String,
pub cpu_percent: f32,
pub memory_bytes: u64,
pub threads: Option<u32>,
pub read_bytes_per_sec: f64,
pub write_bytes_per_sec: f64,
pub run_time_secs: u64,
pub kernel_thread: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn process(pid: u32, cpu: f32) -> ProcessInfo {
ProcessInfo {
pid,
cpu_percent: cpu,
..Default::default()
}
}
#[test]
fn cpu_power_falls_back_to_a_laptop_apu_only() {
let power = |cpu: Option<f32>, igpu: Option<f32>| PowerStats {
cpu_package_w: cpu,
igpu_w: igpu,
..Default::default()
};
assert_eq!(
power(Some(30.0), Some(6.0)).cpu_or_soc_w(),
("CPU", Some(30.0))
);
assert_eq!(power(None, Some(6.0)).cpu_or_soc_w(), ("SoC", Some(6.0)));
assert_eq!(power(None, Some(0.007)).cpu_or_soc_w(), ("CPU", None));
assert_eq!(power(None, None).cpu_or_soc_w(), ("CPU", None));
}
#[test]
fn truncate_keeps_top_cpu_processes_in_order() {
let mut snapshot = Snapshot {
processes: vec![
process(1, 5.0),
process(2, 50.0),
process(3, 0.0),
process(4, 20.0),
],
process_count: 4,
..Default::default()
};
snapshot.truncate_processes(2);
let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
assert_eq!(pids, vec![2, 4]);
assert_eq!(snapshot.process_count, 4);
}
#[test]
fn truncate_leaves_out_kernel_threads() {
let kworker = |pid: u32, cpu: f32| ProcessInfo {
kernel_thread: true,
..process(pid, cpu)
};
let mut snapshot = Snapshot {
processes: vec![
kworker(1, 9.0),
process(2, 5.0),
kworker(3, 8.0),
process(4, 1.0),
process(5, 0.0),
],
process_count: 5,
..Default::default()
};
snapshot.truncate_processes(3);
let pids: Vec<u32> = snapshot.processes.iter().map(|p| p.pid).collect();
assert_eq!(pids, vec![2, 4, 5], "busy kworkers take no places");
assert_eq!(snapshot.process_count, 5);
}
#[test]
fn snapshot_round_trips_through_json() {
let snapshot = Snapshot {
timestamp_ms: 42,
processes: vec![process(7, 1.5)],
..Default::default()
};
let json = serde_json::to_string(&snapshot).unwrap();
let back: Snapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.timestamp_ms, 42);
assert_eq!(back.processes[0].pid, 7);
}
#[test]
fn device_matching_handles_nvme_namespaces() {
assert!(device_matches_disk("sda", "sda"));
assert!(device_matches_disk("nvme0", "nvme0n1"));
assert!(device_matches_disk("nvme1", "nvme1n1"));
assert!(!device_matches_disk("nvme1", "nvme10n1"));
assert!(!device_matches_disk("sd", "sda"));
assert!(!device_matches_disk("sda", "sdb"));
}
#[test]
fn smart_attention_flags() {
let healthy = SmartDevice {
passed: Some(true),
media_errors: Some(0),
available_spare_percent: Some(100),
available_spare_threshold_percent: Some(10),
..Default::default()
};
assert!(!healthy.needs_attention());
let worn = SmartDevice {
available_spare_percent: Some(5),
..healthy.clone()
};
assert!(worn.needs_attention());
let reallocated = SmartDevice {
reallocated_sectors: Some(8),
..healthy
};
assert!(reallocated.needs_attention());
}
#[test]
fn md_array_degraded_detection() {
let mut array = MdArray {
expected_devices: Some(2),
working_devices: Some(2),
..Default::default()
};
assert!(!array.is_degraded());
array.working_devices = Some(1);
assert!(array.is_degraded());
}
}