use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::view::{Alert, alert_key};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnoozeFor {
Hour,
Day,
UntilCleared,
}
impl SnoozeFor {
pub const ALL: [SnoozeFor; 3] = [SnoozeFor::Hour, SnoozeFor::Day, SnoozeFor::UntilCleared];
pub fn label(self) -> &'static str {
match self {
SnoozeFor::Hour => "1 hour",
SnoozeFor::Day => "1 day",
SnoozeFor::UntilCleared => "until it clears",
}
}
fn until_ms(self, now_ms: u64) -> Option<u64> {
match self {
SnoozeFor::Hour => Some(now_ms + 3_600_000),
SnoozeFor::Day => Some(now_ms + 86_400_000),
SnoozeFor::UntilCleared => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snooze {
pub host: String,
pub key: String,
pub text: String,
pub until_ms: Option<u64>,
}
impl Snooze {
fn holds(&self, now_ms: u64) -> bool {
self.until_ms.is_none_or(|until| now_ms < until)
}
}
#[derive(Debug, Default)]
pub struct Snoozes {
entries: Vec<Snooze>,
file: Option<PathBuf>,
seen: Option<(SystemTime, u64)>,
}
pub fn snooze_path() -> Option<PathBuf> {
if let Some(dir) = crate::package::local_state() {
return Some(dir.join("snoozed.json"));
}
dirs::state_dir()
.or_else(dirs::data_local_dir)
.map(|dir| dir.join("rigtop").join("snoozed.json"))
}
impl Snoozes {
pub fn persistent(path: PathBuf) -> Self {
let mut snoozes = Self {
file: Some(path),
..Self::default()
};
snoozes.read();
snoozes
}
pub fn reload(&mut self) {
if self.file.is_some() && self.stamp() != self.seen {
self.read();
}
}
pub fn snooze(&mut self, host: &str, alert: &Alert, duration: SnoozeFor, now_ms: u64) {
self.reload();
let key = alert_key(alert);
self.entries.retain(|s| !(s.host == host && s.key == key));
self.entries.push(Snooze {
host: host.to_owned(),
key,
text: alert.text.clone(),
until_ms: duration.until_ms(now_ms),
});
self.write();
}
pub fn unsnooze(&mut self, host: &str, alert: &Alert) {
self.reload();
let key = alert_key(alert);
let before = self.entries.len();
self.entries.retain(|s| !(s.host == host && s.key == key));
if self.entries.len() != before {
self.write();
}
}
pub fn get(&self, host: &str, alert: &Alert, now_ms: u64) -> Option<&Snooze> {
let key = alert_key(alert);
self.entries
.iter()
.find(|s| s.host == host && s.key == key && s.holds(now_ms))
}
pub fn keys(&self, host: &str, now_ms: u64) -> Vec<String> {
self.entries
.iter()
.filter(|s| s.host == host && s.holds(now_ms))
.map(|s| s.key.clone())
.collect()
}
pub fn split(&self, host: &str, alerts: Vec<Alert>, now_ms: u64) -> (Vec<Alert>, Vec<Alert>) {
alerts
.into_iter()
.partition(|a| self.get(host, a, now_ms).is_none())
}
pub fn prune(&mut self, host: &str, alerts: &[Alert], now_ms: u64) {
let present: Vec<String> = alerts.iter().map(alert_key).collect();
let before = self.entries.len();
self.entries.retain(|s| {
s.host != host
|| match s.until_ms {
Some(until) => now_ms < until,
None => present.contains(&s.key),
}
});
if self.entries.len() != before {
self.write();
}
}
pub fn entries(&self) -> &[Snooze] {
&self.entries
}
fn stamp(&self) -> Option<(SystemTime, u64)> {
let metadata = fs::metadata(self.file.as_ref()?).ok()?;
Some((metadata.modified().ok()?, metadata.len()))
}
fn read(&mut self) {
let Some(path) = &self.file else {
return;
};
self.entries = fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
self.seen = self.stamp();
}
fn write(&mut self) {
let Some(path) = &self.file else {
return;
};
let Ok(text) = serde_json::to_string_pretty(&self.entries) else {
return;
};
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if fs::write(&tmp, text).is_ok() {
let _ = fs::rename(&tmp, path);
}
self.seen = self.stamp();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::Severity;
fn alert(text: &str) -> Alert {
Alert {
severity: Severity::Warning,
text: text.to_owned(),
}
}
#[test]
fn snoozed_alerts_drop_out_until_their_time_or_clearing() {
let mut snoozes = Snoozes::default();
snoozes.snooze("nas", &alert("/srv 92% full"), SnoozeFor::Hour, 0);
snoozes.snooze(
"nas",
&alert("sdc: SMART warning"),
SnoozeFor::UntilCleared,
0,
);
let current = vec![
alert("/srv 93% full"),
alert("sdc: SMART warning"),
alert("I/O pressure 40%"),
];
let (shown, quiet) = snoozes.split("nas", current.clone(), 1_000);
assert_eq!(shown, [alert("I/O pressure 40%")]);
assert_eq!(quiet.len(), 2, "the number changed, the alert did not");
assert_eq!(snoozes.split("zefat", current.clone(), 1_000).0.len(), 3);
let (shown, _) = snoozes.split("nas", current.clone(), 3_600_000);
assert_eq!(shown.len(), 2);
snoozes.prune("nas", ¤t, 3_600_000);
assert_eq!(snoozes.entries().len(), 1);
snoozes.prune("nas", &[alert("I/O pressure 40%")], 3_700_000);
assert!(snoozes.entries().is_empty());
}
#[test]
fn front_ends_share_the_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("rigtop/snoozed.json");
let mut gui = Snoozes::persistent(path.clone());
let mut watch = Snoozes::persistent(path.clone());
gui.snooze("nas", &alert("md0 degraded"), SnoozeFor::Day, 0);
watch.reload();
assert!(watch.get("nas", &alert("md0 degraded"), 1).is_some());
watch.unsnooze("nas", &alert("md0 degraded"));
gui.reload();
assert!(gui.entries().is_empty());
fs::write(&path, "not json").unwrap();
assert!(Snoozes::persistent(path).entries().is_empty());
}
}