diskr/analyzer/
disk_health.rs1use 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 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}