Rewrite the logger in Rust; add install targets
vitals-log replaces the bash sampler and writes the same CSV layout. The crate is now a library plus two binaries. dist/ holds the systemd unit and desktop entry templates, installed by make install. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Per-user install: binaries, the systemd timer that runs the logger, and the
|
||||
# desktop entry for the viewer.
|
||||
|
||||
PREFIX ?= $(HOME)/.local
|
||||
BINDIR := $(PREFIX)/bin
|
||||
APPDIR := $(or $(XDG_DATA_HOME),$(HOME)/.local/share)/applications
|
||||
UNITDIR := $(or $(XDG_CONFIG_HOME),$(HOME)/.config)/systemd/user
|
||||
|
||||
.PHONY: build test install install-bin install-units install-desktop uninstall
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
test:
|
||||
cargo test
|
||||
|
||||
install: install-bin install-units install-desktop
|
||||
|
||||
install-bin: build
|
||||
install -Dm755 target/release/vitals $(BINDIR)/vitals
|
||||
install -Dm755 target/release/vitals-log $(BINDIR)/vitals-log
|
||||
|
||||
# dist/ files carry an @BINDIR@ placeholder so they point at wherever PREFIX put the binaries.
|
||||
install-units:
|
||||
install -d $(UNITDIR)
|
||||
sed 's|@BINDIR@|$(BINDIR)|' dist/vitals-log.service > $(UNITDIR)/vitals-log.service
|
||||
install -m644 dist/vitals-log.timer $(UNITDIR)/vitals-log.timer
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now vitals-log.timer
|
||||
|
||||
install-desktop:
|
||||
install -d $(APPDIR)
|
||||
sed 's|@BINDIR@|$(BINDIR)|' dist/vitals.desktop > $(APPDIR)/vitals.desktop
|
||||
-update-desktop-database $(APPDIR)
|
||||
|
||||
# Leaves the logs in ~/.local/state/vitals alone.
|
||||
uninstall:
|
||||
-systemctl --user disable --now vitals-log.timer
|
||||
rm -f $(UNITDIR)/vitals-log.service $(UNITDIR)/vitals-log.timer
|
||||
rm -f $(APPDIR)/vitals.desktop $(BINDIR)/vitals $(BINDIR)/vitals-log
|
||||
-systemctl --user daemon-reload
|
||||
-update-desktop-database $(APPDIR)
|
||||
@@ -1,17 +1,52 @@
|
||||
# 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.
|
||||
Logs system temperatures, power status and memory load once a minute, and plots
|
||||
the history.
|
||||
|
||||
- `vitals-log` takes one sample and appends it to `vitals-YYYY-MM.csv`. A
|
||||
systemd user timer runs it every minute.
|
||||
- `vitals` is the viewer: the latest reading plus time-series plots, built with
|
||||
egui and egui_plot.
|
||||
|
||||
## Install
|
||||
|
||||
make install
|
||||
|
||||
This builds both binaries into `~/.local/bin`, installs and starts
|
||||
`vitals-log.timer` (systemd user unit), and installs the `Vitals` desktop entry
|
||||
so the viewer shows up in the launcher. `PREFIX=/somewhere make install` changes
|
||||
where the binaries go; the unit and desktop entry are generated from the
|
||||
templates in `dist/` to point at them. The pieces can be installed separately
|
||||
with `make install-bin`, `install-units` and `install-desktop`.
|
||||
|
||||
`make uninstall` removes all of it and leaves the logs alone. For logging to
|
||||
continue while logged out, enable lingering once: `loginctl enable-linger`.
|
||||
|
||||
## Logger
|
||||
|
||||
vitals-log [--dir PATH] [--print]
|
||||
|
||||
Logs go to `$XDG_STATE_HOME/vitals` (`~/.local/state/vitals`), one file per
|
||||
month, about 5 MB each. `--print` writes the header and a sample to stdout
|
||||
without touching the log, which is the quick way to check what a machine
|
||||
exposes.
|
||||
|
||||
The columns are fixed and defined in `src/sample.rs`: temperatures come from
|
||||
hwmon chips looked up by name (the `hwmonN` numbering changes between boots),
|
||||
power from `/sys/class/power_supply` and `powerprofilesctl`, memory from
|
||||
`/proc/meminfo` and `/proc/pressure/memory`. The sensor table is specific to
|
||||
this laptop (Framework, Intel); on other hardware, edit `TEMPERATURES`. A source
|
||||
that can't be read leaves an empty cell. If the column set changes mid-month,
|
||||
the old file is renamed `vitals-YYYY-MM.until-<timestamp>.csv` so layouts never
|
||||
mix in one file.
|
||||
|
||||
## Viewer
|
||||
|
||||
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.
|
||||
Every `vitals-YYYY-MM.csv` 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
|
||||
@@ -19,6 +54,11 @@ 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.
|
||||
|
||||
## Source
|
||||
|
||||
- `src/sample.rs` — reading sensors, appending to the log
|
||||
- `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
|
||||
- `src/bin/vitals-log.rs` — the logger's command line
|
||||
- `src/bin/vitals.rs` — the viewer; the `PANELS` table defines the plots
|
||||
- `dist/` — systemd unit and desktop entry templates
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=Log system temperatures, power status and memory load
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=@BINDIR@/vitals-log
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Log system vitals every minute
|
||||
|
||||
[Timer]
|
||||
OnCalendar=minutely
|
||||
AccuracySec=1s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Vitals
|
||||
GenericName=System Vitals History
|
||||
Comment=Temperature, power and memory history
|
||||
Exec=@BINDIR@/vitals
|
||||
Icon=utilities-system-monitor
|
||||
Terminal=false
|
||||
Categories=System;Monitor;
|
||||
StartupWMClass=vitals
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Append one sample of system vitals to the month's CSV log. Meant to be run
|
||||
//! every minute by `vitals-log.timer`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use vitals::sample::{Roots, append, power_profile, sample};
|
||||
|
||||
const USAGE: &str = "Usage: vitals-log [--dir PATH] [--print]
|
||||
|
||||
Appends one line of temperatures, power status and memory load to
|
||||
vitals-YYYY-MM.csv.
|
||||
--dir PATH log directory (default: $XDG_STATE_HOME/vitals)
|
||||
--print write the header and sample to stdout instead of the log";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut dir = vitals::default_dir();
|
||||
let mut print = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--print" => print = true,
|
||||
"--dir" => match args.next() {
|
||||
Some(value) => dir = PathBuf::from(value),
|
||||
None => {
|
||||
eprintln!("{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
},
|
||||
"-h" | "--help" => {
|
||||
println!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let now = chrono::Local::now();
|
||||
let line = sample(&Roots::default(), now, power_profile());
|
||||
if print {
|
||||
println!("{}\n{line}", vitals::sample::header());
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match append(&dir, now, &line) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("vitals-log: {}: {error}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
//! 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};
|
||||
@@ -12,8 +9,9 @@ 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;
|
||||
use vitals::data::Series;
|
||||
use vitals::default_dir;
|
||||
use vitals::tail::Tailer;
|
||||
|
||||
/// Points per series per plot before min/max downsampling kicks in.
|
||||
const MAX_BUCKETS: usize = 1000;
|
||||
@@ -511,14 +509,6 @@ fn time_tick(mark: GridMark, _range: &RangeInclusive<f64>) -> 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.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
//! Shared by the `vitals-log` sampler and the `vitals` viewer.
|
||||
|
||||
pub mod data;
|
||||
pub mod sample;
|
||||
pub mod tail;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where the logs live: `$XDG_STATE_HOME/vitals`, normally `~/.local/state/vitals`.
|
||||
pub 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")
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
//! Reads one sample of system vitals from sysfs and procfs and appends it to
|
||||
//! the month's CSV file.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// Filesystem roots, so tests can point the sampler at a fake tree.
|
||||
pub struct Roots {
|
||||
pub sys: PathBuf,
|
||||
pub proc: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for Roots {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sys: "/sys".into(),
|
||||
proc: "/proc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a temperature column comes from: an hwmon chip name plus either a
|
||||
/// sensor label or, for chips that don't label their sensors, a sensor index.
|
||||
enum Sensor {
|
||||
Label(&'static str, &'static str),
|
||||
Index(&'static str, u32),
|
||||
/// Highest of the chip's sensors whose label starts with the prefix.
|
||||
MaxLabelled(&'static str, &'static str),
|
||||
}
|
||||
|
||||
const TEMPERATURES: [(&str, Sensor); 18] = [
|
||||
("cpu_pkg_c", Sensor::Label("coretemp", "Package id 0")),
|
||||
("cpu_core_max_c", Sensor::MaxLabelled("coretemp", "Core")),
|
||||
("ec_cpu_c", Sensor::Label("cros_ec", "cpu_f75303@4d")),
|
||||
("ec_top_skin_c", Sensor::Label("cros_ec", "top_skin_f75303@4d")),
|
||||
("ec_local_c", Sensor::Label("cros_ec", "local_f75303@4d")),
|
||||
("ec_battery_c", Sensor::Label("cros_ec", "battery_temp@b")),
|
||||
("ec_peci_c", Sensor::Label("cros_ec", "peci-temp")),
|
||||
("ec_charger_c", Sensor::Label("cros_ec", "charger_temp@9")),
|
||||
("acpitz1_c", Sensor::Index("acpitz", 1)),
|
||||
("acpitz2_c", Sensor::Index("acpitz", 2)),
|
||||
("acpitz3_c", Sensor::Index("acpitz", 3)),
|
||||
("acpitz4_c", Sensor::Index("acpitz", 4)),
|
||||
("acpitz5_c", Sensor::Index("acpitz", 5)),
|
||||
("nvme_composite_c", Sensor::Label("nvme", "Composite")),
|
||||
("nvme_sensor1_c", Sensor::Label("nvme", "Sensor 1")),
|
||||
("nvme_sensor2_c", Sensor::Label("nvme", "Sensor 2")),
|
||||
("ram_c", Sensor::Index("spd5118", 1)),
|
||||
("wifi_c", Sensor::Index("iwlwifi_1", 1)),
|
||||
];
|
||||
|
||||
const OTHER_COLUMNS: [&str; 14] = [
|
||||
"ac_online",
|
||||
"bat_status",
|
||||
"bat_pct",
|
||||
"bat_watts",
|
||||
"power_profile",
|
||||
"mem_total_mib",
|
||||
"mem_used_mib",
|
||||
"mem_avail_mib",
|
||||
"swap_used_mib",
|
||||
"psi_some_avg10",
|
||||
"psi_some_avg60",
|
||||
"psi_full_avg10",
|
||||
"psi_full_avg60",
|
||||
"load1",
|
||||
];
|
||||
|
||||
pub fn header() -> String {
|
||||
let mut names = vec!["timestamp"];
|
||||
names.extend(TEMPERATURES.iter().map(|(name, _)| *name));
|
||||
names.extend(OTHER_COLUMNS);
|
||||
names.join(",")
|
||||
}
|
||||
|
||||
/// One CSV line matching [`header`]. Anything unreadable is an empty cell.
|
||||
pub fn sample(roots: &Roots, now: DateTime<Local>, power_profile: Option<String>) -> String {
|
||||
let mut cells = vec![now.format("%Y-%m-%dT%H:%M:%S%z").to_string()];
|
||||
|
||||
let chips = hwmon_chips(&roots.sys);
|
||||
for (_, sensor) in &TEMPERATURES {
|
||||
cells.push(temperature(&chips, sensor).map(degrees).unwrap_or_default());
|
||||
}
|
||||
|
||||
let supply = roots.sys.join("class/power_supply");
|
||||
let battery = supply.join("BAT1");
|
||||
let status = read(&battery.join("status")).unwrap_or_default();
|
||||
cells.push(read(&supply.join("ACAD/online")).unwrap_or_default());
|
||||
cells.push(status.clone());
|
||||
cells.push(read(&battery.join("capacity")).unwrap_or_default());
|
||||
cells.push(battery_watts(&battery, &status).unwrap_or_default());
|
||||
cells.push(power_profile.unwrap_or_default());
|
||||
|
||||
let mem = meminfo(&roots.proc.join("meminfo"));
|
||||
let mib = |kib: Option<u64>| kib.map(|k| (k / 1024).to_string()).unwrap_or_default();
|
||||
cells.push(mib(mem.total));
|
||||
cells.push(mib(mem.total.zip(mem.available).map(|(t, a)| t.saturating_sub(a))));
|
||||
cells.push(mib(mem.available));
|
||||
cells.push(mib(mem.swap_total.zip(mem.swap_free).map(|(t, f)| t.saturating_sub(f))));
|
||||
|
||||
let psi = fs::read_to_string(roots.proc.join("pressure/memory")).unwrap_or_default();
|
||||
for (kind, window) in [("some", "avg10"), ("some", "avg60"), ("full", "avg10"), ("full", "avg60")] {
|
||||
cells.push(pressure(&psi, kind, window).unwrap_or_default());
|
||||
}
|
||||
|
||||
let loadavg = fs::read_to_string(roots.proc.join("loadavg")).unwrap_or_default();
|
||||
cells.push(loadavg.split_whitespace().next().unwrap_or_default().to_string());
|
||||
|
||||
cells.join(",")
|
||||
}
|
||||
|
||||
/// Append a line to `vitals-YYYY-MM.csv` in `dir`, writing the header first
|
||||
/// when the file is new.
|
||||
pub fn append(dir: &Path, now: DateTime<Local>, line: &str) -> io::Result<()> {
|
||||
fs::create_dir_all(dir)?;
|
||||
let path = dir.join(format!("vitals-{}.csv", now.format("%Y-%m")));
|
||||
let header = header();
|
||||
|
||||
// If the columns changed mid-month, set the old file aside rather than mix layouts.
|
||||
if let Ok(file) = fs::File::open(&path) {
|
||||
let mut first = String::new();
|
||||
io::BufReader::new(file).read_line(&mut first)?;
|
||||
if !first.is_empty() && first.trim_end() != header {
|
||||
let aside = dir.join(format!(
|
||||
"vitals-{}.until-{}.csv",
|
||||
now.format("%Y-%m"),
|
||||
now.format("%Y%m%dT%H%M%S")
|
||||
));
|
||||
fs::rename(&path, aside)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
// One write per line keeps concurrent readers from seeing a torn row.
|
||||
if file.metadata()?.len() == 0 {
|
||||
file.write_all(format!("{header}\n{line}\n").as_bytes())
|
||||
} else {
|
||||
file.write_all(format!("{line}\n").as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// The active power profile, from power-profiles-daemon.
|
||||
pub fn power_profile() -> Option<String> {
|
||||
let output = std::process::Command::new("powerprofilesctl").arg("get").output().ok()?;
|
||||
let profile = String::from_utf8(output.stdout).ok()?.trim().to_string();
|
||||
(output.status.success() && !profile.is_empty()).then_some(profile)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Option<String> {
|
||||
Some(fs::read_to_string(path).ok()?.trim().to_string())
|
||||
}
|
||||
|
||||
/// (chip name, directory) for every hwmon device. The hwmonN numbering changes
|
||||
/// across boots, so chips are always looked up by name.
|
||||
fn hwmon_chips(sys: &Path) -> Vec<(String, PathBuf)> {
|
||||
let Ok(entries) = fs::read_dir(sys.join("class/hwmon")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| {
|
||||
let dir = entry.ok()?.path();
|
||||
Some((read(&dir.join("name"))?, dir))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Millidegrees for one sensor.
|
||||
fn temperature(chips: &[(String, PathBuf)], sensor: &Sensor) -> Option<i64> {
|
||||
let chip = |name: &str| chips.iter().find(|(n, _)| n == name).map(|(_, dir)| dir);
|
||||
let input = |path: PathBuf| read(&path)?.parse::<i64>().ok();
|
||||
match *sensor {
|
||||
Sensor::Index(name, index) => input(chip(name)?.join(format!("temp{index}_input"))),
|
||||
Sensor::Label(name, label) => labelled(chip(name)?)
|
||||
.into_iter()
|
||||
.find(|(l, _)| l == label)
|
||||
.and_then(|(_, path)| input(path)),
|
||||
Sensor::MaxLabelled(name, prefix) => labelled(chip(name)?)
|
||||
.into_iter()
|
||||
.filter(|(l, _)| l.starts_with(prefix))
|
||||
.filter_map(|(_, path)| input(path))
|
||||
.max(),
|
||||
}
|
||||
}
|
||||
|
||||
/// (label, input path) for each labelled temperature sensor of a chip.
|
||||
fn labelled(chip: &Path) -> Vec<(String, PathBuf)> {
|
||||
let Ok(entries) = fs::read_dir(chip) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
let stem = path.file_name()?.to_str()?.strip_suffix("_label")?.to_string();
|
||||
if !stem.starts_with("temp") {
|
||||
return None;
|
||||
}
|
||||
Some((read(&path)?, chip.join(format!("{stem}_input"))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn degrees(millidegrees: i64) -> String {
|
||||
format!("{:.1}", millidegrees as f64 / 1000.0)
|
||||
}
|
||||
|
||||
/// Battery power in watts, negative while discharging.
|
||||
fn battery_watts(battery: &Path, status: &str) -> Option<String> {
|
||||
let microamps: f64 = read(&battery.join("current_now"))?.parse().ok()?;
|
||||
let microvolts: f64 = read(&battery.join("voltage_now"))?.parse().ok()?;
|
||||
let watts = microamps.abs() * microvolts / 1e12;
|
||||
let sign = if status == "Discharging" { -1.0 } else { 1.0 };
|
||||
Some(format!("{:.2}", sign * watts + 0.0))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemInfo {
|
||||
total: Option<u64>,
|
||||
available: Option<u64>,
|
||||
swap_total: Option<u64>,
|
||||
swap_free: Option<u64>,
|
||||
}
|
||||
|
||||
/// Values in KiB.
|
||||
fn meminfo(path: &Path) -> MemInfo {
|
||||
let mut info = MemInfo::default();
|
||||
for line in fs::read_to_string(path).unwrap_or_default().lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
let (Some(key), Some(value)) = (parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
let value = value.parse().ok();
|
||||
match key {
|
||||
"MemTotal:" => info.total = value,
|
||||
"MemAvailable:" => info.available = value,
|
||||
"SwapTotal:" => info.swap_total = value,
|
||||
"SwapFree:" => info.swap_free = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
/// One field of /proc/pressure/memory, e.g. ("some", "avg10").
|
||||
fn pressure(psi: &str, kind: &str, window: &str) -> Option<String> {
|
||||
psi.lines()
|
||||
.find(|line| line.starts_with(kind))?
|
||||
.split_whitespace()
|
||||
.find_map(|field| field.strip_prefix(window)?.strip_prefix('='))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn write(root: &Path, path: &str, content: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
/// A fake /sys and /proc. hwmon numbers are deliberately out of order.
|
||||
fn fake_system(root: &Path, battery_status: &str) -> Roots {
|
||||
let files = [
|
||||
("sys/class/hwmon/hwmon7/name", "acpitz"),
|
||||
("sys/class/hwmon/hwmon7/temp1_input", "84800"),
|
||||
("sys/class/hwmon/hwmon7/temp2_input", "53900"),
|
||||
("sys/class/hwmon/hwmon2/name", "coretemp"),
|
||||
("sys/class/hwmon/hwmon2/temp1_label", "Package id 0"),
|
||||
("sys/class/hwmon/hwmon2/temp1_input", "76000"),
|
||||
("sys/class/hwmon/hwmon2/temp2_label", "Core 0"),
|
||||
("sys/class/hwmon/hwmon2/temp2_input", "73000"),
|
||||
("sys/class/hwmon/hwmon2/temp6_label", "Core 4"),
|
||||
("sys/class/hwmon/hwmon2/temp6_input", "79500"),
|
||||
("sys/class/hwmon/hwmon0/name", "cros_ec"),
|
||||
("sys/class/hwmon/hwmon0/temp5_label", "peci-temp"),
|
||||
("sys/class/hwmon/hwmon0/temp5_input", "84850"),
|
||||
// A wifi chip whose sensor can't be read (radio off) has no input file.
|
||||
("sys/class/hwmon/hwmon9/name", "iwlwifi_1"),
|
||||
("sys/class/power_supply/ACAD/online", "0"),
|
||||
("sys/class/power_supply/BAT1/status", battery_status),
|
||||
("sys/class/power_supply/BAT1/capacity", "87"),
|
||||
("sys/class/power_supply/BAT1/current_now", "1500000"),
|
||||
("sys/class/power_supply/BAT1/voltage_now", "12000000"),
|
||||
(
|
||||
"proc/meminfo",
|
||||
"MemTotal: 32576512 kB\nMemFree: 1000000 kB\nMemAvailable: 22606848 kB\nSwapTotal: 4194304 kB\nSwapFree: 3145728 kB\n",
|
||||
),
|
||||
(
|
||||
"proc/pressure/memory",
|
||||
"some avg10=1.25 avg60=0.40 avg300=0.10 total=184\nfull avg10=0.50 avg60=0.05 avg300=0.00 total=97\n",
|
||||
),
|
||||
("proc/loadavg", "0.91 0.80 0.70 1/1234 5678\n"),
|
||||
];
|
||||
for (path, content) in files {
|
||||
write(root, path, &format!("{content}\n"));
|
||||
}
|
||||
Roots {
|
||||
sys: root.join("sys"),
|
||||
proc: root.join("proc"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cells(line: &str) -> std::collections::HashMap<String, String> {
|
||||
header()
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.zip(line.split(',').map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn now() -> DateTime<Local> {
|
||||
Local.with_ymd_and_hms(2026, 9, 19, 23, 21, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_matches_the_original_logger() {
|
||||
assert_eq!(
|
||||
header(),
|
||||
"timestamp,cpu_pkg_c,cpu_core_max_c,ec_cpu_c,ec_top_skin_c,ec_local_c,ec_battery_c,ec_peci_c,ec_charger_c,acpitz1_c,acpitz2_c,acpitz3_c,acpitz4_c,acpitz5_c,nvme_composite_c,nvme_sensor1_c,nvme_sensor2_c,ram_c,wifi_c,ac_online,bat_status,bat_pct,bat_watts,power_profile,mem_total_mib,mem_used_mib,mem_avail_mib,swap_used_mib,psi_some_avg10,psi_some_avg60,psi_full_avg10,psi_full_avg60,load1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_every_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let roots = fake_system(dir.path(), "Discharging");
|
||||
let line = sample(&roots, now(), Some("balanced".into()));
|
||||
let c = cells(&line);
|
||||
|
||||
assert_eq!(line.split(',').count(), header().split(',').count());
|
||||
assert!(c["timestamp"].starts_with("2026-09-19T23:21:00"));
|
||||
assert_eq!(c["cpu_pkg_c"], "76.0");
|
||||
assert_eq!(c["cpu_core_max_c"], "79.5");
|
||||
assert_eq!(c["ec_peci_c"], "84.8");
|
||||
assert_eq!(c["acpitz2_c"], "53.9");
|
||||
assert_eq!(c["ac_online"], "0");
|
||||
assert_eq!(c["bat_status"], "Discharging");
|
||||
assert_eq!(c["bat_pct"], "87");
|
||||
assert_eq!(c["bat_watts"], "-18.00");
|
||||
assert_eq!(c["power_profile"], "balanced");
|
||||
assert_eq!(c["mem_total_mib"], "31813");
|
||||
assert_eq!(c["mem_used_mib"], "9736");
|
||||
assert_eq!(c["mem_avail_mib"], "22077");
|
||||
assert_eq!(c["swap_used_mib"], "1024");
|
||||
assert_eq!(c["psi_some_avg10"], "1.25");
|
||||
assert_eq!(c["psi_full_avg60"], "0.05");
|
||||
assert_eq!(c["load1"], "0.91");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_sources_leave_empty_cells() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let roots = fake_system(dir.path(), "Charging");
|
||||
let c = cells(&sample(&roots, now(), None));
|
||||
|
||||
assert_eq!(c["wifi_c"], "");
|
||||
assert_eq!(c["nvme_composite_c"], "");
|
||||
assert_eq!(c["acpitz5_c"], "");
|
||||
assert_eq!(c["power_profile"], "");
|
||||
assert_eq!(c["bat_watts"], "18.00");
|
||||
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
let nothing = Roots {
|
||||
sys: empty.path().join("sys"),
|
||||
proc: empty.path().join("proc"),
|
||||
};
|
||||
let line = sample(¬hing, now(), None);
|
||||
assert_eq!(line.split(',').count(), header().split(',').count());
|
||||
assert!(line.split(',').skip(1).all(str::is_empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_writes_the_header_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
append(dir.path(), now(), "row1").unwrap();
|
||||
append(dir.path(), now(), "row2").unwrap();
|
||||
|
||||
let text = fs::read_to_string(dir.path().join("vitals-2026-09.csv")).unwrap();
|
||||
assert_eq!(text, format!("{}\nrow1\nrow2\n", header()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_sets_aside_a_file_with_a_different_layout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("vitals-2026-09.csv");
|
||||
fs::write(&log, "timestamp,old_column\n2026-09-01T00:00:00-0700,1\n").unwrap();
|
||||
|
||||
append(dir.path(), now(), "row1").unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&log).unwrap(), format!("{}\nrow1\n", header()));
|
||||
let aside = dir.path().join("vitals-2026-09.until-20260919T232100.csv");
|
||||
assert!(fs::read_to_string(aside).unwrap().contains("old_column"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user