refactor: add SystemStats singleton + nova-stats daemon for cpu/mem polling

This commit is contained in:
Damocles 2026-04-15 02:10:45 +02:00
parent 71a843e0f3
commit 136ff53cb5
13 changed files with 371 additions and 196 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
result
result-*

View file

@ -32,6 +32,7 @@
projectRootFile = "flake.nix"; projectRootFile = "flake.nix";
programs.nixfmt.enable = true; programs.nixfmt.enable = true;
programs.qmlformat.enable = true; programs.qmlformat.enable = true;
programs.rustfmt.enable = true;
}; };
forAllSystems = forAllSystems =
fn: fn:
@ -53,9 +54,14 @@
withX11 = false; withX11 = false;
withI3 = false; withI3 = false;
}; };
nova-stats = pkgs.callPackage ./nix/stats-daemon.nix { };
in in
rec { rec {
nova-shell = pkgs.callPackage ./nix/package.nix { quickshell = qs; }; inherit nova-stats;
nova-shell = pkgs.callPackage ./nix/package.nix {
quickshell = qs;
inherit nova-stats;
};
nova-shell-cli = pkgs.runCommand "nova-shell-cli" { nativeBuildInputs = [ pkgs.makeWrapper ]; } '' nova-shell-cli = pkgs.runCommand "nova-shell-cli" { nativeBuildInputs = [ pkgs.makeWrapper ]; } ''
mkdir -p $out/bin mkdir -p $out/bin
makeWrapper ${qs}/bin/quickshell $out/bin/nova-shell \ makeWrapper ${qs}/bin/quickshell $out/bin/nova-shell \
@ -65,6 +71,21 @@
} }
); );
devShells = forAllSystems (
{ pkgs, ... }:
{
default = pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rust-analyzer
clippy
rustfmt
];
};
}
);
checks = forAllSystems ( checks = forAllSystems (
{ pkgs, treefmt-eval }: { pkgs, treefmt-eval }:
{ {

View file

@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Io
import "." as M import "." as M
M.BarSection { M.BarSection {
@ -7,7 +6,7 @@ M.BarSection {
spacing: Math.max(1, M.Theme.moduleSpacing - 2) spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: "" tooltip: ""
property int usage: 0 property int usage: M.SystemStats.cpuUsage
Behavior on usage { Behavior on usage {
NumberAnimation { NumberAnimation {
duration: 400 duration: 400
@ -15,7 +14,7 @@ M.BarSection {
} }
} }
property real freqGhz: 0 property real freqGhz: M.SystemStats.cpuFreqGhz
Behavior on freqGhz { Behavior on freqGhz {
NumberAnimation { NumberAnimation {
duration: 400 duration: 400
@ -23,115 +22,11 @@ M.BarSection {
} }
} }
property var _prev: null readonly property var _coreUsage: M.SystemStats.cpuCoreUsage
property var _corePrev: [] readonly property var _coreFreq: M.SystemStats.cpuCoreFreq
property var _coreUsage: [] readonly property var _coreHistory: M.SystemStats.cpuCoreHistory
property var _coreFreq: [] readonly property var _coreMaxFreq: M.SystemStats.cpuCoreMaxFreq
property var _coreHistory: [] // array of arrays, last 16 samples per core readonly property var _coreTypes: M.SystemStats.cpuCoreTypes
property var _coreMaxFreq: [] // max freq in GHz per core, from cpufreq
property var _coreTypes: [] // "Performance" or "Efficiency" per core
FileView {
id: stat
path: "/proc/stat"
onLoaded: {
const lines = text().split("\n");
// Aggregate
const agg = lines[0].trim().split(/\s+/).slice(1).map(Number);
const idle = agg[3] + agg[4];
const total = agg.reduce((a, b) => a + b, 0);
if (root._prev) {
const dIdle = idle - root._prev.idle;
const dTotal = total - root._prev.total;
if (dTotal > 0)
root.usage = Math.round((1 - dIdle / dTotal) * 100);
}
root._prev = {
idle,
total
};
// Per-core
const coreLines = lines.filter(l => /^cpu\d+\s/.test(l));
const newUsage = [];
const newPrev = root._corePrev.length === coreLines.length ? root._corePrev : Array(coreLines.length).fill(null);
for (let i = 0; i < coreLines.length; i++) {
const f = coreLines[i].trim().split(/\s+/).slice(1).map(Number);
const ci = f[3] + f[4];
const ct = f.reduce((a, b) => a + b, 0);
if (newPrev[i]) {
const di = ci - newPrev[i].idle;
const dt = ct - newPrev[i].total;
newUsage.push(dt > 0 ? Math.round((1 - di / dt) * 100) : 0);
} else {
newUsage.push(0);
}
newPrev[i] = {
idle: ci,
total: ct
};
}
root._coreUsage = newUsage;
root._corePrev = newPrev;
// Update sparkline history
const histLen = 16;
const oldH = root._coreHistory;
const newH = [];
for (let i = 0; i < newUsage.length; i++) {
const prev = i < oldH.length ? oldH[i] : [];
const next = prev.concat([newUsage[i]]);
newH.push(next.length > histLen ? next.slice(next.length - histLen) : next);
}
root._coreHistory = newH;
}
}
FileView {
id: cpuinfo
path: "/proc/cpuinfo"
onLoaded: {
const lines = text().split("\n").filter(l => l.startsWith("cpu MHz"));
if (lines.length === 0)
return;
const freqs = lines.map(l => parseFloat(l.split(":")[1]) / 1000);
root.freqGhz = freqs.reduce((a, b) => a + b, 0) / freqs.length;
root._coreFreq = freqs;
}
}
// Read per-core max freq once at init
Process {
running: true
command: ["sh", "-c", "for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do [ -f \"$f\" ] && cat \"$f\" || echo 0; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root._coreMaxFreq = text.trim().split("\n").filter(l => l).map(l => parseInt(l) / 1e6);
}
}
}
// Read P/E-core topology once at init
Process {
running: true
command: ["sh", "-c", "for d in /sys/devices/system/cpu/cpu[0-9]*/topology/core_type; do [ -f \"$d\" ] && cat \"$d\" || echo Performance; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root._coreTypes = text.trim().split("\n").filter(l => l).map(l => l.trim());
}
}
}
Timer {
interval: M.Modules.cpu.interval || 1000
running: true
repeat: true
onTriggered: {
stat.reload();
cpuinfo.reload();
}
}
property bool _pinned: false property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered

View file

@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Io
import "." as M import "." as M
M.BarSection { M.BarSection {
@ -7,46 +6,8 @@ M.BarSection {
spacing: Math.max(1, M.Theme.moduleSpacing - 2) spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: "" tooltip: ""
property var _mounts: [] property var _mounts: M.SystemStats.diskMounts
property int _rootPct: 0 property int _rootPct: M.SystemStats.diskRootPct
Process {
id: proc
running: true
command: ["sh", "-c", "df -x tmpfs -x devtmpfs -x squashfs -x efivarfs -x overlay -B1 --output=target,size,used 2>/dev/null | awk 'NR>1 && $2+0>0 {print $1\"|\"$2\"|\"$3}'"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n").filter(l => l);
const mounts = [];
for (const line of lines) {
const parts = line.split("|");
if (parts.length < 3)
continue;
const total = parseInt(parts[1]);
const used = parseInt(parts[2]);
if (total <= 0)
continue;
mounts.push({
"target": parts[0],
"pct": Math.round(used / total * 100),
"usedBytes": used,
"totalBytes": total
});
}
root._mounts = mounts;
const rm = mounts.find(m => m.target === "/");
if (rm)
root._rootPct = rm.pct;
}
}
}
Timer {
interval: M.Modules.disk.interval || 30000
running: true
repeat: true
onTriggered: proc.running = true
}
function _fmt(bytes) { function _fmt(bytes) {
if (bytes >= 1e12) if (bytes >= 1e12)

View file

@ -1,5 +1,4 @@
import QtQuick import QtQuick
import Quickshell.Io
import "." as M import "." as M
M.BarSection { M.BarSection {
@ -7,45 +6,12 @@ M.BarSection {
spacing: Math.max(1, M.Theme.moduleSpacing - 2) spacing: Math.max(1, M.Theme.moduleSpacing - 2)
tooltip: "" tooltip: ""
property int percent: 0 property int percent: M.SystemStats.memPercent
property real usedGb: 0 property real usedGb: M.SystemStats.memUsedGb
property real totalGb: 0 property real totalGb: M.SystemStats.memTotalGb
property real availGb: 0 property real availGb: M.SystemStats.memAvailGb
property real cachedGb: 0 property real cachedGb: M.SystemStats.memCachedGb
property real buffersGb: 0 property real buffersGb: M.SystemStats.memBuffersGb
FileView {
id: meminfo
path: "/proc/meminfo"
onLoaded: {
const m = {};
text().split("\n").forEach(l => {
const [k, v] = l.split(":");
if (v)
m[k.trim()] = parseInt(v.trim());
});
const total = m.MemTotal || 0;
const avail = m.MemAvailable || 0;
const buffers = m.Buffers || 0;
const cached = (m.Cached || 0) + (m.SReclaimable || 0);
const used = total - avail;
if (total > 0) {
root.percent = Math.round(used / total * 100);
root.usedGb = used / 1048576;
root.totalGb = total / 1048576;
root.availGb = avail / 1048576;
root.cachedGb = cached / 1048576;
root.buffersGb = buffers / 1048576;
}
}
}
Timer {
interval: M.Modules.memory.interval || 2000
running: true
repeat: true
onTriggered: meminfo.reload()
}
function _fmt(gb) { function _fmt(gb) {
return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G";

127
modules/SystemStats.qml Normal file
View file

@ -0,0 +1,127 @@
pragma Singleton
import QtQuick
import Quickshell.Io
import "." as M
QtObject {
id: root
// CPU
property int cpuUsage: 0
property real cpuFreqGhz: 0
property var cpuCoreUsage: []
property var cpuCoreFreq: []
property var cpuCoreHistory: []
property var cpuCoreMaxFreq: []
property var cpuCoreTypes: []
// Memory
property int memPercent: 0
property real memUsedGb: 0
property real memTotalGb: 0
property real memAvailGb: 0
property real memCachedGb: 0
property real memBuffersGb: 0
// Disk
property var diskMounts: []
property int diskRootPct: 0
// nova-stats stream (cpu + mem)
property var _statsProc: Process {
running: true
command: ["nova-stats"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const ev = JSON.parse(line);
if (ev.type === "cpu") {
root.cpuUsage = ev.usage;
root.cpuFreqGhz = ev.freq_ghz;
root.cpuCoreUsage = ev.core_usage;
root.cpuCoreFreq = ev.core_freq_ghz;
const histLen = 16;
const oldH = root.cpuCoreHistory;
const newH = [];
for (let i = 0; i < ev.core_usage.length; i++) {
const prev = i < oldH.length ? oldH[i] : [];
const next = prev.concat([ev.core_usage[i]]);
newH.push(next.length > histLen ? next.slice(next.length - histLen) : next);
}
root.cpuCoreHistory = newH;
} else if (ev.type === "mem") {
root.memPercent = ev.percent;
root.memUsedGb = ev.used_gb;
root.memTotalGb = ev.total_gb;
root.memAvailGb = ev.avail_gb;
root.memCachedGb = ev.cached_gb;
root.memBuffersGb = ev.buffers_gb;
}
} catch (e) {}
}
}
}
// One-time: per-core max freq
property var _maxFreqProc: Process {
running: true
command: ["sh", "-c", "for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do [ -f \"$f\" ] && cat \"$f\" || echo 0; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root.cpuCoreMaxFreq = text.trim().split("\n").filter(l => l).map(l => parseInt(l) / 1e6);
}
}
}
// One-time: P/E-core topology
property var _coreTypesProc: Process {
running: true
command: ["sh", "-c", "for d in /sys/devices/system/cpu/cpu[0-9]*/topology/core_type; do [ -f \"$d\" ] && cat \"$d\" || echo Performance; done 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
root.cpuCoreTypes = text.trim().split("\n").filter(l => l).map(l => l.trim());
}
}
}
// Disk via df
property var _diskProc: Process {
id: diskProc
running: true
command: ["sh", "-c", "df -x tmpfs -x devtmpfs -x squashfs -x efivarfs -x overlay -B1 --output=target,size,used 2>/dev/null | awk 'NR>1 && $2+0>0 {print $1\"|\"$2\"|\"$3}'"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n").filter(l => l);
const mounts = [];
for (const line of lines) {
const parts = line.split("|");
if (parts.length < 3)
continue;
const total = parseInt(parts[1]);
const used = parseInt(parts[2]);
if (total <= 0)
continue;
mounts.push({
"target": parts[0],
"pct": Math.round(used / total * 100),
"usedBytes": used,
"totalBytes": total
});
}
root.diskMounts = mounts;
const rm = mounts.find(m => m.target === "/");
if (rm)
root.diskRootPct = rm.pct;
}
}
}
property var _diskTimer: Timer {
interval: M.Modules.disk.interval || 30000
running: true
repeat: true
onTriggered: diskProc.running = true
}
}

View file

@ -33,6 +33,7 @@ PowerProfile 1.0 PowerProfile.qml
IdleInhibitor 1.0 IdleInhibitor.qml IdleInhibitor 1.0 IdleInhibitor.qml
Notifications 1.0 Notifications.qml Notifications 1.0 Notifications.qml
singleton NiriIpc 1.0 NiriIpc.qml singleton NiriIpc 1.0 NiriIpc.qml
singleton SystemStats 1.0 SystemStats.qml
singleton ProcessList 1.0 ProcessList.qml singleton ProcessList 1.0 ProcessList.qml
singleton NotifService 1.0 NotifService.qml singleton NotifService 1.0 NotifService.qml
NotifItem 1.0 NotifItem.qml NotifItem 1.0 NotifItem.qml

View file

@ -202,7 +202,8 @@ in
pkgs.nerd-fonts.symbols-only pkgs.nerd-fonts.symbols-only
] ]
++ lib.optional cfg.modules.weather.enable pkgs.wttrbar ++ lib.optional cfg.modules.weather.enable pkgs.wttrbar
++ lib.optional cfg.modules.mpris.enable pkgs.cava; ++ lib.optional cfg.modules.mpris.enable pkgs.cava
++ lib.optional cfg.modules.disk.enable pkgs.coreutils;
xdg.configFile."nova-shell/modules.json".source = xdg.configFile."nova-shell/modules.json".source =
(pkgs.formats.json { }).generate "nova-shell-modules.json" (pkgs.formats.json { }).generate "nova-shell-modules.json"

View file

@ -4,6 +4,7 @@
makeWrapper, makeWrapper,
quickshell, quickshell,
qt6, qt6,
nova-stats,
}: }:
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "nova-shell"; pname = "nova-shell";
@ -32,7 +33,8 @@ stdenvNoCC.mkDerivation {
mkdir -p $out/bin mkdir -p $out/bin
makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \ makeWrapper ${lib.getExe quickshell} $out/bin/nova-shell \
--add-flags "-p $out/share/nova-shell/shell.qml" --add-flags "-p $out/share/nova-shell/shell.qml" \
--prefix PATH : ${lib.makeBinPath [ nova-stats ]}
runHook postInstall runHook postInstall
''; '';

12
nix/stats-daemon.nix Normal file
View file

@ -0,0 +1,12 @@
{ lib, rustPlatform }:
rustPlatform.buildRustPackage {
pname = "nova-stats";
version = "0.1.0";
src = lib.cleanSource ../stats-daemon;
cargoLock.lockFile = ../stats-daemon/Cargo.lock;
meta = {
description = "System stats daemon for nova-shell";
mainProgram = "nova-stats";
platforms = lib.platforms.linux;
};
}

7
stats-daemon/Cargo.lock generated Normal file
View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "nova-stats"
version = "0.1.0"

8
stats-daemon/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "nova-stats"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nova-stats"
path = "src/main.rs"

172
stats-daemon/src/main.rs Normal file
View file

@ -0,0 +1,172 @@
use std::fs;
use std::io::{self, Write};
use std::thread;
use std::time::{Duration, Instant};
struct Sample {
idle: u64,
total: u64,
}
fn read_stat() -> Vec<Sample> {
fs::read_to_string("/proc/stat")
.unwrap_or_default()
.lines()
.filter(|l| l.starts_with("cpu"))
.map(|l| {
let vals: Vec<u64> = l
.split_whitespace()
.skip(1)
.filter_map(|s| s.parse().ok())
.collect();
let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0);
let total = vals.iter().sum();
Sample { idle, total }
})
.collect()
}
fn pct(prev: &Sample, curr: &Sample) -> u32 {
let dt = curr.total.saturating_sub(prev.total);
let di = curr.idle.saturating_sub(prev.idle);
if dt == 0 {
return 0;
}
(dt.saturating_sub(di) * 100 / dt) as u32
}
fn read_core_freqs() -> Vec<f64> {
let mut freqs = Vec::new();
for i in 0.. {
let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq");
match fs::read_to_string(&path) {
Ok(s) => match s.trim().parse::<u64>() {
Ok(khz) => freqs.push(khz as f64 / 1_000_000.0),
Err(_) => break,
},
Err(_) => break,
}
}
freqs
}
fn emit_cpu(out: &mut impl Write, prev: &[Sample], curr: &[Sample], freqs: &[f64]) {
if curr.is_empty() {
return;
}
let usage = prev
.first()
.zip(curr.first())
.map(|(p, c)| pct(p, c))
.unwrap_or(0);
let core_usage: Vec<u32> = prev
.iter()
.skip(1)
.zip(curr.iter().skip(1))
.map(|(p, c)| pct(p, c))
.collect();
let avg_freq = if freqs.is_empty() {
0.0
} else {
freqs.iter().sum::<f64>() / freqs.len() as f64
};
let _ = write!(
out,
"{{\"type\":\"cpu\",\"usage\":{usage},\"freq_ghz\":{avg_freq:.3},\"core_usage\":["
);
for (i, u) in core_usage.iter().enumerate() {
if i > 0 {
let _ = write!(out, ",");
}
let _ = write!(out, "{u}");
}
let _ = write!(out, "],\"core_freq_ghz\":[");
for (i, f) in freqs.iter().enumerate() {
if i > 0 {
let _ = write!(out, ",");
}
let _ = write!(out, "{f:.3}");
}
let _ = writeln!(out, "]}}");
}
fn emit_mem(out: &mut impl Write) {
let content = fs::read_to_string("/proc/meminfo").unwrap_or_default();
let mut total = 0u64;
let mut avail = 0u64;
let mut buffers = 0u64;
let mut cached = 0u64;
let mut sreclaimable = 0u64;
for line in content.lines() {
let mut parts = line.splitn(2, ':');
let key = parts.next().unwrap_or("").trim();
let val: u64 = parts
.next()
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("")
.parse()
.unwrap_or(0);
match key {
"MemTotal" => total = val,
"MemAvailable" => avail = val,
"Buffers" => buffers = val,
"Cached" => cached = val,
"SReclaimable" => sreclaimable = val,
_ => {}
}
}
if total == 0 {
return;
}
let used = total.saturating_sub(avail);
let cached_total = cached + sreclaimable;
let percent = used * 100 / total;
let gb = |kb: u64| kb as f64 / 1_048_576.0;
let _ = writeln!(
out,
"{{\"type\":\"mem\",\"percent\":{percent},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}",
gb(used),
gb(total),
gb(avail),
gb(cached_total),
gb(buffers),
);
}
fn main() {
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
let mut prev: Vec<Sample> = vec![];
let mut tick = 0u64;
loop {
let t0 = Instant::now();
let curr = read_stat();
let freqs = read_core_freqs();
emit_cpu(&mut out, &prev, &curr, &freqs);
prev = curr;
if tick % 2 == 0 {
emit_mem(&mut out);
}
let _ = out.flush();
tick += 1;
let elapsed = t0.elapsed();
if elapsed < Duration::from_secs(1) {
thread::sleep(Duration::from_secs(1) - elapsed);
}
}
}