Files
amane-config/src/stats.rs
T

565 lines
22 KiB
Rust

use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use amane::{Blend, Canvas, Center, Color, Column, Full, Path, Rectangle, Row, Service, Shape, SpaceBetween, Stack, Start, Text, Weight, Widget};
use crate::config::appearance::{overlays, text};
use crate::config::pills::{layout, monitor as settings};
use crate::glyph::{Glyph, glyph_shapes};
use crate::island::faded;
use crate::parse;
use crate::system::Gpu;
use crate::{pods, theme};
/*
* the system monitor card: cpu, the integrated gpu, the external gpu, memory, swap,
* network, disk reads and writes as graphs over the last readings; the external gpu's
* memory and the programs on it; the hottest temperature sensors in their colours.
* everything comes from /proc and /sys on this Service's thread (the external gpu from
* system::Gpu, which reads only while this card is open); no process list here, its
* button opens btm's in a terminal on a special workspace instead
*/
#[derive(Default)]
pub struct Stats {
// 0 to 1
cpu: Vec<f32>,
// the integrated gpu's clock as a share of its highest, and in words
gpu: Vec<f32>,
gpu_words: String,
// the external gpu's busy share
egpu: Vec<f32>,
ram: Vec<f32>,
ram_words: String,
swap: Vec<f32>,
swap_words: String,
// bytes a second
down: Vec<f32>,
up: Vec<f32>,
read: Vec<f32>,
write: Vec<f32>,
// (sensor, its icon, °C), hottest first
temps: Vec<(String, Glyph, f32)>,
}
struct Sample {
at: Instant,
cpu: (u64, u64),
net: (u64, u64),
disk: (u64, u64),
}
impl Service for Stats {
fn new() -> Self {
Self::default()
}
fn listen() {
let mut before = sample();
loop {
thread::sleep(Duration::from_millis(settings::interval_ms()));
let now = sample();
let seconds = now.at.duration_since(before.at).as_secs_f32().max(0.001);
let rate = |new: u64, old: u64| new.saturating_sub(old) as f32 / seconds;
let busy = now.cpu.0.saturating_sub(before.cpu.0) as f32 / now.cpu.1.saturating_sub(before.cpu.1).max(1) as f32;
let memory = parse::memory(&read("/proc/meminfo")).unwrap_or_default();
let used = memory.total_kb.saturating_sub(memory.available_kb);
let swapped = memory.swap_total_kb.saturating_sub(memory.swap_free_kb);
let (clock, clock_words) = gpu_clock();
let egpu = Gpu::read().busy as f32 / 100.0;
let temps = temperatures();
{
let mut stats = Self::write();
let keep = settings::history();
push(&mut stats.cpu, busy, keep);
push(&mut stats.gpu, clock, keep);
push(&mut stats.egpu, egpu, keep);
push(&mut stats.ram, used as f32 / memory.total_kb.max(1) as f32, keep);
push(&mut stats.swap, swapped as f32 / memory.swap_total_kb.max(1) as f32, keep);
push(&mut stats.down, rate(now.net.0, before.net.0), keep);
push(&mut stats.up, rate(now.net.1, before.net.1), keep);
push(&mut stats.read, rate(now.disk.0, before.disk.0) * 512.0, keep);
push(&mut stats.write, rate(now.disk.1, before.disk.1) * 512.0, keep);
stats.gpu_words = clock_words;
stats.ram_words = format!("{} of {}", gigabytes(used), gigabytes(memory.total_kb));
stats.swap_words = if memory.swap_total_kb == 0 { String::from("no swap") } else { format!("{} of {}", gigabytes(swapped), gigabytes(memory.swap_total_kb)) };
stats.temps = temps;
}
before = now;
}
}
}
fn gigabytes(kb: u64) -> String {
format!("{:.1} GB", kb as f32 / 1048576.0)
}
fn push(history: &mut Vec<f32>, value: f32, keep: usize) {
history.push(value);
let extra = history.len().saturating_sub(keep);
history.drain(..extra);
}
fn read(path: impl Into<PathBuf>) -> String {
fs::read_to_string(path.into()).unwrap_or_default()
}
fn sample() -> Sample {
Sample { at: Instant::now(), cpu: parse::cpu_jiffies(&read("/proc/stat")).unwrap_or((0, 0)), net: parse::network_bytes(&read("/proc/net/dev")), disk: disk_sectors() }
}
// sectors read and written by the whole physical disks: ones with a device behind them, not loop, ram or zram
fn disk_sectors() -> (u64, u64) {
let mut sum = (0, 0);
for entry in fs::read_dir("/sys/block").into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if ["loop", "ram", "zram"].iter().any(|virtual_disk| name.starts_with(virtual_disk)) || !entry.path().join("device").exists() {
continue;
}
if let Some((reads, writes)) = parse::block_sectors(&read(entry.path().join("stat"))) {
sum.0 += reads;
sum.1 += writes;
}
}
sum
}
// the first gpu that tells its clock (xe or i915): its share of the highest clock, and the clock in words
fn gpu_clock() -> (f32, String) {
for entry in fs::read_dir("/sys/class/drm").into_iter().flatten().flatten() {
let card = entry.path();
for (now, top) in [("device/tile0/gt0/freq0/act_freq", "device/tile0/gt0/freq0/max_freq"), ("gt_act_freq_mhz", "gt_max_freq_mhz")] {
let mhz = |file: &str| read(card.join(file)).trim().parse::<f32>().ok();
if let (Some(now), Some(top)) = (mhz(now), mhz(top)) {
return ((now / top.max(1.0)).clamp(0.0, 1.0), format!("{now:.0} of {top:.0} MHz"));
}
}
}
(0.0, String::from("no clock to read"))
}
// what the temperatures are shown in, always: a sensor keeps its place whatever its reading
const ORDER: [&str; 8] = ["CPU", "iGPU", "eGPU", "NVMe", "Memory", "Board", "ThinkPad", "Wi-Fi"];
// every hwmon device's hottest sensor, named for what it is, in ORDER (then by name, then by device)
fn temperatures() -> Vec<(String, Glyph, f32)> {
let mut found: Vec<(String, Glyph, f32)> = Vec::new();
// the devices in their numbers' order (hwmon2 before hwmon10), so two of a kind keep theirs too
let mut devices: Vec<PathBuf> = fs::read_dir("/sys/class/hwmon").into_iter().flatten().flatten().map(|entry| entry.path()).collect();
devices.sort_by_key(|path| path.file_name().and_then(|name| name.to_str()).and_then(|name| name.trim_start_matches("hwmon").parse::<u32>().ok()).unwrap_or(u32::MAX));
for device in devices {
let kind = read(device.join("name")).trim().to_string();
let hottest = fs::read_dir(&device)
.into_iter()
.flatten()
.flatten()
.filter(|file| file.file_name().to_string_lossy().starts_with("temp") && file.file_name().to_string_lossy().ends_with("_input"))
.filter_map(|file| read(file.path()).trim().parse::<f32>().ok())
.map(|milli| milli / 1000.0)
.filter(|celsius| *celsius > 0.0 && *celsius < 150.0)
.fold(None, |hottest: Option<f32>, celsius| Some(hottest.map_or(celsius, |hot| hot.max(celsius))));
let Some(celsius) = hottest else { continue };
let (name, glyph) = match kind.as_str() {
"coretemp" | "k10temp" | "zenpower" => ("CPU", Glyph::Chip),
"nvme" => ("NVMe", Glyph::Drive),
"amdgpu" | "nouveau" => ("eGPU", Glyph::Gpu),
"xe" | "i915" => ("iGPU", Glyph::Gpu),
"acpitz" => ("Board", Glyph::Thermometer),
"thinkpad" => ("ThinkPad", Glyph::Laptop),
"spd5118" | "jc42" => ("Memory", Glyph::Memory),
other if other.starts_with("iwlwifi") => ("Wi-Fi", Glyph::Thermometer),
other => (other, Glyph::Thermometer),
};
found.push((name.to_string(), glyph, celsius));
}
// a stable sort: same kinds keep the devices' order
found.sort_by_key(|(name, ..)| (ORDER.iter().position(|kind| kind == name).unwrap_or(ORDER.len()), name.clone()));
found.truncate(settings::temperatures());
// a kind shown more than once is numbered, in the order shown
let names: Vec<String> = found.iter().map(|(name, ..)| name.clone()).collect();
for (index, (name, ..)) in found.iter_mut().enumerate() {
if names.iter().filter(|other| *other == name).count() > 1 {
let number = names[..index].iter().filter(|other| *other == name).count() + 1;
*name = format!("{name} {number}");
}
}
found
}
// a temperature's colour: cool below `warm`, then warm turning hot up to `hot`
fn temperature_color(celsius: f32) -> Color {
let (warm, hot) = (settings::warm(), settings::hot());
if celsius < warm {
settings::cool_color()
} else if celsius >= hot {
settings::hot_color()
} else {
Color::blend(settings::warm_color(), settings::hot_color(), (celsius - warm) / (hot - warm).max(0.1))
}
}
/*
* opens btm's process list in a terminal on a special workspace, or shows and hides that
* workspace when it runs already. the window is told apart by its class
*/
pub fn processes() {
pods::close();
let (terminal, command, space) = (settings::terminal(), settings::processes(), settings::workspace());
thread::spawn(move || {
let class = format!("amane.{space}");
let running = Command::new("pgrep").args(["-f", "--", &format!("--class={class}")]).output().is_ok_and(|done| done.status.success());
if !running {
let line = format!("{terminal} --class={class} -e {command}");
let lua = format!("hl.exec_cmd({}, {{ workspace = {} }})", parse::quoted(&line), parse::quoted(&format!("special:{space} silent")));
let _ = Command::new("hyprctl").args(["eval", &lua]).output();
}
let _ = Command::new("hyprctl").args(["dispatch", &format!("hl.dsp.workspace.toggle_special({})", parse::quoted(&space))]).output();
});
}
fn label_height() -> f32 {
text::small() + 8.0
}
fn temp_row_height() -> f32 {
text::small() + 16.0
}
fn temp_rows() -> usize {
settings::temperatures().div_ceil(2)
}
fn program_row_height() -> f32 {
text::small() + 8.0
}
// the external gpu's rows under its graph: its memory, then the programs on it (at least one line)
fn egpu_rows(gpu: &Gpu) -> usize {
1 + gpu.programs.len().clamp(1, settings::programs())
}
fn graph_count(gpu: &Gpu) -> usize {
if gpu.name.is_empty() { 6 } else { 7 }
}
/*
* the card's height, from settings and from whether there is an external gpu and how
* many programs use it, so the window's size doesn't follow the readings
*/
pub fn height() -> f32 {
let gpu = Gpu::read();
let count = graph_count(&gpu) as f32;
let graphs = count * (label_height() + settings::graph_height()) + (count - 1.0) * 10.0;
// header, graphs, the external gpu's rows, rule, the temperatures' label and rows, with the column's gaps
let (egpu, parts) = if gpu.name.is_empty() { (0.0, 5.0) } else { (egpu_rows(&gpu) as f32 * program_row_height(), 6.0) };
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + (parts - 1.0) * 10.0
}
// the card's height at its tallest: an external gpu with its whole list of programs
pub fn tallest() -> f32 {
let graphs = 7.0 * (label_height() + settings::graph_height()) + 6.0 * 10.0;
let egpu = (1 + settings::programs()) as f32 * program_row_height();
2.0 * layout::pad() + pods::header_height() + graphs + egpu + 1.0 + label_height() + temp_rows() as f32 * temp_row_height() + 5.0 * 10.0
}
// bytes a second in words
fn rate(bytes: f32) -> String {
match bytes {
b if b >= 1e9 => format!("{:.1} GB/s", b / 1e9),
b if b >= 1e6 => format!("{:.1} MB/s", b / 1e6),
b if b >= 1e3 => format!("{:.0} kB/s", b / 1e3),
b => format!("{b:.0} B/s"),
}
}
// video memory in KiB, the way a person reads it
fn size(kib: u64) -> String {
match kib {
0..1024 => String::from("<1 MB"),
1024..1_048_576 => format!("{} MB", kib / 1024),
_ => format!("{:.1} GB", kib as f64 / 1_048_576.0),
}
}
// one graph's readings, the value at its top, its colour
type Series<'a> = (&'a [f32], f32, Color);
// a graph's line and the faint area under it, scaled so `top` reaches the top
fn graph(history: &[f32], top: f32, origin: (f32, f32), size: (f32, f32), color: Color) -> Vec<Box<dyn Shape>> {
let (x0, y0) = origin;
let (width, height) = size;
let keep = settings::history().max(2) as f32;
if history.len() < 2 {
return Vec::new();
}
// newest on the right; older readings fill in leftwards
let point = |index: usize, value: f32| {
let age = (history.len() - 1 - index) as f32;
(x0 + width - age / (keep - 1.0) * width, y0 + height - (value / top.max(f32::EPSILON)).clamp(0.0, 1.0) * height)
};
let mut line = Path::new();
let mut area = Path::new();
for (index, value) in history.iter().enumerate() {
let (x, y) = point(index, *value);
if index == 0 {
line = line.move_to(x, y);
area = area.move_to(x, y0 + height).line_to(x, y);
} else {
line = line.line_to(x, y);
area = area.line_to(x, y);
}
}
let (last_x, _) = point(history.len() - 1, 0.0);
vec![Box::new(area.line_to(last_x, y0 + height).close().fill(faded(color, 40))), Box::new(line.stroke(1.5, color))]
}
// a label on the left and its value on the right, one line
fn line_of(name: impl Into<String>, value: impl Into<String>, width: f32, height: f32, name_color: Color) -> Rectangle {
let theme = theme::now();
Rectangle::new().width(width).height(height).align_child(Start, Center).child(
Row::new(vec![
Box::new(Text::new(name).size(text::small()).color(name_color).elide()) as Box<dyn Widget>,
Box::new(Text::new(value).size(text::small()).color(theme.dim)),
])
.width(width)
.justify(SpaceBetween)
.align(Center),
)
}
// the external gpu's memory as a bar, and the programs on it, the most memory first
fn egpu_detail(gpu: &Gpu, width: f32) -> Column {
let theme = theme::now();
let row_h = program_row_height();
let share = if gpu.vram_total > 0 { gpu.vram_used as f32 / gpu.vram_total as f32 } else { 0.0 };
let words = format!("{} of {}", size(gpu.vram_used / 1024), size(gpu.vram_total / 1024));
let bar_w = width * 0.32;
let memory = Row::new(vec![
Box::new(Text::new("Video memory").size(text::small()).color(theme.text)) as Box<dyn Widget>,
Box::new(
Row::new(vec![
Box::new(Rectangle::new().width(bar_w).height(6.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(6.0)).height(6.0).radius(Full).fill(theme.accent)))
as Box<dyn Widget>,
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
])
.gap(10.0)
.align(Center),
),
])
.width(width)
.height(row_h)
.justify(SpaceBetween)
.align(Center);
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(memory)];
let most = settings::programs();
let shown = if gpu.programs.len() > most { most - 1 } else { most };
for program in gpu.programs.iter().take(shown) {
let amount = match program.busy {
Some(busy) => format!("{} · {busy}%", size(program.vram_kib)),
None => size(program.vram_kib),
};
rows.push(Box::new(line_of(program.name.as_str(), amount, width, row_h, theme.text)));
}
if gpu.programs.len() > most {
rows.push(Box::new(line_of(format!("and {} more", gpu.programs.len() - shown), "", width, row_h, theme.dim)));
}
if gpu.programs.is_empty() {
rows.push(Box::new(line_of("No programs on it", "", width, row_h, theme.dim)));
}
Column::new(rows)
}
// the temperatures in two columns: icon, name, value in its colour, a bar up to `hot`
fn temperature_grid(temps: &[(String, Glyph, f32)], width: f32) -> Stack {
let theme = theme::now();
let cell_w = width / 2.0 - 8.0;
let (row_h, line_h, icon) = (temp_row_height(), text::small() + 6.0, 14.0);
let bar_w = cell_w - icon - 8.0;
// every icon into one canvas behind the rows
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
for (row, pair) in temps.chunks(2).enumerate() {
let mut cells: Vec<Box<dyn Widget>> = Vec::new();
for (column, (name, glyph, celsius)) in pair.iter().enumerate() {
let color = temperature_color(*celsius);
let share = (celsius / settings::hot().max(1.0)).clamp(0.0, 1.0);
shapes.extend(glyph_shapes(*glyph, icon, color, 0.0, (column as f32 * (cell_w + 16.0), row as f32 * row_h + (line_h - icon) / 2.0)));
cells.push(Box::new(
Rectangle::new().width(cell_w).height(row_h).padding(amane::Padding { top: 0.0, right: 0.0, bottom: 0.0, left: icon + 8.0 }).child(
Column::new(vec![
Box::new(
Row::new(vec![
Box::new(Text::new(name.as_str()).size(text::small()).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(format!("{celsius:.0} °C")).size(text::small()).weight(Weight::Bold).color(color)),
])
.width(bar_w)
.height(line_h)
.justify(SpaceBetween)
.align(Center),
) as Box<dyn Widget>,
Box::new(Rectangle::new().width(bar_w).height(3.0).radius(Full).fill(overlays::track()).child(Rectangle::new().width((bar_w * share).max(3.0)).height(3.0).radius(Full).fill(color))),
])
.gap(2.0),
),
));
}
rows.push(Box::new(Row::new(cells).gap(16.0)));
}
let height = temp_rows() as f32 * row_h;
Stack::new(vec![Box::new(Canvas::new().width(width).height(height).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows))]).width(width).height(height)
}
pub fn card(width: f32, height: f32) -> Rectangle {
let stats = Stats::read();
let gpu = Gpu::read();
let theme = theme::now();
let header = pods::header("System", vec![Box::new(pods::chip("Processes", Some(Glyph::Terminal), processes))], width);
let last = |history: &[f32]| history.last().copied().unwrap_or(0.0);
let peak = |a: &[f32], b: &[f32]| a.iter().chain(b).copied().fold(1024.0_f32, f32::max);
let net_top = peak(&stats.down, &stats.up);
let disk_top = peak(&stats.read, &stats.write);
// (name, value in words, graphs as (history, top, colour))
let mut blocks: Vec<(String, String, Vec<Series>)> = vec![
(String::from("CPU"), format!("{:.0} %", last(&stats.cpu) * 100.0), vec![(&stats.cpu, 1.0, theme.accent)]),
(String::from("iGPU"), stats.gpu_words.clone(), vec![(&stats.gpu, 1.0, theme.accent)]),
];
if !gpu.name.is_empty() {
let mut facts = vec![format!("{}%", gpu.busy)];
if let Some(celsius) = gpu.celsius {
facts.push(format!("{celsius} °C"));
}
if let Some(watts) = gpu.watts {
facts.push(format!("{watts:.0} W"));
}
blocks.push((gpu.name.clone(), facts.join(" · "), vec![(&stats.egpu, 1.0, theme.flame)]));
}
blocks.extend([
(String::from("Memory"), stats.ram_words.clone(), vec![(stats.ram.as_slice(), 1.0, theme.accent)]),
(String::from("Swap"), stats.swap_words.clone(), vec![(stats.swap.as_slice(), 1.0, theme.flame)]),
(String::from("Network"), format!("↓ {} ↑ {}", rate(last(&stats.down)), rate(last(&stats.up))), vec![(stats.down.as_slice(), net_top, theme.accent), (stats.up.as_slice(), net_top, theme.flame)]),
(String::from("Disk"), format!("read {} write {}", rate(last(&stats.read)), rate(last(&stats.write))), vec![(stats.read.as_slice(), disk_top, theme.accent), (stats.write.as_slice(), disk_top, theme.flame)]),
]);
let (label_h, graph_h) = (label_height(), settings::graph_height());
// every graph into one canvas under the labels: each canvas costs the gpu on its own
let mut shapes: Vec<Box<dyn Shape>> = Vec::new();
let mut rows: Vec<Box<dyn Widget>> = Vec::new();
for (index, (name, words, series)) in blocks.into_iter().enumerate() {
let top = index as f32 * (label_h + graph_h + 10.0);
for (history, peak, color) in series {
shapes.extend(graph(history, peak, (0.0, top + label_h), (width, graph_h), color));
}
rows.push(Box::new(
Rectangle::new().width(width).height(label_h + graph_h).child(
Row::new(vec![
Box::new(Text::new(name).size(text::small()).weight(Weight::Bold).color(theme.text)) as Box<dyn Widget>,
Box::new(Text::new(words).size(text::small()).color(theme.dim)),
])
.width(width)
.justify(SpaceBetween),
),
));
}
let count = graph_count(&gpu) as f32;
let graphs_h = count * (label_h + graph_h) + (count - 1.0) * 10.0;
let graphs = Stack::new(vec![Box::new(Canvas::new().width(width).height(graphs_h).shapes(shapes)) as Box<dyn Widget>, Box::new(Column::new(rows).gap(10.0))]).width(width).height(graphs_h);
let mut parts: Vec<Box<dyn Widget>> = vec![Box::new(header), Box::new(graphs)];
if !gpu.name.is_empty() {
parts.push(Box::new(egpu_detail(&gpu, width)));
}
parts.push(Box::new(pods::rule(width)));
parts.push(Box::new(Rectangle::new().width(width).height(label_h).child(Text::new("Temperatures").size(text::small()).weight(Weight::Bold).color(theme.text))));
if stats.temps.is_empty() {
let words = if stats.cpu.is_empty() { "Reading…" } else { "No temperature sensors" };
parts.push(Box::new(Rectangle::new().width(width).height(temp_rows() as f32 * temp_row_height()).child(Text::new(words).size(text::small()).color(theme.dim))));
} else {
parts.push(Box::new(temperature_grid(&stats.temps, width)));
}
Rectangle::new().width(width).height(height).child(Column::new(parts).gap(10.0))
}