Skip to main content

diskr/analyzer/
disk_health.rs

1use serde::Serialize;
2use std::path::Path;
3use std::process::Command;
4use sysinfo::Disks;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7pub enum HealthStatus {
8    Good,
9    Warning,
10    Critical,
11    Unknown,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct DiskHealth {
16    pub device: String,
17    pub model: String,
18    pub kind: String,
19    pub total_bytes: u64,
20    pub available_bytes: u64,
21    pub temperature_c: Option<i32>,
22    pub power_on_hours: Option<u64>,
23    pub reallocated_sectors: Option<u64>,
24    pub wear_level_percent: Option<u8>,
25    pub status: HealthStatus,
26    pub smart_available: bool,
27}
28
29pub fn list_disks() -> Vec<DiskHealth> {
30    let disks = Disks::new_with_refreshed_list();
31    disks
32        .iter()
33        .map(|d| {
34            let device = d.name().to_string_lossy().to_string();
35            let phys = physical_device(&device);
36            let smart = read_smart(&phys);
37            let smart_available = smart.is_some();
38
39            let model = smart
40                .as_ref()
41                .and_then(|s| s.model.clone())
42                .or_else(|| read_model_sysfs(&phys))
43                .unwrap_or_else(|| "Unknown model".to_string());
44
45            let mut health = DiskHealth {
46                device,
47                model,
48                kind: format!("{:?}", d.kind()),
49                total_bytes: d.total_space(),
50                available_bytes: d.available_space(),
51                temperature_c: smart.as_ref().and_then(|s| s.temperature_c),
52                power_on_hours: smart.as_ref().and_then(|s| s.power_on_hours),
53                reallocated_sectors: smart.as_ref().and_then(|s| s.reallocated_sectors),
54                wear_level_percent: smart.as_ref().and_then(|s| s.wear_level_percent),
55                status: HealthStatus::Unknown,
56                smart_available,
57            };
58            health.status = evaluate(&health);
59            health
60        })
61        .collect()
62}
63
64fn physical_device(device: &str) -> String {
65    let path = Path::new(device);
66    let dir = path.parent().and_then(|p| p.to_str()).unwrap_or("/dev");
67    let name = match path.file_name().and_then(|n| n.to_str()) {
68        Some(n) => n,
69        None => return device.to_string(),
70    };
71
72    if name.starts_with("nvme") || name.starts_with("mmcblk") {
73        if let Some(pidx) = name.rfind('p') {
74            let tail = &name[pidx + 1..];
75            if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
76                return format!("{dir}/{}", &name[..pidx]);
77            }
78        }
79        return format!("{dir}/{name}");
80    }
81
82    // sda1 -> sda, vdb2 -> vdb, hdc3 -> hdc, xvda1 -> xvda
83    if name.starts_with("sd") || name.starts_with("hd") || name.starts_with("vd") || name.starts_with("xvd") {
84        let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit());
85        if !trimmed.is_empty() {
86            return format!("{dir}/{trimmed}");
87        }
88    }
89
90    device.to_string()
91}
92
93fn read_model_sysfs(phys_device: &str) -> Option<String> {
94    let base_name = Path::new(phys_device).file_name()?.to_str()?;
95    let candidates = [
96        format!("/sys/block/{base_name}/device/model"),
97        format!("/sys/block/{base_name}/device/name"),
98        format!("/sys/class/nvme/{base_name}/model"),
99    ];
100    for c in candidates {
101        if let Ok(text) = std::fs::read_to_string(&c) {
102            let trimmed = text.trim();
103            if !trimmed.is_empty() {
104                return Some(trimmed.to_string());
105            }
106        }
107    }
108    None
109}
110
111struct SmartInfo {
112    model: Option<String>,
113    temperature_c: Option<i32>,
114    power_on_hours: Option<u64>,
115    reallocated_sectors: Option<u64>,
116    wear_level_percent: Option<u8>,
117}
118
119fn read_smart(device: &str) -> Option<SmartInfo> {
120    let mut cmd = Command::new("smartctl");
121    cmd.arg("-a");
122    if device.contains("nvme") {
123        cmd.arg("-d").arg("nvme");
124    }
125    cmd.arg(device);
126    let output = cmd.output().ok()?;
127    let text = String::from_utf8_lossy(&output.stdout);
128    if text.trim().is_empty() {
129        return None;
130    }
131
132    let model = text
133        .lines()
134        .find(|l| l.starts_with("Device Model") || l.starts_with("Model Number"))
135        .and_then(|l| l.split(':').nth(1))
136        .map(|s| s.trim().to_string())
137        .filter(|s| !s.is_empty());
138
139    let temperature_c = text
140        .lines()
141        .find(|l| l.contains("Temperature_Celsius") || l.contains("Airflow_Temperature_Cel"))
142        .and_then(|l| l.split_whitespace().last())
143        .and_then(|v| v.parse().ok())
144        .or_else(|| {
145            text.lines()
146                .find(|l| l.trim_start().starts_with("Temperature:"))
147                .and_then(|l| l.split(':').nth(1))
148                .and_then(|v| v.trim().split_whitespace().next())
149                .and_then(|v| v.parse().ok())
150        });
151
152    let power_on_hours = text
153        .lines()
154        .find(|l| l.contains("Power_On_Hours"))
155        .and_then(|l| l.split_whitespace().last())
156        .and_then(|v| v.parse().ok())
157        .or_else(|| {
158            text.lines()
159                .find(|l| l.trim_start().starts_with("Power On Hours:"))
160                .and_then(|l| l.split(':').nth(1))
161                .map(|v| v.trim().replace(',', ""))
162                .and_then(|v| v.parse().ok())
163        });
164
165    let reallocated_sectors = text
166        .lines()
167        .find(|l| l.contains("Reallocated_Sector_Ct"))
168        .and_then(|l| l.split_whitespace().last())
169        .and_then(|v| v.parse().ok());
170    let wear_level_percent = text
171        .lines()
172        .find(|l| l.contains("Wear_Leveling_Count"))
173        .and_then(|l| l.split_whitespace().last())
174        .and_then(|v| v.parse::<u8>().ok())
175        .or_else(|| {
176            text.lines()
177                .find(|l| l.trim_start().starts_with("Percentage Used:"))
178                .and_then(|l| l.split(':').nth(1))
179                .map(|v| v.trim().trim_end_matches('%').to_string())
180                .and_then(|v| v.parse().ok())
181        });
182
183    Some(SmartInfo { model, temperature_c, power_on_hours, reallocated_sectors, wear_level_percent })
184}
185
186fn evaluate(h: &DiskHealth) -> HealthStatus {
187    if let Some(sectors) = h.reallocated_sectors {
188        if sectors > 100 {
189            return HealthStatus::Critical;
190        }
191        if sectors > 0 {
192            return HealthStatus::Warning;
193        }
194    }
195    if let Some(temp) = h.temperature_c {
196        if temp > 65 {
197            return HealthStatus::Critical;
198        }
199        if temp > 55 {
200            return HealthStatus::Warning;
201        }
202    }
203    if let Some(wear) = h.wear_level_percent {
204        if wear > 90 {
205            return HealthStatus::Critical;
206        }
207        if wear > 75 {
208            return HealthStatus::Warning;
209        }
210    }
211
212    let usage_ratio = 1.0 - (h.available_bytes as f64 / h.total_bytes.max(1) as f64);
213    if usage_ratio > 0.97 {
214        return HealthStatus::Critical;
215    }
216    if usage_ratio > 0.90 {
217        return HealthStatus::Warning;
218    }
219    HealthStatus::Good
220}