From 136ff53cb54e40339cebb0d27bdc26d8d8bffdc4 Mon Sep 17 00:00:00 2001 From: Damocles Date: Wed, 15 Apr 2026 02:10:45 +0200 Subject: [PATCH] refactor: add SystemStats singleton + nova-stats daemon for cpu/mem polling --- .gitignore | 2 + flake.nix | 23 +++++- modules/Cpu.qml | 119 ++------------------------- modules/Disk.qml | 43 +--------- modules/Memory.qml | 46 ++--------- modules/SystemStats.qml | 127 +++++++++++++++++++++++++++++ modules/qmldir | 1 + nix/hm-module.nix | 3 +- nix/package.nix | 4 +- nix/stats-daemon.nix | 12 +++ stats-daemon/Cargo.lock | 7 ++ stats-daemon/Cargo.toml | 8 ++ stats-daemon/src/main.rs | 172 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 371 insertions(+), 196 deletions(-) create mode 100644 .gitignore create mode 100644 modules/SystemStats.qml create mode 100644 nix/stats-daemon.nix create mode 100644 stats-daemon/Cargo.lock create mode 100644 stats-daemon/Cargo.toml create mode 100644 stats-daemon/src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750baeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +result +result-* diff --git a/flake.nix b/flake.nix index a86139d..c6ca4c1 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ projectRootFile = "flake.nix"; programs.nixfmt.enable = true; programs.qmlformat.enable = true; + programs.rustfmt.enable = true; }; forAllSystems = fn: @@ -53,9 +54,14 @@ withX11 = false; withI3 = false; }; + nova-stats = pkgs.callPackage ./nix/stats-daemon.nix { }; in 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 ]; } '' mkdir -p $out/bin 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 ( { pkgs, treefmt-eval }: { diff --git a/modules/Cpu.qml b/modules/Cpu.qml index 70eeae4..52c3420 100644 --- a/modules/Cpu.qml +++ b/modules/Cpu.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell.Io import "." as M M.BarSection { @@ -7,7 +6,7 @@ M.BarSection { spacing: Math.max(1, M.Theme.moduleSpacing - 2) tooltip: "" - property int usage: 0 + property int usage: M.SystemStats.cpuUsage Behavior on usage { NumberAnimation { duration: 400 @@ -15,7 +14,7 @@ M.BarSection { } } - property real freqGhz: 0 + property real freqGhz: M.SystemStats.cpuFreqGhz Behavior on freqGhz { NumberAnimation { duration: 400 @@ -23,115 +22,11 @@ M.BarSection { } } - property var _prev: null - property var _corePrev: [] - property var _coreUsage: [] - property var _coreFreq: [] - property var _coreHistory: [] // array of arrays, last 16 samples per core - 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(); - } - } + readonly property var _coreUsage: M.SystemStats.cpuCoreUsage + readonly property var _coreFreq: M.SystemStats.cpuCoreFreq + readonly property var _coreHistory: M.SystemStats.cpuCoreHistory + readonly property var _coreMaxFreq: M.SystemStats.cpuCoreMaxFreq + readonly property var _coreTypes: M.SystemStats.cpuCoreTypes property bool _pinned: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered diff --git a/modules/Disk.qml b/modules/Disk.qml index 45f145f..05ffcb6 100644 --- a/modules/Disk.qml +++ b/modules/Disk.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell.Io import "." as M M.BarSection { @@ -7,46 +6,8 @@ M.BarSection { spacing: Math.max(1, M.Theme.moduleSpacing - 2) tooltip: "" - property var _mounts: [] - property int _rootPct: 0 - - 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 - } + property var _mounts: M.SystemStats.diskMounts + property int _rootPct: M.SystemStats.diskRootPct function _fmt(bytes) { if (bytes >= 1e12) diff --git a/modules/Memory.qml b/modules/Memory.qml index 2c83502..56df098 100644 --- a/modules/Memory.qml +++ b/modules/Memory.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell.Io import "." as M M.BarSection { @@ -7,45 +6,12 @@ M.BarSection { spacing: Math.max(1, M.Theme.moduleSpacing - 2) tooltip: "" - property int percent: 0 - property real usedGb: 0 - property real totalGb: 0 - property real availGb: 0 - property real cachedGb: 0 - property real buffersGb: 0 - - 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() - } + property int percent: M.SystemStats.memPercent + property real usedGb: M.SystemStats.memUsedGb + property real totalGb: M.SystemStats.memTotalGb + property real availGb: M.SystemStats.memAvailGb + property real cachedGb: M.SystemStats.memCachedGb + property real buffersGb: M.SystemStats.memBuffersGb function _fmt(gb) { return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; diff --git a/modules/SystemStats.qml b/modules/SystemStats.qml new file mode 100644 index 0000000..fd0c20d --- /dev/null +++ b/modules/SystemStats.qml @@ -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 + } +} diff --git a/modules/qmldir b/modules/qmldir index c460e16..327123f 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -33,6 +33,7 @@ PowerProfile 1.0 PowerProfile.qml IdleInhibitor 1.0 IdleInhibitor.qml Notifications 1.0 Notifications.qml singleton NiriIpc 1.0 NiriIpc.qml +singleton SystemStats 1.0 SystemStats.qml singleton ProcessList 1.0 ProcessList.qml singleton NotifService 1.0 NotifService.qml NotifItem 1.0 NotifItem.qml diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 93ec3ce..cd3ab56 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -202,7 +202,8 @@ in pkgs.nerd-fonts.symbols-only ] ++ 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 = (pkgs.formats.json { }).generate "nova-shell-modules.json" diff --git a/nix/package.nix b/nix/package.nix index f64f509..bdc503e 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,6 +4,7 @@ makeWrapper, quickshell, qt6, + nova-stats, }: stdenvNoCC.mkDerivation { pname = "nova-shell"; @@ -32,7 +33,8 @@ stdenvNoCC.mkDerivation { mkdir -p $out/bin 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 ''; diff --git a/nix/stats-daemon.nix b/nix/stats-daemon.nix new file mode 100644 index 0000000..4f5f694 --- /dev/null +++ b/nix/stats-daemon.nix @@ -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; + }; +} diff --git a/stats-daemon/Cargo.lock b/stats-daemon/Cargo.lock new file mode 100644 index 0000000..1040bb4 --- /dev/null +++ b/stats-daemon/Cargo.lock @@ -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" diff --git a/stats-daemon/Cargo.toml b/stats-daemon/Cargo.toml new file mode 100644 index 0000000..71f41fe --- /dev/null +++ b/stats-daemon/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nova-stats" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nova-stats" +path = "src/main.rs" diff --git a/stats-daemon/src/main.rs b/stats-daemon/src/main.rs new file mode 100644 index 0000000..b65093d --- /dev/null +++ b/stats-daemon/src/main.rs @@ -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 { + fs::read_to_string("/proc/stat") + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with("cpu")) + .map(|l| { + let vals: Vec = 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 { + 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::() { + 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 = 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::() / 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 = 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); + } + } +}