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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4242
File diff suppressed because it is too large
Load Diff
+12
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user