use std::fs;
use std::path::PathBuf;
fn parse_root_source(mountinfo: &str) -> Option<String> {
mountinfo.lines().rev().find_map(|line| {
let (head, tail) = line.split_once(" - ")?;
if head.split_whitespace().nth(4)? != "/" {
return None;
}
let source = tail.split_whitespace().nth(1)?;
source.starts_with("/dev/").then(|| source.to_string())
})
}
fn physical_disk(name: &str, depth: u32) -> Option<String> {
let sys = PathBuf::from("/sys/class/block").join(name);
if depth > 8 || !sys.exists() {
return None;
}
let first_slave = fs::read_dir(sys.join("slaves")).ok().and_then(|d| {
d.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.min()
});
if let Some(slave) = first_slave {
return physical_disk(&slave, depth + 1);
}
if sys.join("partition").exists() {
let real = fs::canonicalize(&sys).ok()?;
return real.parent()?.file_name()?.to_str().map(String::from);
}
Some(name.to_string())
}
pub fn detect_device() -> Option<String> {
let from_root = fs::read_to_string("/proc/self/mountinfo")
.ok()
.and_then(|m| parse_root_source(&m))
.and_then(|src| fs::canonicalize(src).ok()) .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.and_then(|n| physical_disk(&n, 0));
if from_root.is_some() {
return from_root;
}
let mut disks: Vec<String> = fs::read_dir("/sys/block")
.ok()?
.flatten()
.filter(|e| e.path().join("device").exists())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
disks.sort();
disks.into_iter().next()
}
fn sysfs(path: &str) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
pub fn list_devices() -> Vec<String> {
let auto = detect_device();
let mut names: Vec<String> = fs::read_dir("/sys/block")
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("device").exists())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
.iter()
.map(|n| {
let sectors: u64 = sysfs(&format!("/sys/block/{n}/size"))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let kind = match sysfs(&format!("/sys/block/{n}/queue/rotational")).as_deref() {
Some("1") => "HDD",
Some("0") => "SSD",
_ => "?",
};
let model = sysfs(&format!("/sys/block/{n}/device/model")).unwrap_or_default();
let mark = if auto.as_deref() == Some(n) {
" (auto)"
} else {
""
};
format!(
"{n:<12} {:>8.1} GiB {kind} {model}{mark}",
sectors as f64 * 512.0 / (1u64 << 30) as f64
)
})
.collect()
}
#[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct DiskStats {
pub reads: u64,
pub read_sectors: u64,
pub read_ms: u64,
pub writes: u64,
pub write_sectors: u64,
pub write_ms: u64,
pub io_ms: u64,
pub weighted_ms: u64,
}
fn parse_stats(diskstats: &str, device: &str) -> Option<DiskStats> {
diskstats.lines().find_map(|line| {
let p: Vec<&str> = line.split_whitespace().collect();
if p.get(2) != Some(&device) {
return None;
}
let f = |i: usize| p.get(i)?.parse::<u64>().ok();
Some(DiskStats {
reads: f(3)?,
read_sectors: f(5)?,
read_ms: f(6)?,
writes: f(7)?,
write_sectors: f(9)?,
write_ms: f(10)?,
io_ms: f(12)?,
weighted_ms: f(13)?,
})
})
}
pub fn read_stats(device: &str) -> Option<DiskStats> {
parse_stats(&fs::read_to_string("/proc/diskstats").ok()?, device)
}
pub fn utilization(delta_ticks_ms: u64, elapsed_ms: u64) -> f64 {
if elapsed_ms == 0 {
return 0.0;
}
(delta_ticks_ms as f64 / elapsed_ms as f64 * 100.0).min(100.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metrics {
pub util: f64,
pub await_ms: Option<f64>,
pub queue_depth: f64,
pub read_bytes_per_s: f64,
pub write_bytes_per_s: f64,
}
pub fn metrics(prev: &DiskStats, now: &DiskStats, elapsed_ms: u64) -> Metrics {
let d = |a: u64, b: u64| a.saturating_sub(b);
let secs = (elapsed_ms as f64 / 1000.0).max(0.001);
let ios = d(now.reads, prev.reads) + d(now.writes, prev.writes);
let io_time = d(now.read_ms, prev.read_ms) + d(now.write_ms, prev.write_ms);
Metrics {
util: utilization(d(now.io_ms, prev.io_ms), elapsed_ms),
await_ms: (ios > 0).then(|| io_time as f64 / ios as f64),
queue_depth: d(now.weighted_ms, prev.weighted_ms) as f64 / (secs * 1000.0),
read_bytes_per_s: d(now.read_sectors, prev.read_sectors) as f64 * 512.0 / secs,
write_bytes_per_s: d(now.write_sectors, prev.write_sectors) as f64 * 512.0 / secs,
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pressure {
pub some_avg10: f64,
pub full_avg10: f64,
}
fn parse_pressure(text: &str) -> Option<Pressure> {
let avg10 = |kind: &str| {
text.lines()
.find_map(|l| l.strip_prefix(kind)?.trim_start().strip_prefix("avg10="))
.and_then(|v| v.split_whitespace().next()?.parse::<f64>().ok())
};
Some(Pressure {
some_avg10: avg10("some")?,
full_avg10: avg10("full")?,
})
}
pub fn read_pressure() -> Option<Pressure> {
parse_pressure(&fs::read_to_string("/proc/pressure/io").ok()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stats_are_read_from_the_right_device() {
let s = " 8 0 sda 100 0 800 50 200 0 1600 70 0 4242 120 0 0 0 0 0 0\n\
\x20 8 1 sda1 1 0 8 5 2 0 16 7 0 99 12 0 0 0 0 0 0\n";
let a = parse_stats(s, "sda").unwrap();
assert_eq!((a.reads, a.read_sectors, a.read_ms), (100, 800, 50));
assert_eq!((a.writes, a.write_sectors, a.write_ms), (200, 1600, 70));
assert_eq!((a.io_ms, a.weighted_ms), (4242, 120));
assert_eq!(parse_stats(s, "sda1").unwrap().io_ms, 99);
assert_eq!(parse_stats(s, "nvme0n1"), None);
}
#[test]
fn metrics_follow_iostat_definitions() {
let a = DiskStats::default();
let b = DiskStats {
reads: 10,
read_sectors: 2048,
read_ms: 100,
writes: 10,
write_sectors: 4096,
write_ms: 300,
io_ms: 900,
weighted_ms: 3000,
};
let m = metrics(&a, &b, 2000);
assert_eq!(m.util, 45.0);
assert_eq!(m.await_ms, Some(20.0));
assert_eq!(m.queue_depth, 1.5);
assert_eq!(m.read_bytes_per_s, 512.0 * 1024.0);
assert_eq!(m.write_bytes_per_s, 1024.0 * 1024.0);
assert_eq!(metrics(&b, &b, 2000).await_ms, None);
}
#[test]
fn pressure_is_parsed() {
let t = "some avg10=1.50 avg60=0.50 avg300=0.10 total=123\n\
full avg10=0.25 avg60=0.10 avg300=0.00 total=45\n";
assert_eq!(
parse_pressure(t),
Some(Pressure {
some_avg10: 1.5,
full_avg10: 0.25
})
);
assert_eq!(parse_pressure(""), None);
}
#[test]
fn root_source_is_extracted_from_mountinfo() {
let m = "22 1 8:2 / / rw,relatime shared:1 - ext4 /dev/sda2 rw\n\
30 22 0:25 / /tmp rw - tmpfs tmpfs rw\n\
40 22 0:40 /@ / rw - btrfs /dev/mapper/cryptroot rw\n";
assert_eq!(
parse_root_source(m).as_deref(),
Some("/dev/mapper/cryptroot")
);
assert_eq!(
parse_root_source("1 0 0:1 / / rw - overlay overlay rw\n"),
None
);
}
#[test]
fn utilization_is_capped_at_100() {
assert_eq!(utilization(500, 1000), 50.0);
assert_eq!(utilization(2000, 1000), 100.0);
assert_eq!(utilization(10, 0), 0.0);
}
}