use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::logger::{format_time, json_str};
use crate::procs::human_rate;
pub const DEFAULT_SINCE_SECS: u64 = 24 * 3600;
pub fn parse_duration(text: &str) -> Option<u64> {
let text = text.trim();
let split = text
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(text.len());
let (num, unit) = text.split_at(split);
let n: u64 = num.parse().ok()?;
let mult = match unit {
"" | "s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86400,
"w" => 7 * 86400,
_ => return None,
};
n.checked_mul(mult).filter(|&s| s > 0)
}
#[derive(Debug, PartialEq)]
struct Hit {
name: String,
unit: String,
rate: f64,
}
#[derive(Debug, PartialEq)]
struct Alert {
time: String,
util: f64,
hits: Vec<Hit>,
}
fn parse_rate(text: &str) -> f64 {
let text = text.trim_end_matches("/s");
if let Some(v) = text.strip_suffix("MB") {
v.parse::<f64>().unwrap_or(0.0) * 1024.0 * 1024.0
} else if let Some(v) = text.strip_suffix("KB") {
v.parse::<f64>().unwrap_or(0.0) * 1024.0
} else {
0.0
}
}
fn parse_hit(segment: &str) -> Option<Hit> {
let (_, rest) = segment.split_once(") ")?;
let (name, rest) = rest.split_once(" pid=")?;
let fields = rest.split(" cmd=").next()?;
let mut unit = String::new();
let mut rate = 0.0;
for token in fields.split_whitespace() {
if let Some(u) = token.strip_prefix("unit=") {
unit = u.to_string();
} else if let Some(r) = token
.strip_prefix("R=")
.or_else(|| token.strip_prefix("W="))
{
rate += parse_rate(r);
}
}
Some(Hit {
name: name.to_string(),
unit,
rate,
})
}
fn parse_alert(line: &str) -> Option<Alert> {
let start = line.find("ALERT IO=")?;
let time = if line.starts_with('{') {
let t = line.split_once("\"time\":\"")?.1;
t.get(..19)?.to_string()
} else {
line.get(..19)?.to_string()
};
let body = &line[start + "ALERT IO=".len()..];
let util = body
.split(|c: char| !c.is_ascii_digit() && c != '.')
.next()?
.parse()
.ok()?;
let hits = body.split(" | ").skip(1).filter_map(parse_hit).collect();
Some(Alert { time, util, hits })
}
#[derive(Default)]
struct Offender {
alerts: u32,
total_rate: f64,
peak_rate: f64,
}
#[derive(Default)]
struct Summary {
alerts: u32,
peak_util: f64,
first: String,
last: String,
by_hour: BTreeMap<String, u32>,
offenders: BTreeMap<(String, String), Offender>,
}
fn analyze<'a>(lines: impl Iterator<Item = &'a str>, cutoff: &str) -> Summary {
let mut s = Summary::default();
for alert in lines.filter_map(parse_alert) {
if alert.time.as_str() < cutoff {
continue;
}
s.alerts += 1;
s.peak_util = s.peak_util.max(alert.util);
if s.first.is_empty() || alert.time < s.first {
s.first = alert.time.clone();
}
if alert.time > s.last {
s.last = alert.time.clone();
}
if let Some(h) = alert.time.get(11..13) {
*s.by_hour.entry(h.to_string()).or_default() += 1;
}
for hit in alert.hits {
let o = s.offenders.entry((hit.name, hit.unit)).or_default();
o.alerts += 1;
o.total_rate += hit.rate;
o.peak_rate = o.peak_rate.max(hit.rate);
}
}
s
}
fn ranked(s: &Summary, top: usize) -> Vec<(&(String, String), &Offender)> {
let mut v: Vec<_> = s.offenders.iter().collect();
v.sort_by(|a, b| {
b.1.alerts.cmp(&a.1.alerts).then(
(b.1.total_rate / b.1.alerts as f64).total_cmp(&(a.1.total_rate / a.1.alerts as f64)),
)
});
v.truncate(top);
v
}
fn render_text(s: &Summary, since_label: &str, log: &Path, top: usize) -> String {
let mut out = format!(
"iowatchd report: alerts in the last {since_label} (from {})\n",
log.display()
);
if s.alerts == 0 {
out.push_str("No IO alerts in this period.\n");
return out;
}
out.push_str(&format!(
"alerts: {} peak IO: {:.0}% first: {} last: {}\n",
s.alerts, s.peak_util, s.first, s.last
));
out.push_str("\nAlerts by hour of day:\n");
let max = s.by_hour.values().copied().max().unwrap_or(1).max(1);
for (hour, n) in &s.by_hour {
let bar = "#".repeat((n * 30 / max).max(1) as usize);
out.push_str(&format!(" {hour}h {bar} {n}\n"));
}
if s.offenders.is_empty() {
out.push_str("\nNo process was identified in these alerts.\n");
return out;
}
out.push_str("\nTop offenders (by number of alerts they appeared in):\n");
out.push_str(&format!(
" {:>2} {:<20} {:<24} {:>6} {:>9} {:>9}\n",
"#", "process", "unit", "alerts", "avg IO", "peak IO"
));
for (i, ((name, unit), o)) in ranked(s, top).into_iter().enumerate() {
out.push_str(&format!(
" {:>2} {:<20} {:<24} {:>6} {:>9} {:>9}\n",
i + 1,
name,
if unit.is_empty() { "-" } else { unit },
o.alerts,
human_rate(o.total_rate / o.alerts as f64),
human_rate(o.peak_rate)
));
}
out
}
fn render_json(s: &Summary, since_secs: u64, top: usize) -> String {
let offenders: Vec<String> = ranked(s, top)
.into_iter()
.map(|((name, unit), o)| {
format!(
"{{\"name\":{},\"unit\":{},\"alerts\":{},\"avg_bytes_per_s\":{:.0},\"peak_bytes_per_s\":{:.0}}}",
json_str(name),
json_str(unit),
o.alerts,
o.total_rate / o.alerts as f64,
o.peak_rate
)
})
.collect();
format!(
"{{\"since_seconds\":{since_secs},\"alerts\":{},\"peak_util\":{:.0},\"first\":{},\"last\":{},\"offenders\":[{}]}}",
s.alerts,
s.peak_util,
json_str(&s.first),
json_str(&s.last),
offenders.join(",")
)
}
fn label(secs: u64) -> String {
match secs {
s if s % 86400 == 0 => format!("{}d", s / 86400),
s if s % 3600 == 0 => format!("{}h", s / 3600),
s if s % 60 == 0 => format!("{}m", s / 60),
s => format!("{s}s"),
}
}
pub fn run(log_file: Option<&Path>, since_secs: u64, top: usize, json: bool) -> i32 {
let Some(path) = log_file else {
eprintln!(
"iowatchd: error: no log_file configured; the report needs the log file \
(journal-only setups: use 'iowatchd-log')"
);
return 1;
};
let mut old = path.as_os_str().to_os_string();
old.push(".1");
let mut text = fs::read_to_string(&old).unwrap_or_default();
match fs::read_to_string(path) {
Ok(t) => text.push_str(&t),
Err(e) if text.is_empty() => {
eprintln!("iowatchd: error: could not read {}: {e}", path.display());
return 1;
}
Err(_) => {}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let cutoff = format_time(now - since_secs as i64);
let summary = analyze(text.lines(), &cutoff);
if json {
println!("{}", render_json(&summary, since_secs, top));
} else {
print!("{}", render_text(&summary, &label(since_secs), path, top));
}
0
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: &str = "2026-10-17 10:00:02 ALERT IO=97% on sda (await=45.2ms queue=3.1 read=0KB/s write=60KB/s psi=12.0%) | 1) plasmashell pid=987 user=thiago unit=app-plasmashell R=0KB/s W=48KB/s cmd=\"/usr/bin/plasmashell --no-respawn\" | 2) brave pid=2163 user=thiago R=1.0MB/s W=12KB/s cmd=\"brave | x\"";
#[test]
fn durations_are_parsed() {
assert_eq!(parse_duration("90"), Some(90));
assert_eq!(parse_duration("15m"), Some(900));
assert_eq!(parse_duration("24h"), Some(86400));
assert_eq!(parse_duration("7d"), Some(604800));
assert_eq!(parse_duration("0"), None);
assert_eq!(parse_duration("h"), None);
assert_eq!(parse_duration("5x"), None);
}
#[test]
fn text_alert_is_parsed() {
let a = parse_alert(TEXT).unwrap();
assert_eq!(a.time, "2026-10-17 10:00:02");
assert_eq!(a.util, 97.0);
assert_eq!(a.hits.len(), 2);
assert_eq!(a.hits[0].name, "plasmashell");
assert_eq!(a.hits[0].unit, "app-plasmashell");
assert_eq!(a.hits[0].rate, 48.0 * 1024.0);
assert_eq!(a.hits[1].unit, "");
assert_eq!(a.hits[1].rate, 1024.0 * 1024.0 + 12.0 * 1024.0);
}
#[test]
fn json_alert_is_parsed() {
let line = "{\"time\":\"2026-10-17 11:30:00\",\"event\":\"alert\",\"message\":\"ALERT IO=100% on sda | 1) cron pid=5 user=root unit=cron R=0KB/s W=8KB/s cmd=\\\"cron\\\"\",\"device\":\"sda\"}";
let a = parse_alert(line).unwrap();
assert_eq!(a.time, "2026-10-17 11:30:00");
assert_eq!(a.util, 100.0);
assert_eq!(a.hits[0].name, "cron");
assert_eq!(a.hits[0].rate, 8.0 * 1024.0);
assert!(parse_alert("2026-10-17 10:00:00 IO back to normal: 10% on sda").is_none());
}
#[test]
fn summary_filters_by_time_and_ranks() {
let old = TEXT.replace("2026-10-17 10:00:02", "2026-10-10 10:00:02");
let later = TEXT.replace("10:00:02", "11:00:00");
let log = format!("{old}\n{TEXT}\n{later}\n");
let s = analyze(log.lines(), "2026-10-17 00:00:00");
assert_eq!(s.alerts, 2);
assert_eq!(s.peak_util, 97.0);
assert_eq!(s.last, "2026-10-17 11:00:00");
assert_eq!(s.by_hour.get("10"), Some(&1));
let top = ranked(&s, 1);
assert_eq!(top[0].1.alerts, 2);
assert_eq!(top[0].0 .0, "brave"); assert!(render_text(&s, "24h", Path::new("/x"), 5).contains("brave"));
assert!(render_json(&s, 86400, 5).starts_with("{\"since_seconds\":86400,\"alerts\":2"));
}
}