use std::collections::{HashMap, HashSet, VecDeque};
use crate::{format, sampler::Snapshot};
const CAPACITY: usize = 120;
#[derive(Default)]
pub struct History {
cpu: VecDeque<f64>,
memory: VecDeque<f64>,
process_cpu: HashMap<u32, VecDeque<f64>>,
}
impl History {
pub fn record(&mut self, snapshot: &Snapshot) {
push_capped(&mut self.cpu, snapshot.totals.cpu_usage as f64);
push_capped(&mut self.memory, memory_percent(snapshot));
for process in &snapshot.processes {
push_capped(
self.process_cpu.entry(process.pid).or_default(),
process.cpu_usage as f64,
);
}
let live: HashSet<u32> = snapshot
.processes
.iter()
.map(|process| process.pid)
.collect();
self.process_cpu.retain(|pid, _| live.contains(pid));
}
pub fn cpu_sparkline(&self, width: usize) -> String {
format::sparkline(&recent(&self.cpu, width), 100.0)
}
pub fn memory_sparkline(&self, width: usize) -> String {
format::sparkline(&recent(&self.memory, width), 100.0)
}
pub fn process_cpu_sparkline(&self, pid: u32, width: usize) -> Option<String> {
let series = self.process_cpu.get(&pid)?;
if series.len() < 2 {
return None;
}
let values = recent(series, width);
let max = values.iter().copied().fold(1.0, f64::max);
Some(format::sparkline(&values, max))
}
}
fn memory_percent(snapshot: &Snapshot) -> f64 {
let totals = &snapshot.totals;
if totals.total_memory > 0 {
totals.used_memory as f64 / totals.total_memory as f64 * 100.0
} else {
0.0
}
}
fn push_capped(series: &mut VecDeque<f64>, value: f64) {
if series.len() == CAPACITY {
series.pop_front();
}
series.push_back(value);
}
fn recent(series: &VecDeque<f64>, width: usize) -> Vec<f64> {
let start = series.len().saturating_sub(width);
series.iter().skip(start).copied().collect()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crate::sampler::{ProcessRow, ProcessTrend, Snapshot, SystemTotals};
use super::{CAPACITY, History};
fn snapshot(cpu: f32, used_memory: u64, processes: Vec<ProcessRow>) -> Snapshot {
Snapshot {
totals: SystemTotals {
cpu_usage: cpu,
cpu_count: 8,
total_memory: 100,
used_memory,
total_swap: 0,
used_swap: 0,
disk_read_rate: 0.0,
disk_write_rate: 0.0,
net_in_rate: 0.0,
net_out_rate: 0.0,
uptime: 0,
host: "host".into(),
os: "macOS".into(),
},
process_count: processes.len(),
processes,
disks: Vec::new(),
networks: Vec::new(),
sample_span: Duration::from_millis(1_000),
}
}
fn process(pid: u32, cpu_usage: f32) -> ProcessRow {
ProcessRow {
pid,
parent_pid: None,
name: format!("process-{pid}"),
sort_name: format!("process-{pid}"),
user: "user".into(),
command: "command".into(),
exe: "-".into(),
cwd: "-".into(),
status: "running".into(),
cpu_usage,
memory: 0,
virtual_memory: 0,
memory_percent: 0.0,
disk_read_rate: 0.0,
disk_write_rate: 0.0,
total_disk_read: 0,
total_disk_write: 0,
run_time: 0,
start_time: 0,
energy_impact: 0.0,
trend: ProcessTrend::default(),
selected_details: None,
search_text: String::new(),
}
}
#[test]
fn records_system_series_and_renders_sparklines() {
let mut history = History::default();
history.record(&snapshot(0.0, 0, Vec::new()));
history.record(&snapshot(100.0, 100, Vec::new()));
assert_eq!(history.cpu_sparkline(8), "▁█");
assert_eq!(history.memory_sparkline(8), "▁█");
}
#[test]
fn process_series_needs_two_samples_and_drops_dead_pids() {
let mut history = History::default();
history.record(&snapshot(0.0, 0, vec![process(1, 10.0)]));
assert!(history.process_cpu_sparkline(1, 8).is_none());
history.record(&snapshot(0.0, 0, vec![process(1, 20.0)]));
assert!(history.process_cpu_sparkline(1, 8).is_some());
history.record(&snapshot(0.0, 0, vec![process(2, 5.0)]));
assert!(history.process_cpu_sparkline(1, 8).is_none());
}
#[test]
fn series_length_is_bounded_by_capacity() {
let mut history = History::default();
for _ in 0..(CAPACITY + 50) {
history.record(&snapshot(50.0, 50, vec![process(1, 50.0)]));
}
assert_eq!(
history.cpu_sparkline(CAPACITY + 50).chars().count(),
CAPACITY
);
}
}