Vitals log viewer

egui viewer for the vitals-log CSV files: status strip, grouped
time-series plots, incremental tailing of the monthly logs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 23:40:38 -07:00
co-authored by Claude Fable 5.1
commit 07f68b92a0
7 changed files with 5342 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+4242
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "vitals"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
eframe = "0.32"
egui_plot = "0.33"
[dev-dependencies]
tempfile = "3.27.0"
+24
View File
@@ -0,0 +1,24 @@
# vitals
Viewer for the CSV logs written by `vitals-log` (`~/.local/bin/vitals-log`, run
every minute by the `vitals-log.timer` systemd user unit). Shows the latest
reading and time-series plots of temperatures, battery, memory, memory pressure
and load. Built with egui and egui_plot.
cargo install --path .
vitals [--dir PATH] [--interval SECONDS] [--range 1h|6h|24h|7d|all]
The log directory defaults to `$XDG_STATE_HOME/vitals` (`~/.local/state/vitals`).
Every `vitals-YYYY-MM.csv` there is loaded at startup; after that only bytes
appended since the last check are read (every 10 s by default). Columns are
matched by header name, so a changed log layout shows gaps rather than breaking.
Drag to pan and ctrl-scroll or pinch to zoom the time axis; all plots move
together. Picking a range preset returns to following the clock. Lines break
where the logger was not running, and on-battery periods are shaded in the
battery plot. Wide ranges are reduced to per-bucket min/max, so spikes stay
visible.
- `src/data.rs` — parsed series, windowing, gap splitting, downsampling
- `src/tail.rs` — incremental file reader, month rollover, replaced-file reload
- `src/main.rs` — the UI; the `PANELS` table defines the plots
+294
View File
@@ -0,0 +1,294 @@
//! In-memory time series parsed from the vitals-log CSV files.
use std::collections::{BTreeMap, HashMap};
use chrono::DateTime;
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
/// Samples further apart than this are not joined by a line (logger stopped, machine suspended).
pub const GAP_SECS: f64 = 180.0;
/// Column-oriented samples. Columns are keyed by header name so files with
/// different layouts can be merged; cells that are missing or non-numeric are NaN.
#[derive(Default)]
pub struct Series {
/// Unix seconds, non-decreasing.
pub t: Vec<f64>,
cols: BTreeMap<String, Vec<f64>>,
/// Most recent value of each non-numeric column (bat_status, power_profile).
text: HashMap<String, String>,
/// Lines rejected: wrong field count, bad timestamp, or out of order.
pub skipped: usize,
}
impl Series {
pub fn len(&self) -> usize {
self.t.len()
}
pub fn is_empty(&self) -> bool {
self.t.is_empty()
}
pub fn col(&self, name: &str) -> Option<&[f64]> {
self.cols.get(name).map(Vec::as_slice)
}
/// Latest non-NaN value of a column.
pub fn last(&self, name: &str) -> Option<f64> {
self.col(name)?.iter().rev().copied().find(|v| !v.is_nan())
}
pub fn last_text(&self, name: &str) -> Option<&str> {
self.text.get(name).map(String::as_str)
}
/// Append one CSV data line laid out according to `header`.
pub fn push_line(&mut self, header: &[String], line: &str) {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() != header.len() || header.is_empty() {
self.skipped += 1;
return;
}
let Ok(ts) = DateTime::parse_from_str(fields[0], TIMESTAMP_FORMAT) else {
self.skipped += 1;
return;
};
let t = ts.timestamp() as f64;
if self.t.last().is_some_and(|&last| t < last) {
self.skipped += 1;
return;
}
let n = self.t.len();
self.t.push(t);
for (name, cell) in header.iter().zip(&fields).skip(1) {
let value = cell.parse::<f64>().unwrap_or(f64::NAN);
if value.is_nan() && !cell.is_empty() {
self.text.insert(name.clone(), cell.to_string());
}
self.cols
.entry(name.clone())
.or_insert_with(|| vec![f64::NAN; n])
.push(value);
}
// Columns this file's layout lacks still need a cell for the row.
for col in self.cols.values_mut() {
col.resize(n + 1, f64::NAN);
}
}
/// Index range of samples with `lo <= t <= hi`, widened by one sample each
/// side so lines run to the plot edge.
pub fn window(&self, lo: f64, hi: f64) -> std::ops::Range<usize> {
let start = self.t.partition_point(|&t| t < lo).saturating_sub(1);
let end = (self.t.partition_point(|&t| t <= hi) + 1).min(self.t.len());
start..end.max(start)
}
/// Polylines for one column over an index range: split at NaNs and time
/// gaps, and reduced to per-bucket min/max when there are more samples than
/// `max_buckets` so spikes survive downsampling.
pub fn segments(
&self,
name: &str,
range: std::ops::Range<usize>,
max_buckets: usize,
) -> Vec<Vec<[f64; 2]>> {
let Some(col) = self.col(name) else {
return Vec::new();
};
let bucket = range.len().div_ceil(max_buckets.max(1)).max(1);
let mut out = Vec::new();
let mut current: Vec<[f64; 2]> = Vec::new();
let mut prev_t = f64::NAN;
let mut i = range.start;
while i < range.end {
let end = (i + bucket).min(range.end);
for point in reduce(&self.t[i..end], &col[i..end]) {
match point {
Some([t, v]) => {
if t - prev_t > GAP_SECS * bucket as f64 && !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.push([t, v]);
prev_t = t;
}
None if !current.is_empty() => out.push(std::mem::take(&mut current)),
None => {}
}
}
i = end;
}
if !current.is_empty() {
out.push(current);
}
out
}
/// Time intervals where a column equals `value` (e.g. ac_online == 0).
pub fn intervals_where(&self, name: &str, value: f64) -> Vec<(f64, f64)> {
let Some(col) = self.col(name) else {
return Vec::new();
};
let mut out: Vec<(f64, f64)> = Vec::new();
let mut open: Option<(f64, f64)> = None;
for (&t, &v) in self.t.iter().zip(col) {
let matches = v == value;
match &mut open {
Some((_, end)) if matches && t - *end <= GAP_SECS => *end = t,
_ => {
out.extend(open.take());
if matches {
open = Some((t, t));
}
}
}
}
out.extend(open);
out
}
}
/// One bucket of samples → its points in time order: a lone sample as-is, a
/// larger bucket as its min and max. `None` marks a bucket with no data, which
/// breaks the line.
fn reduce(t: &[f64], v: &[f64]) -> Vec<Option<[f64; 2]>> {
let mut min: Option<[f64; 2]> = None;
let mut max: Option<[f64; 2]> = None;
for (&t, &v) in t.iter().zip(v) {
if v.is_nan() {
continue;
}
if min.is_none_or(|m| v < m[1]) {
min = Some([t, v]);
}
if max.is_none_or(|m| v > m[1]) {
max = Some([t, v]);
}
}
match (min, max) {
(Some(a), Some(b)) if a[0] == b[0] => vec![Some(a)],
(Some(a), Some(b)) if a[0] < b[0] => vec![Some(a), Some(b)],
(Some(a), Some(b)) => vec![Some(b), Some(a)],
_ => vec![None],
}
}
pub fn parse_header(line: &str) -> Vec<String> {
line.split(',').map(|s| s.trim().to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn header(s: &str) -> Vec<String> {
parse_header(s)
}
#[test]
fn parses_values_gaps_and_text() {
let h = header("timestamp,cpu_pkg_c,wifi_c,bat_status");
let mut s = Series::default();
s.push_line(&h, "2026-09-19T23:21:00-0700,76.0,,Not charging");
s.push_line(&h, "2026-09-19T23:22:00-0700,73.5,47.0,Discharging");
assert_eq!(s.len(), 2);
assert_eq!(s.t[1] - s.t[0], 60.0);
assert_eq!(s.col("cpu_pkg_c").unwrap(), &[76.0, 73.5]);
assert!(s.col("wifi_c").unwrap()[0].is_nan());
assert_eq!(s.last("wifi_c"), Some(47.0));
assert_eq!(s.last_text("bat_status"), Some("Discharging"));
assert_eq!(s.skipped, 0);
}
#[test]
fn rejects_malformed_and_out_of_order_lines() {
let h = header("timestamp,a");
let mut s = Series::default();
s.push_line(&h, "2026-09-19T23:21:00-0700,1");
s.push_line(&h, "2026-09-19T23:22:00-0700,1,extra");
s.push_line(&h, "not-a-time,1");
s.push_line(&h, "2026-09-19T23:20:00-0700,1");
s.push_line(&h, "2026-09-19T23:21:00-0700,2");
assert_eq!(s.col("a").unwrap(), &[1.0, 2.0]);
assert_eq!(s.skipped, 3);
}
#[test]
fn merges_files_with_different_layouts_by_name() {
let mut s = Series::default();
s.push_line(&header("timestamp,a,b"), "2026-09-19T23:21:00-0700,1,2");
s.push_line(&header("timestamp,c,a"), "2026-09-19T23:22:00-0700,3,4");
assert_eq!(s.col("a").unwrap(), &[1.0, 4.0]);
let b = s.col("b").unwrap();
assert!(b[0] == 2.0 && b[1].is_nan());
let c = s.col("c").unwrap();
assert!(c[0].is_nan() && c[1] == 3.0);
}
fn minutely(values: &[f64]) -> Series {
let h = header("timestamp,v");
let mut s = Series::default();
for (i, v) in values.iter().enumerate() {
let cell = if v.is_nan() { String::new() } else { v.to_string() };
s.push_line(&h, &format!("2026-09-19T{:02}:{:02}:00-0700,{cell}", i / 60, i % 60));
}
s
}
#[test]
fn segments_break_at_nan() {
let s = minutely(&[1.0, 2.0, f64::NAN, 4.0]);
let segs = s.segments("v", 0..4, 100);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].len(), 2);
assert_eq!(segs[1], vec![[s.t[3], 4.0]]);
}
#[test]
fn segments_break_at_time_gaps() {
let h = header("timestamp,v");
let mut s = Series::default();
s.push_line(&h, "2026-09-19T10:00:00-0700,1");
s.push_line(&h, "2026-09-19T10:01:00-0700,2");
s.push_line(&h, "2026-09-19T12:00:00-0700,3");
assert_eq!(s.segments("v", 0..3, 100).len(), 2);
}
#[test]
fn downsampling_keeps_spikes() {
let mut values = vec![10.0; 600];
values[317] = 99.0;
values[401] = 1.0;
let s = minutely(&values);
let points: Vec<[f64; 2]> = s.segments("v", 0..600, 20).concat();
assert!(points.len() <= 40);
assert!(points.iter().any(|p| p[1] == 99.0));
assert!(points.iter().any(|p| p[1] == 1.0));
assert!(points.windows(2).all(|w| w[0][0] <= w[1][0]));
}
#[test]
fn window_selects_visible_samples_with_margin() {
let s = minutely(&[0.0; 10]);
assert_eq!(s.window(s.t[3], s.t[5]), 2..7);
assert_eq!(s.window(s.t[0] - 1e6, s.t[9] + 1e6), 0..10);
assert!(s.window(s.t[9] + 1e5, s.t[9] + 1e6).len() <= 1);
}
#[test]
fn intervals_where_finds_battery_periods() {
let s = minutely(&[1.0, 0.0, 0.0, 1.0, 0.0]);
assert_eq!(
s.intervals_where("v", 0.0),
vec![(s.t[1], s.t[2]), (s.t[4], s.t[4])]
);
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Viewer for the CSV files written by `vitals-log`.
mod data;
mod tail;
use std::ops::RangeInclusive;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use chrono::{Local, Offset, TimeZone};
use eframe::egui::text::{LayoutJob, TextFormat};
use eframe::egui::{self, Color32, RichText, Stroke, Theme};
use egui_plot::{GridInput, GridMark, Line, Plot, PlotPoints, Polygon};
use data::Series;
use tail::Tailer;
/// Points per series per plot before min/max downsampling kicks in.
const MAX_BUCKETS: usize = 1000;
const PLOT_HEIGHT: f32 = 170.0;
/// A last sample older than this means the logger has stopped.
const STALE_SECS: f64 = 180.0;
struct SeriesSpec {
column: &'static str,
label: &'static str,
}
/// One plot. Every series in a panel shares a unit, so there is one y-axis.
struct Panel {
title: &'static str,
unit: &'static str,
/// Multiplier from the logged value to the displayed unit.
scale: f64,
/// Pin the y-axis to include these values.
include_y: &'static [f64],
open: bool,
series: &'static [SeriesSpec],
}
const fn s(column: &'static str, label: &'static str) -> SeriesSpec {
SeriesSpec { column, label }
}
const PANELS: &[Panel] = &[
Panel {
title: "CPU temperature",
unit: "°C",
scale: 1.0,
include_y: &[],
open: true,
series: &[
s("cpu_pkg_c", "Package"),
s("cpu_core_max_c", "Hottest core"),
s("ec_peci_c", "PECI (EC)"),
s("ec_cpu_c", "CPU board (EC)"),
],
},
Panel {
title: "Chassis temperature",
unit: "°C",
scale: 1.0,
include_y: &[],
open: true,
series: &[
s("ec_top_skin_c", "Top skin"),
s("ec_local_c", "Mainboard"),
s("ec_charger_c", "Charger"),
s("ec_battery_c", "Battery"),
],
},
Panel {
title: "Storage, RAM and wifi temperature",
unit: "°C",
scale: 1.0,
include_y: &[],
open: false,
series: &[
s("nvme_composite_c", "NVMe composite"),
s("nvme_sensor1_c", "NVMe sensor 1"),
s("nvme_sensor2_c", "NVMe sensor 2"),
s("ram_c", "RAM"),
s("wifi_c", "Wifi"),
],
},
Panel {
title: "ACPI thermal zones",
unit: "°C",
scale: 1.0,
include_y: &[],
open: false,
series: &[
s("acpitz1_c", "ACPI zone 1"),
s("acpitz2_c", "ACPI zone 2"),
s("acpitz3_c", "ACPI zone 3"),
s("acpitz4_c", "ACPI zone 4"),
s("acpitz5_c", "ACPI zone 5"),
],
},
Panel {
title: "Battery charge",
unit: "%",
scale: 1.0,
include_y: &[0.0, 100.0],
open: true,
series: &[s("bat_pct", "Charge")],
},
Panel {
title: "Battery power (negative = discharging)",
unit: "W",
scale: 1.0,
include_y: &[0.0],
open: false,
series: &[s("bat_watts", "Power")],
},
Panel {
title: "Memory",
unit: "GiB",
scale: 1.0 / 1024.0,
include_y: &[0.0],
open: true,
series: &[
s("mem_used_mib", "Used"),
s("mem_avail_mib", "Available"),
s("swap_used_mib", "Swap used"),
],
},
Panel {
title: "Memory pressure (share of time stalled)",
unit: "%",
scale: 1.0,
include_y: &[0.0],
open: true,
series: &[
s("psi_some_avg10", "Some, 10 s"),
s("psi_some_avg60", "Some, 60 s"),
s("psi_full_avg10", "Full, 10 s"),
s("psi_full_avg60", "Full, 60 s"),
],
},
Panel {
title: "Load average (1 min)",
unit: "",
scale: 1.0,
include_y: &[0.0],
open: false,
series: &[s("load1", "Load")],
},
];
/// Categorical slots in fixed order; a series keeps its slot regardless of
/// which others are visible.
const SERIES_DARK: [Color32; 5] = [
Color32::from_rgb(0x39, 0x87, 0xe5),
Color32::from_rgb(0xd9, 0x59, 0x26),
Color32::from_rgb(0x19, 0x9e, 0x70),
Color32::from_rgb(0xc9, 0x85, 0x00),
Color32::from_rgb(0xd5, 0x51, 0x81),
];
const SERIES_LIGHT: [Color32; 5] = [
Color32::from_rgb(0x2a, 0x78, 0xd6),
Color32::from_rgb(0xeb, 0x68, 0x34),
Color32::from_rgb(0x1b, 0xaf, 0x7a),
Color32::from_rgb(0xed, 0xa1, 0x00),
Color32::from_rgb(0xe8, 0x7b, 0xa4),
];
const CRITICAL: Color32 = Color32::from_rgb(0xd0, 0x3b, 0x3b);
#[derive(Clone, Copy, PartialEq)]
enum TimeRange {
/// Trailing window that follows the clock.
Last(f64),
All,
/// Set by dragging or zooming a plot.
Custom(f64, f64),
}
const PRESETS: &[(&str, TimeRange)] = &[
("1 h", TimeRange::Last(3600.0)),
("6 h", TimeRange::Last(6.0 * 3600.0)),
("24 h", TimeRange::Last(86400.0)),
("7 d", TimeRange::Last(7.0 * 86400.0)),
("All", TimeRange::All),
];
/// Look up a preset by its label, ignoring case and spaces ("24h", "all").
fn preset(name: &str) -> Option<TimeRange> {
PRESETS
.iter()
.find(|(label, _)| label.replace(' ', "").eq_ignore_ascii_case(name))
.map(|&(_, range)| range)
}
struct App {
tailer: Tailer,
interval: Duration,
last_poll: Option<Instant>,
error: Option<String>,
range: TimeRange,
}
impl App {
fn new(dir: PathBuf, interval: Duration, range: TimeRange) -> Self {
Self {
tailer: Tailer::new(dir),
interval,
last_poll: None,
error: None,
range,
}
}
fn poll(&mut self) {
self.error = self.tailer.poll().err().map(|e| e.to_string());
self.last_poll = Some(Instant::now());
}
fn bounds(&self, now: f64) -> (f64, f64) {
match self.range {
TimeRange::Last(secs) => (now - secs, now),
TimeRange::All => {
let first = self.tailer.series.t.first().copied().unwrap_or(now - 3600.0);
(first.min(now - 600.0), now)
}
TimeRange::Custom(lo, hi) => (lo, hi),
}
}
fn toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
for &(label, preset) in PRESETS {
if ui.selectable_label(self.range == preset, label).clicked() {
self.range = preset;
}
}
if matches!(self.range, TimeRange::Custom(..)) {
ui.weak("custom range — pick a preset to follow the clock again");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Refresh").clicked() {
self.poll();
}
ui.weak(format!("checks every {} s", self.interval.as_secs()));
});
});
}
fn status(&self, ui: &mut egui::Ui, now: f64) {
let series = &self.tailer.series;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 24.0;
if let Some(v) = series.last("cpu_pkg_c") {
stat(ui, "CPU", format!("{v:.0} °C"));
}
if let Some((name, v)) = hottest(series) {
stat(ui, "Hottest", format!("{v:.0} °C {name}"));
}
let mut power = match series.last("ac_online") {
Some(1.0) => "AC".to_string(),
Some(_) => "Battery".to_string(),
None => "?".to_string(),
};
if let Some(pct) = series.last("bat_pct") {
power += &format!(" · {pct:.0}%");
}
if let Some(status) = series.last_text("bat_status") {
power += &format!(" · {status}");
}
if let Some(w) = series.last("bat_watts") {
power += &format!(" · {w:.1} W");
}
stat(ui, "Power", power);
if let Some(profile) = series.last_text("power_profile") {
stat(ui, "Profile", profile.to_string());
}
if let (Some(used), Some(total)) = (series.last("mem_used_mib"), series.last("mem_total_mib")) {
stat(
ui,
"Memory",
format!("{:.1} / {:.1} GiB ({:.0}%)", used / 1024.0, total / 1024.0, 100.0 * used / total),
);
}
if let Some(&last) = series.t.last() {
let age = (now - last).max(0.0);
if age > STALE_SECS {
ui.label(
RichText::new(format!("⚠ Logger stale — last sample {} ago", human_age(age)))
.color(CRITICAL)
.strong(),
);
} else {
stat(ui, "Last sample", format!("{} ago", human_age(age)));
}
}
if series.skipped > 0 {
ui.weak(format!("{} malformed lines skipped", series.skipped));
}
});
}
/// Draw one plot; returns the x-range the user moved it to, if they did.
fn plot(&self, ui: &mut egui::Ui, panel: &Panel, lo: f64, hi: f64) -> Option<(f64, f64)> {
let series = &self.tailer.series;
let palette = if ui.visuals().dark_mode { SERIES_DARK } else { SERIES_LIGHT };
let shade = ui.visuals().weak_text_color().gamma_multiply(0.25);
let unit = panel.unit;
let mut plot = Plot::new(panel.title)
.height(PLOT_HEIGHT)
.allow_drag([true, false])
.allow_zoom([true, false])
.allow_scroll(false)
.allow_boxed_zoom(false)
.allow_double_click_reset(false)
.link_cursor("vitals", [true, false])
.y_axis_min_width(40.0)
.x_grid_spacer(time_grid)
.x_axis_formatter(time_tick)
.label_formatter(move |name, point| {
let time = local_time(point.x).format("%a %b %-d %H:%M");
if name.is_empty() {
format!("{time}")
} else {
format!("{name}\n{:.1} {unit}\n{time}", point.y)
}
});
for &y in panel.include_y {
plot = plot.include_y(y);
}
// The legend sits above the plot, in panel order, so it never covers data.
if panel.series.len() > 1 {
ui.horizontal_wrapped(|ui| {
for (slot, spec) in panel.series.iter().enumerate() {
let mut job = LayoutJob::default();
job.append("", 0.0, TextFormat { color: palette[slot], ..Default::default() });
job.append(spec.label, 0.0, TextFormat { color: ui.visuals().text_color(), ..Default::default() });
ui.add(egui::Label::new(job).wrap_mode(egui::TextWrapMode::Extend));
ui.add_space(8.0);
}
});
}
let response = plot.show(ui, |plot_ui| {
plot_ui.set_plot_bounds_x(lo..=hi);
if panel.series[0].column == "bat_pct" {
for (start, end) in series.intervals_where("ac_online", 0.0) {
let end = end + 60.0;
plot_ui.polygon(
Polygon::new(
"On battery",
PlotPoints::from(vec![[start, 0.0], [end, 0.0], [end, 100.0], [start, 100.0]]),
)
.fill_color(shade)
.stroke(Stroke::NONE)
.allow_hover(false),
);
}
}
let window = series.window(lo, hi);
for (slot, spec) in panel.series.iter().enumerate() {
for mut segment in series.segments(spec.column, window.clone(), MAX_BUCKETS) {
for point in &mut segment {
point[1] *= panel.scale;
}
plot_ui.line(
Line::new(spec.label, PlotPoints::from(segment))
.color(palette[slot])
.width(2.0_f32),
);
}
}
});
let shown = response.transform.bounds();
let (new_lo, new_hi) = (shown.min()[0], shown.max()[0]);
let moved = (new_lo - lo).abs() > 0.5 || (new_hi - hi).abs() > 0.5;
(moved && response.response.hovered() && new_hi - new_lo >= 120.0).then_some((new_lo, new_hi))
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.last_poll.is_none_or(|t| t.elapsed() >= self.interval) {
self.poll();
}
let since_poll = self.last_poll.map_or(Duration::ZERO, |t| t.elapsed());
ctx.request_repaint_after(self.interval.saturating_sub(since_poll));
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0.0, |d| d.as_secs_f64());
egui::TopBottomPanel::top("status").show(ctx, |ui| {
ui.add_space(6.0);
self.status(ui, now);
ui.add_space(4.0);
self.toolbar(ui);
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(error) = &self.error {
ui.heading("Can't read the vitals log");
ui.label(format!("{}: {error}", self.tailer.dir().display()));
ui.label("Start the logger with: systemctl --user enable --now vitals-log.timer");
return;
}
if self.tailer.series.is_empty() {
ui.heading("No samples yet");
ui.label(format!("Waiting for data in {}", self.tailer.dir().display()));
return;
}
let (lo, hi) = self.bounds(now);
let mut moved = None;
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
for panel in PANELS {
egui::CollapsingHeader::new(RichText::new(panel.title).strong())
.default_open(panel.open)
.show(ui, |ui| {
moved = moved.or(self.plot(ui, panel, lo, hi));
});
}
});
if let Some((lo, hi)) = moved {
self.range = TimeRange::Custom(lo, hi);
ctx.request_repaint();
}
});
}
}
/// A label/value pair that wraps to the next row as a unit.
fn stat(ui: &mut egui::Ui, label: &str, value: String) {
let mut job = LayoutJob::default();
job.append(label, 0.0, TextFormat { color: ui.visuals().weak_text_color(), ..Default::default() });
job.append(&value, 6.0, TextFormat { color: ui.visuals().strong_text_color(), ..Default::default() });
ui.add(egui::Label::new(job).wrap_mode(egui::TextWrapMode::Extend));
}
/// The temperature column with the highest latest reading.
fn hottest(series: &Series) -> Option<(&'static str, f64)> {
PANELS
.iter()
.filter(|p| p.unit == "°C")
.flat_map(|p| p.series)
.filter_map(|spec| Some((spec.label, series.last(spec.column)?)))
.max_by(|a, b| a.1.total_cmp(&b.1))
}
fn human_age(secs: f64) -> String {
let secs = secs as u64;
match secs {
0..60 => format!("{secs} s"),
60..3600 => format!("{} min", secs / 60),
3600..86400 => format!("{} h {} min", secs / 3600, secs % 3600 / 60),
_ => format!("{} d {} h", secs / 86400, secs % 86400 / 3600),
}
}
fn local_time(unix: f64) -> chrono::DateTime<Local> {
Local
.timestamp_opt(unix as i64, 0)
.single()
.unwrap_or_else(Local::now)
}
fn utc_offset(unix: f64) -> i64 {
local_time(unix).offset().fix().local_minus_utc() as i64
}
const TIME_STEPS: [i64; 9] = [60, 300, 900, 3600, 3 * 3600, 6 * 3600, 86400, 7 * 86400, 28 * 86400];
/// Grid lines on round local-time boundaries instead of powers of ten of Unix seconds.
fn time_grid(input: GridInput) -> Vec<GridMark> {
let (lo, hi) = input.bounds;
// base_step_size is the closest spacing egui will draw; labels need about ten times that.
let wanted = input.base_step_size * 10.0;
let index = TIME_STEPS
.iter()
.position(|&s| s as f64 >= wanted)
.unwrap_or(TIME_STEPS.len() - 1);
let step = TIME_STEPS[index];
let offset = utc_offset(lo);
let first = ((lo as i64 + offset).div_euclid(step) + 1) * step;
(0..)
.map(|k| first + k * step)
.take_while(|&local| ((local - offset) as f64) <= hi)
.take(2000)
.map(|local| GridMark {
value: (local - offset) as f64,
step_size: step as f64,
})
.collect()
}
fn time_tick(mark: GridMark, _range: &RangeInclusive<f64>) -> String {
let time = local_time(mark.value);
let midnight = (mark.value as i64 + utc_offset(mark.value)) % 86400 == 0;
if midnight {
time.format("%b %-d").to_string()
} else {
time.format("%H:%M").to_string()
}
}
fn default_dir() -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(".local/state")))
.unwrap_or_default()
.join("vitals")
}
const USAGE: &str = "Usage: vitals [--dir PATH] [--interval SECONDS] [--range 1h|6h|24h|7d|all]
Plots the CSV files written by vitals-log.
--dir PATH log directory (default: $XDG_STATE_HOME/vitals)
--interval SECONDS how often to check for new samples (default: 10)
--range RANGE time range shown at startup (default: 6h)";
fn main() -> eframe::Result {
let mut dir = default_dir();
let mut interval = Duration::from_secs(10);
let mut range = TimeRange::Last(6.0 * 3600.0);
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match (arg.as_str(), args.next()) {
("--dir", Some(value)) => dir = PathBuf::from(value),
("--interval", Some(value)) if value.parse::<u64>().is_ok_and(|s| s > 0) => {
interval = Duration::from_secs(value.parse().unwrap());
}
("--range", Some(value)) if preset(&value).is_some() => range = preset(&value).unwrap(),
("-h" | "--help", _) => {
println!("{USAGE}");
return Ok(());
}
_ => {
eprintln!("{USAGE}");
std::process::exit(2);
}
}
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Vitals")
.with_app_id("vitals")
.with_inner_size([1000.0, 900.0]),
..Default::default()
};
eframe::run_native(
"Vitals",
options,
Box::new(move |cc| {
cc.egui_ctx.style_mut_of(Theme::Dark, |style| {
style.visuals.panel_fill = Color32::from_rgb(0x0d, 0x0d, 0x0d);
style.visuals.extreme_bg_color = Color32::from_rgb(0x1a, 0x1a, 0x19);
});
cc.egui_ctx.style_mut_of(Theme::Light, |style| {
style.visuals.panel_fill = Color32::from_rgb(0xf9, 0xf9, 0xf7);
style.visuals.extreme_bg_color = Color32::from_rgb(0xfc, 0xfc, 0xfb);
});
Ok(Box::new(App::new(dir, interval, range)))
}),
)
}
+195
View File
@@ -0,0 +1,195 @@
//! Incremental reader for the monthly vitals CSV files.
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use crate::data::{Series, parse_header};
struct FileState {
/// Bytes consumed so far; always just past a newline.
offset: u64,
header: Vec<String>,
}
/// Follows every `vitals-YYYY-MM.csv` in a directory, parsing only bytes
/// appended since the previous poll.
pub struct Tailer {
dir: PathBuf,
files: BTreeMap<PathBuf, FileState>,
pub series: Series,
}
impl Tailer {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
files: BTreeMap::new(),
series: Series::default(),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Read whatever is new. Returns the number of samples added.
pub fn poll(&mut self) -> io::Result<usize> {
let paths = log_files(&self.dir)?;
// A file that shrank or vanished was replaced; offsets into it mean
// nothing now, so start over rather than guess.
let replaced = self.files.iter().any(|(path, state)| {
std::fs::metadata(path).map_or(true, |m| m.len() < state.offset)
});
if replaced {
self.files.clear();
self.series = Series::default();
}
let before = self.series.len();
// Name order is chronological, which keeps the series sorted.
for path in paths {
self.read_appended(&path)?;
}
Ok(self.series.len() - before)
}
fn read_appended(&mut self, path: &Path) -> io::Result<()> {
let state = self.files.entry(path.to_path_buf()).or_insert(FileState {
offset: 0,
header: Vec::new(),
});
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(state.offset))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
// Leave a partially written last line for the next poll.
let Some(complete) = buf.iter().rposition(|&b| b == b'\n').map(|i| i + 1) else {
return Ok(());
};
for line in String::from_utf8_lossy(&buf[..complete]).lines() {
if line.is_empty() {
continue;
}
if state.header.is_empty() {
state.header = parse_header(line);
} else {
self.series.push_line(&state.header, line);
}
}
state.offset += complete as u64;
Ok(())
}
}
/// `vitals-YYYY-MM.csv` files, sorted. Set-aside `.until-*` files are ignored.
fn log_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|entry| Some(entry.ok()?.path()))
.filter(|path| {
path.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_prefix("vitals-")?.strip_suffix(".csv"))
.is_some_and(|month| {
month.len() == 7 && month.chars().all(|c| c.is_ascii_digit() || c == '-')
})
})
.collect();
paths.sort();
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::OpenOptions;
use std::io::Write;
fn append(path: &Path, text: &str) {
let mut f = OpenOptions::new().create(true).append(true).open(path).unwrap();
f.write_all(text.as_bytes()).unwrap();
}
#[test]
fn reads_only_appended_lines() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("vitals-2026-09.csv");
append(&log, "timestamp,a\n2026-09-19T23:21:00-0700,1\n");
let mut tailer = Tailer::new(dir.path());
assert_eq!(tailer.poll().unwrap(), 1);
assert_eq!(tailer.poll().unwrap(), 0);
append(&log, "2026-09-19T23:22:00-0700,2\n");
assert_eq!(tailer.poll().unwrap(), 1);
assert_eq!(tailer.series.col("a").unwrap(), &[1.0, 2.0]);
}
#[test]
fn waits_for_partial_lines_to_complete() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("vitals-2026-09.csv");
append(&log, "timestamp,a\n2026-09-19T23:21:00-0700,1\n2026-09-19T23:2");
let mut tailer = Tailer::new(dir.path());
assert_eq!(tailer.poll().unwrap(), 1);
append(&log, "2:00-0700,2\n");
assert_eq!(tailer.poll().unwrap(), 1);
assert_eq!(tailer.series.col("a").unwrap(), &[1.0, 2.0]);
assert_eq!(tailer.series.skipped, 0);
}
#[test]
fn picks_up_next_month_and_ignores_set_aside_files() {
let dir = tempfile::tempdir().unwrap();
append(
&dir.path().join("vitals-2026-09.csv"),
"timestamp,a\n2026-09-30T23:59:00-0700,1\n",
);
append(
&dir.path().join("vitals-2026-09.until-20260920T010203.csv"),
"timestamp,old\n2026-09-01T00:00:00-0700,9\n",
);
let mut tailer = Tailer::new(dir.path());
assert_eq!(tailer.poll().unwrap(), 1);
append(
&dir.path().join("vitals-2026-10.csv"),
"timestamp,a\n2026-10-01T00:00:00-0700,2\n",
);
assert_eq!(tailer.poll().unwrap(), 1);
assert_eq!(tailer.series.col("a").unwrap(), &[1.0, 2.0]);
assert!(tailer.series.col("old").is_none());
}
#[test]
fn reloads_when_a_file_is_replaced() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("vitals-2026-09.csv");
append(
&log,
"timestamp,a\n2026-09-19T23:21:00-0700,1\n2026-09-19T23:22:00-0700,2\n",
);
let mut tailer = Tailer::new(dir.path());
assert_eq!(tailer.poll().unwrap(), 2);
std::fs::write(&log, "timestamp,b\n2026-09-19T23:30:00-0700,7\n").unwrap();
tailer.poll().unwrap();
assert_eq!(tailer.series.len(), 1);
assert_eq!(tailer.series.col("b").unwrap(), &[7.0]);
assert!(tailer.series.col("a").is_none());
}
#[test]
fn missing_directory_is_an_error() {
let mut tailer = Tailer::new("/nonexistent/vitals");
assert!(tailer.poll().is_err());
}
}