diff --git a/README.md b/README.md index cab99da..7324f9a 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ programs.nova-shell.modules = { Each module is an object with `enable` (default `true`) and optional extra settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, `notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`, -`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`, +`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `cpu`, `memory`, `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`. ### Theme diff --git a/modules/Bar.qml b/modules/Bar.qml index f8318a6..809edad 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -207,7 +207,6 @@ PanelWindow { M.Memory { visible: M.Modules.memory.enable } - M.Gpu {} M.Temperature { visible: M.Modules.temperature.enable } diff --git a/modules/Gpu.qml b/modules/Gpu.qml deleted file mode 100644 index 5f43c4b..0000000 --- a/modules/Gpu.qml +++ /dev/null @@ -1,276 +0,0 @@ -import QtQuick -import Quickshell -import "." as M - -M.BarSection { - id: root - spacing: Math.max(1, M.Theme.moduleSpacing - 2) - tooltip: "" - visible: M.Modules.gpu.enable && M.SystemStats.gpuAvailable - - property bool _pinned: false - readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered - readonly property bool _showPanel: _anyHover || _pinned - - on_AnyHoverChanged: { - if (_anyHover) - _unpinTimer.stop(); - else if (_pinned) - _unpinTimer.start(); - } - - Timer { - id: _unpinTimer - interval: 500 - onTriggered: root._pinned = false - } - - function _loadColor(pct) { - const t = Math.max(0, Math.min(100, pct)) / 100; - const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A; - const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08; - const u = t < 0.5 ? t * 2 : (t - 0.5) * 2; - return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1); - } - - function _fmt(gb) { - return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; - } - - M.BarIcon { - icon: "\uDB84\uDCB0" - color: root._loadColor(M.SystemStats.gpuUsage) - anchors.verticalCenter: parent.verticalCenter - TapHandler { - cursorShape: Qt.PointingHandCursor - onTapped: root._pinned = !root._pinned - } - } - M.BarLabel { - label: M.SystemStats.gpuUsage + "%" - minText: "100%" - color: root._loadColor(M.SystemStats.gpuUsage) - anchors.verticalCenter: parent.verticalCenter - TapHandler { - cursorShape: Qt.PointingHandCursor - onTapped: root._pinned = !root._pinned - } - } - - M.HoverPanel { - id: hoverPanel - showPanel: root._showPanel - screen: QsWindow.window?.screen ?? null - anchorItem: root - accentColor: root.accentColor - panelNamespace: "nova-gpu" - panelTitle: "GPU" - contentWidth: 240 - - // Header — vendor + usage% - Item { - width: parent.width - height: 28 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: M.SystemStats.gpuVendor.toUpperCase() - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 3 - font.family: M.Theme.fontFamily - font.letterSpacing: 1 - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: M.SystemStats.gpuUsage + "%" - color: root._loadColor(M.SystemStats.gpuUsage) - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - } - } - - // Usage bar - Item { - width: parent.width - height: 14 - - Item { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - height: 6 - - Rectangle { - anchors.fill: parent - color: M.Theme.base02 - radius: 3 - } - - Rectangle { - width: parent.width * Math.min(1, M.SystemStats.gpuUsage / 100) - height: parent.height - color: root._loadColor(M.SystemStats.gpuUsage) - radius: 3 - Behavior on width { - enabled: root._showPanel - NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic - } - } - } - } - } - - // Usage history sparkline - Canvas { - id: _sparkline - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - height: 36 - - property var _hist: M.SystemStats.gpuHistory - - on_HistChanged: if (root._showPanel) - requestPaint() - - Connections { - target: root - function on_ShowPanelChanged() { - if (root._showPanel) - _sparkline.requestPaint(); - } - } - - onPaint: { - const ctx = getContext("2d"); - if (!ctx) - return; - ctx.clearRect(0, 0, width, height); - const d = _hist; - if (!d.length) - return; - const maxSamples = 60; - const bw = width / maxSamples; - const offset = maxSamples - d.length; - for (let i = 0; i < d.length; i++) { - const barH = Math.max(1, height * d[i] / 100); - const col = root._loadColor(d[i]); - ctx.fillStyle = col.toString(); - ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH); - } - } - } - - // VRAM section - Rectangle { - width: parent.width - 16 - height: 1 - anchors.horizontalCenter: parent.horizontalCenter - color: M.Theme.base03 - } - - Item { - width: parent.width - height: 22 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "VRAM" - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 3 - font.family: M.Theme.fontFamily - font.letterSpacing: 1 - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: root._fmt(M.SystemStats.gpuVramUsedGb) + " / " + root._fmt(M.SystemStats.gpuVramTotalGb) - color: root.accentColor - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily - font.bold: true - } - } - - Item { - width: parent.width - height: 12 - - Item { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - height: 5 - - Rectangle { - anchors.fill: parent - color: M.Theme.base02 - radius: 2 - } - - Rectangle { - width: M.SystemStats.gpuVramTotalGb > 0 ? parent.width * Math.min(1, M.SystemStats.gpuVramUsedGb / M.SystemStats.gpuVramTotalGb) : 0 - height: parent.height - color: root.accentColor - radius: 2 - Behavior on width { - enabled: root._showPanel - NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic - } - } - } - } - } - - // Temperature row - Item { - width: parent.width - height: 22 - visible: M.SystemStats.gpuTempC > 0 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "Temp" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: M.SystemStats.gpuTempC + "\u00B0C" - color: M.SystemStats.gpuTempC > 85 ? M.Theme.base08 : M.SystemStats.gpuTempC > 70 ? M.Theme.base0A : M.Theme.base05 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - } - - Item { - width: 1 - height: 4 - } - } -} diff --git a/modules/Modules.qml b/modules/Modules.qml index 4a0053e..70a2d5d 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -58,9 +58,6 @@ QtObject { warm: 80, hot: 90 }) - property var gpu: ({ - enable: true - }) property var cpu: ({ enable: true }) @@ -97,7 +94,7 @@ QtObject { // All module keys that have an enable flag — used to default-enable anything // not explicitly mentioned in modules.json - readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop"] + readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop"] // Fallback: if modules.json doesn't exist, enable everything Component.onCompleted: _apply("{}") diff --git a/modules/NotifCard.qml b/modules/NotifCard.qml index eb77208..e908572 100644 --- a/modules/NotifCard.qml +++ b/modules/NotifCard.qml @@ -125,112 +125,86 @@ Item { anchors.topMargin: 8 spacing: 2 - // Text section — tappable for default action - Item { - id: _textSection + // App name + time row (optional) + Row { + visible: root.showAppName width: parent.width - height: _textCol.implicitHeight - implicitHeight: _textCol.implicitHeight - TapHandler { - cursorShape: root.notif?.actions?.some(a => a.identifier === "default") ? Qt.PointingHandCursor : undefined - onTapped: { - const def = root.notif?.actions?.find(a => a.identifier === "default"); - if (def) { - def.invoke(); - root.dismissRequested(); - } - } + Text { + text: root.notif?.appName ?? "Notification" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + width: parent.width - _timeText.implicitWidth - 4 + elide: Text.ElideRight } - Column { - id: _textCol - width: parent.width - spacing: 2 - - // App name + time row (optional) - Row { - visible: root.showAppName - width: parent.width - - Text { - text: root.notif?.appName ?? "Notification" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - width: parent.width - _timeText.implicitWidth - 4 - elide: Text.ElideRight - } - - Text { - id: _timeText - text: root.notif?.timeStr ?? "" - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - } - - // Summary (with inline time when app name row is hidden) - Row { - visible: !root.showAppName - width: parent.width - - Text { - text: root.notif?.summary ?? "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - width: parent.width - _inlineTime.implicitWidth - 4 - } - - Text { - id: _inlineTime - text: root.notif?.timeStr ?? "" - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - anchors.verticalCenter: parent.verticalCenter - } - } - - Text { - visible: root.showAppName - width: parent.width - text: root.notif?.summary ?? "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 2 - } - - Text { - width: parent.width - text: root.notif?.body ?? "" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: root.bodyMaxLines - visible: text !== "" - } + Text { + id: _timeText + text: root.notif?.timeStr ?? "" + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily } } - // Action buttons — filter "default" (click-notification convention) and empty labels + // Summary (with inline time when app name row is hidden) + Row { + visible: !root.showAppName + width: parent.width + + Text { + text: root.notif?.summary ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + width: parent.width - _inlineTime.implicitWidth - 4 + } + + Text { + id: _inlineTime + text: root.notif?.timeStr ?? "" + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + } + + Text { + visible: root.showAppName + width: parent.width + text: root.notif?.summary ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + + Text { + width: parent.width + text: root.notif?.body ?? "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: root.bodyMaxLines + visible: text !== "" + } + + // Action buttons Row { spacing: 6 - visible: _actionRepeater.count > 0 + visible: !!(root.notif?.actions?.length) Repeater { - id: _actionRepeater - model: (root.notif?.actions ?? []).filter(a => a.text && a.identifier !== "default") + model: root.notif?.actions ?? [] delegate: Rectangle { required property var modelData width: _actText.implicitWidth + 12 diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index 40c21a3..a8f72aa 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -411,6 +411,18 @@ M.HoverPanel { // ---- Individual notification ---- + // Vertical connector line — visually ties notifs to their group header + Rectangle { + visible: notifDelegate._type === "notif" + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 2 + color: M.Theme.base02 + radius: 1 + } + M.NotifCard { id: _notifCard visible: notifDelegate._type === "notif" diff --git a/modules/NotifItem.qml b/modules/NotifItem.qml index d3e96b9..1f5199f 100644 --- a/modules/NotifItem.qml +++ b/modules/NotifItem.qml @@ -42,14 +42,6 @@ QtObject { return d > 0 ? d + "d" : h + "h"; } - // App closed the notification from its side — remove from our list while the object is still alive - readonly property Connections _notifConn: Connections { - target: root.notification - function onClosed() { - M.NotifService.dismiss(root.id); - } - } - function beginDismiss() { if (state === "visible") state = "dismissing"; diff --git a/modules/SystemStats.qml b/modules/SystemStats.qml index a668be4..9ee7adf 100644 --- a/modules/SystemStats.qml +++ b/modules/SystemStats.qml @@ -34,15 +34,6 @@ QtObject { property int tempCelsius: 0 property var tempHistory: [] // 150 samples @ 4s each ≈ 10 min - // ── GPU ────────────────────────────────────────────────────────────── - property bool gpuAvailable: false - property string gpuVendor: "" - property int gpuUsage: 0 - property real gpuVramUsedGb: 0 - property real gpuVramTotalGb: 0 - property int gpuTempC: 0 - property var gpuHistory: [] // 60 samples @ ~4-8s each ≈ 4-8 min - // ── Memory ─────────────────────────────────────────────────────────── property int memPercent: 0 property real memUsedGb: 0 @@ -95,15 +86,6 @@ QtObject { root.tempCelsius = ev.celsius; const th = root.tempHistory.concat([ev.celsius]); root.tempHistory = th.length > 150 ? th.slice(th.length - 150) : th; - } else if (ev.type === "gpu") { - root.gpuAvailable = true; - root.gpuVendor = ev.vendor; - root.gpuUsage = ev.usage; - root.gpuVramUsedGb = ev.vram_used_gb; - root.gpuVramTotalGb = ev.vram_total_gb; - root.gpuTempC = ev.temp_c; - const gh = root.gpuHistory.concat([ev.usage]); - root.gpuHistory = gh.length > 60 ? gh.slice(gh.length - 60) : gh; } else if (ev.type === "mem") { root.memPercent = ev.percent; root.memUsedGb = ev.used_gb; diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 7911c9b..33cbc0f 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -87,7 +87,6 @@ in "bluetooth" "network" "powerProfile" - "gpu" "cpu" "memory" "idleInhibitor" diff --git a/stats-daemon/src/cpu.rs b/stats-daemon/src/cpu.rs deleted file mode 100644 index 34c8863..0000000 --- a/stats-daemon/src/cpu.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::fs; -use std::io::Write; - -pub struct Sample { - pub idle: u64, - pub total: u64, -} - -pub fn parse_stat(input: &str) -> Vec { - input - .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() -} - -pub 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 -} - -pub fn read_stat() -> Vec { - parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) -} - -pub 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 -} - -pub 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 n = core_usage.len().max(freqs.len()); - let _ = write!( - out, - "{{\"type\":\"cpu\",\"usage\":{usage},\"freq_ghz\":{avg_freq:.3},\"cores\":[" - ); - for i in 0..n { - if i > 0 { - let _ = write!(out, ","); - } - let u = core_usage.get(i).copied().unwrap_or(0); - let f = freqs.get(i).copied().unwrap_or(0.0); - let _ = write!(out, "{{\"usage\":{u},\"freq_ghz\":{f:.3}}}"); - } - let _ = writeln!(out, "]}}"); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample(idle: u64, total: u64) -> Sample { - Sample { idle, total } - } - - // ── pct ────────────────────────────────────────────────────────────── - - #[test] - fn pct_zero_delta_returns_zero() { - let s = Sample { - idle: 100, - total: 400, - }; - assert_eq!(pct(&s, &s), 0); - } - - #[test] - fn pct_all_idle() { - let prev = Sample { - idle: 0, - total: 100, - }; - let curr = Sample { - idle: 100, - total: 200, - }; - assert_eq!(pct(&prev, &curr), 0); - } - - #[test] - fn pct_fully_busy() { - let prev = Sample { - idle: 100, - total: 200, - }; - let curr = Sample { - idle: 100, - total: 300, - }; - assert_eq!(pct(&prev, &curr), 100); - } - - #[test] - fn pct_half_busy() { - let prev = Sample { idle: 0, total: 0 }; - let curr = Sample { - idle: 50, - total: 100, - }; - assert_eq!(pct(&prev, &curr), 50); - } - - #[test] - fn pct_no_underflow_on_backwards_clock() { - let prev = Sample { - idle: 200, - total: 400, - }; - let curr = Sample { - idle: 100, - total: 300, - }; - assert_eq!(pct(&prev, &curr), 0); - } - - // ── parse_stat ─────────────────────────────────────────────────────── - - const STAT_SAMPLE: &str = "\ -cpu 100 10 50 700 40 0 0 0 0 0 -cpu0 50 5 25 350 20 0 0 0 0 0 -cpu1 50 5 25 350 20 0 0 0 0 0"; - - #[test] - fn parse_stat_count() { - assert_eq!(parse_stat(STAT_SAMPLE).len(), 3); - } - - #[test] - fn parse_stat_aggregate_idle() { - assert_eq!(parse_stat(STAT_SAMPLE)[0].idle, 740); - } - - #[test] - fn parse_stat_aggregate_total() { - assert_eq!(parse_stat(STAT_SAMPLE)[0].total, 900); - } - - #[test] - fn parse_stat_per_core_idle() { - let s = parse_stat(STAT_SAMPLE); - assert_eq!(s[1].idle, 370); - assert_eq!(s[2].idle, 370); - } - - #[test] - fn parse_stat_ignores_non_cpu_lines() { - let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; - assert_eq!(parse_stat(input).len(), 1); - } - - // ── emit_cpu ───────────────────────────────────────────────────────── - - #[test] - fn emit_cpu_valid_json_structure() { - let prev = vec![sample(0, 0), sample(0, 0)]; - let curr = vec![sample(50, 100), sample(25, 100)]; - let freqs = vec![3.2, 3.1]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &prev, &curr, &freqs); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"type\":\"cpu\"")); - assert!(s.contains("\"usage\":")); - assert!(s.contains("\"freq_ghz\":")); - assert!(s.contains("\"cores\":")); - assert!(s.trim().ends_with('}')); - } - - #[test] - fn emit_cpu_correct_usage() { - let prev = vec![sample(0, 0), sample(0, 0)]; - let curr = vec![sample(50, 100), sample(0, 0)]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &prev, &curr, &[]); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"usage\":50"), "got: {s}"); - } - - #[test] - fn emit_cpu_no_prev_gives_zero_usage() { - let curr = vec![sample(50, 100)]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &[], &curr, &[]); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"usage\":0"), "got: {s}"); - } - - #[test] - fn emit_cpu_empty_curr_produces_no_output() { - let mut buf = Vec::new(); - emit_cpu(&mut buf, &[], &[], &[]); - assert!(buf.is_empty()); - } - - #[test] - fn emit_cpu_core_freqs_in_output() { - let curr = vec![sample(0, 100)]; - let freqs = vec![3.200, 2.900]; - let mut buf = Vec::new(); - emit_cpu(&mut buf, &curr, &curr, &freqs); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("\"freq_ghz\":3.200"), "got: {s}"); - assert!(s.contains("\"freq_ghz\":2.900"), "got: {s}"); - } -} diff --git a/stats-daemon/src/gpu.rs b/stats-daemon/src/gpu.rs deleted file mode 100644 index 3c2a989..0000000 --- a/stats-daemon/src/gpu.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::fs; -use std::io::Write; - -pub struct GpuInfo { - pub usage: u32, - pub vram_used_gb: f64, - pub vram_total_gb: f64, - pub temp_c: i32, - pub vendor: &'static str, -} - -pub enum GpuBackend { - Amd { - card_path: String, - hwmon_path: Option, - }, - Nvidia, - None, -} - -pub fn detect_gpu() -> GpuBackend { - // AMD: look for gpu_busy_percent exposed by the amdgpu driver - for i in 0..8 { - let p = format!("/sys/class/drm/card{i}/device/gpu_busy_percent"); - if fs::read_to_string(&p).is_ok() { - let card = format!("/sys/class/drm/card{i}/device"); - let hwmon = find_amd_hwmon(); - return GpuBackend::Amd { - card_path: card, - hwmon_path: hwmon, - }; - } - } - // NVIDIA: probe nvidia-smi - let nvidia_ok = std::process::Command::new("nvidia-smi") - .args(["--query-gpu=name", "--format=csv,noheader"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if nvidia_ok { - return GpuBackend::Nvidia; - } - GpuBackend::None -} - -fn find_amd_hwmon() -> Option { - for i in 0..32 { - let name = format!("/sys/class/hwmon/hwmon{i}/name"); - if fs::read_to_string(&name).ok()?.trim() == "amdgpu" { - return Some(format!("/sys/class/hwmon/hwmon{i}")); - } - } - None -} - -fn read_amd(card: &str, hwmon: &Option) -> Option { - let usage: u32 = fs::read_to_string(format!("{card}/gpu_busy_percent")) - .ok()? - .trim() - .parse() - .ok()?; - let vram_used: u64 = fs::read_to_string(format!("{card}/mem_info_vram_used")) - .ok()? - .trim() - .parse() - .ok()?; - let vram_total: u64 = fs::read_to_string(format!("{card}/mem_info_vram_total")) - .ok()? - .trim() - .parse() - .ok()?; - let temp_c = hwmon - .as_ref() - .and_then(|h| fs::read_to_string(format!("{h}/temp1_input")).ok()) - .and_then(|s| s.trim().parse::().ok()) - .map(|mc| mc / 1000) - .unwrap_or(0); - Some(GpuInfo { - usage, - vram_used_gb: vram_used as f64 / 1_073_741_824.0, - vram_total_gb: vram_total as f64 / 1_073_741_824.0, - temp_c, - vendor: "amd", - }) -} - -fn read_nvidia() -> Option { - let out = std::process::Command::new("nvidia-smi") - .args([ - "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu", - "--format=csv,noheader,nounits", - ]) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let s = String::from_utf8_lossy(&out.stdout); - let p: Vec<&str> = s.trim().split(',').map(str::trim).collect(); - if p.len() < 4 { - return None; - } - Some(GpuInfo { - usage: p[0].parse().ok()?, - vram_used_gb: p[1].parse::().ok()? / 1024.0, - vram_total_gb: p[2].parse::().ok()? / 1024.0, - temp_c: p[3].parse().ok()?, - vendor: "nvidia", - }) -} - -pub fn emit_gpu(out: &mut impl Write, backend: &GpuBackend) { - let info = match backend { - GpuBackend::Amd { - card_path, - hwmon_path, - } => read_amd(card_path, hwmon_path), - GpuBackend::Nvidia => read_nvidia(), - GpuBackend::None => return, - }; - if let Some(g) = info { - let _ = writeln!( - out, - "{{\"type\":\"gpu\",\"usage\":{},\"vram_used_gb\":{:.3},\"vram_total_gb\":{:.3},\"temp_c\":{},\"vendor\":\"{}\"}}", - g.usage, g.vram_used_gb, g.vram_total_gb, g.temp_c, g.vendor - ); - } -} diff --git a/stats-daemon/src/main.rs b/stats-daemon/src/main.rs index 572b864..838d32f 100644 --- a/stats-daemon/src/main.rs +++ b/stats-daemon/src/main.rs @@ -1,11 +1,186 @@ +use std::fs; use std::io::{self, Write}; use std::thread; use std::time::{Duration, Instant}; -mod cpu; -mod gpu; -mod mem; -mod temp; +struct Sample { + idle: u64, + total: u64, +} + +struct MemInfo { + percent: u64, + used_gb: f64, + total_gb: f64, + avail_gb: f64, + cached_gb: f64, + buffers_gb: f64, +} + +fn parse_stat(input: &str) -> Vec { + input + .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 parse_meminfo(input: &str) -> Option { + let mut total = 0u64; + let mut avail = 0u64; + let mut buffers = 0u64; + let mut cached = 0u64; + let mut sreclaimable = 0u64; + + for line in input.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 None; + } + + let used = total.saturating_sub(avail); + let cached_total = cached + sreclaimable; + let gb = |kb: u64| kb as f64 / 1_048_576.0; + + Some(MemInfo { + percent: used * 100 / total, + used_gb: gb(used), + total_gb: gb(total), + avail_gb: gb(avail), + cached_gb: gb(cached_total), + buffers_gb: gb(buffers), + }) +} + +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_stat() -> Vec { + parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default()) +} + +fn read_temp_celsius() -> Option { + let mut max: Option = None; + for i in 0.. { + let path = format!("/sys/class/thermal/thermal_zone{i}/temp"); + match fs::read_to_string(&path) { + Ok(s) => { + if let Ok(millic) = s.trim().parse::() { + let c = millic / 1000; + max = Some(max.map_or(c, |m: i32| m.max(c))); + } + } + Err(_) => break, + } + } + max +} + +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 n = core_usage.len().max(freqs.len()); + let _ = write!( + out, + "{{\"type\":\"cpu\",\"usage\":{usage},\"freq_ghz\":{avg_freq:.3},\"cores\":[" + ); + for i in 0..n { + if i > 0 { + let _ = write!(out, ","); + } + let u = core_usage.get(i).copied().unwrap_or(0); + let f = freqs.get(i).copied().unwrap_or(0.0); + let _ = write!(out, "{{\"usage\":{u},\"freq_ghz\":{f:.3}}}"); + } + let _ = writeln!(out, "]}}"); +} + +fn emit_temp(out: &mut impl Write) { + if let Some(c) = read_temp_celsius() { + let _ = writeln!(out, "{{\"type\":\"temp\",\"celsius\":{c}}}"); + } +} + +fn emit_mem(out: &mut impl Write) { + let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); + if let Some(m) = parse_meminfo(&content) { + let _ = writeln!( + out, + "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", + m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, + ); + } +} fn parse_interval_ms() -> u64 { let args: Vec = std::env::args().collect(); @@ -25,30 +200,23 @@ fn main() { let interval = Duration::from_millis(parse_interval_ms()); let stdout = io::stdout(); let mut out = io::BufWriter::new(stdout.lock()); - let mut prev: Vec = vec![]; + let mut prev: Vec = vec![]; let mut freqs: Vec = vec![]; - let gpu = gpu::detect_gpu(); let mut tick = 0u64; loop { let t0 = Instant::now(); - let curr = cpu::read_stat(); + let curr = read_stat(); if tick.is_multiple_of(2) { - freqs = cpu::read_core_freqs(); - mem::emit_mem(&mut out); + freqs = read_core_freqs(); + emit_mem(&mut out); } - cpu::emit_cpu(&mut out, &prev, &curr, &freqs); + emit_cpu(&mut out, &prev, &curr, &freqs); prev = curr; if tick.is_multiple_of(4) { - temp::emit_temp(&mut out); - // AMD sysfs is instant; NVIDIA calls nvidia-smi so runs less often - match &gpu { - gpu::GpuBackend::Amd { .. } => gpu::emit_gpu(&mut out, &gpu), - gpu::GpuBackend::Nvidia if tick.is_multiple_of(8) => gpu::emit_gpu(&mut out, &gpu), - _ => {} - } + emit_temp(&mut out); } let _ = out.flush(); @@ -60,3 +228,232 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── pct ────────────────────────────────────────────────────────────── + + #[test] + fn pct_zero_delta_returns_zero() { + let s = Sample { + idle: 100, + total: 400, + }; + assert_eq!(pct(&s, &s), 0); + } + + #[test] + fn pct_all_idle() { + let prev = Sample { + idle: 0, + total: 100, + }; + let curr = Sample { + idle: 100, + total: 200, + }; + assert_eq!(pct(&prev, &curr), 0); + } + + #[test] + fn pct_fully_busy() { + let prev = Sample { + idle: 100, + total: 200, + }; + let curr = Sample { + idle: 100, + total: 300, + }; + assert_eq!(pct(&prev, &curr), 100); + } + + #[test] + fn pct_half_busy() { + let prev = Sample { idle: 0, total: 0 }; + let curr = Sample { + idle: 50, + total: 100, + }; + assert_eq!(pct(&prev, &curr), 50); + } + + #[test] + fn pct_no_underflow_on_backwards_clock() { + let prev = Sample { + idle: 200, + total: 400, + }; + let curr = Sample { + idle: 100, + total: 300, + }; // idle went backwards + // dt=saturating 0, di=saturating 0 → returns 0 + assert_eq!(pct(&prev, &curr), 0); + } + + // ── parse_stat ─────────────────────────────────────────────────────── + + const STAT_SAMPLE: &str = "\ +cpu 100 10 50 700 40 0 0 0 0 0 +cpu0 50 5 25 350 20 0 0 0 0 0 +cpu1 50 5 25 350 20 0 0 0 0 0"; + + #[test] + fn parse_stat_count() { + // aggregate line + 2 cores = 3 samples + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples.len(), 3); + } + + #[test] + fn parse_stat_aggregate_idle() { + // idle=field[3], iowait=field[4] → 700+40=740 + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[0].idle, 740); + } + + #[test] + fn parse_stat_aggregate_total() { + // sum of all fields: 100+10+50+700+40 = 900 + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[0].total, 900); + } + + #[test] + fn parse_stat_per_core_idle() { + let samples = parse_stat(STAT_SAMPLE); + assert_eq!(samples[1].idle, 370); // 350+20 + assert_eq!(samples[2].idle, 370); + } + + #[test] + fn parse_stat_ignores_non_cpu_lines() { + let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0"; + let samples = parse_stat(input); + assert_eq!(samples.len(), 1); + } + + // ── parse_meminfo ──────────────────────────────────────────────────── + + const MEMINFO_SAMPLE: &str = "\ +MemTotal: 16384000 kB +MemFree: 2048000 kB +MemAvailable: 4096000 kB +Buffers: 512000 kB +Cached: 3072000 kB +SReclaimable: 512000 kB +SwapTotal: 8192000 kB +SwapFree: 8192000 kB"; + + #[test] + fn parse_meminfo_percent() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // used = total - avail = 16384000 - 4096000 = 12288000 + // percent = 12288000 * 100 / 16384000 = 75 + assert_eq!(m.percent, 75); + } + + #[test] + fn parse_meminfo_total_gb() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // 16384000 kB / 1048576 ≈ 15.625 GB + assert!((m.total_gb - 15.625).abs() < 0.001); + } + + #[test] + fn parse_meminfo_cached_includes_sreclaimable() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + // cached = 3072000 + 512000 = 3584000 kB + let expected = 3_584_000.0 / 1_048_576.0; + assert!((m.cached_gb - expected).abs() < 0.001); + } + + #[test] + fn parse_meminfo_zero_total_returns_none() { + assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); + } + + #[test] + fn parse_meminfo_empty_returns_none() { + assert!(parse_meminfo("").is_none()); + } + + // ── emit_cpu ───────────────────────────────────────────────────────── + + fn sample(idle: u64, total: u64) -> Sample { + Sample { idle, total } + } + + #[test] + fn emit_cpu_valid_json_structure() { + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(25, 100)]; + let freqs = vec![3.2, 3.1]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"type\":\"cpu\"")); + assert!(s.contains("\"usage\":")); + assert!(s.contains("\"freq_ghz\":")); + assert!(s.contains("\"cores\":")); + assert!(s.trim().ends_with('}')); + } + + #[test] + fn emit_cpu_correct_usage() { + // prev aggregate: idle=0, total=0 → curr: idle=50, total=100 → 50% busy + let prev = vec![sample(0, 0), sample(0, 0)]; + let curr = vec![sample(50, 100), sample(0, 0)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &prev, &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":50"), "got: {s}"); + } + + #[test] + fn emit_cpu_no_prev_gives_zero_usage() { + let curr = vec![sample(50, 100)]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &curr, &[]); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"usage\":0"), "got: {s}"); + } + + #[test] + fn emit_cpu_empty_curr_produces_no_output() { + let mut buf = Vec::new(); + emit_cpu(&mut buf, &[], &[], &[]); + assert!(buf.is_empty()); + } + + #[test] + fn emit_cpu_core_freqs_in_output() { + let curr = vec![sample(0, 100)]; + let freqs = vec![3.200, 2.900]; + let mut buf = Vec::new(); + emit_cpu(&mut buf, &curr, &curr, &freqs); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"freq_ghz\":3.200"), "got: {s}"); + assert!(s.contains("\"freq_ghz\":2.900"), "got: {s}"); + } + + // ── emit_mem (via parse_meminfo) ───────────────────────────────────── + + #[test] + fn emit_mem_valid_json_structure() { + let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); + let mut buf = Vec::new(); + let _ = writeln!( + &mut buf, + "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", + m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"type\":\"mem\"")); + assert!(s.contains("\"percent\":75")); + assert!(s.trim().ends_with('}')); + } +} diff --git a/stats-daemon/src/mem.rs b/stats-daemon/src/mem.rs deleted file mode 100644 index 36fc2ab..0000000 --- a/stats-daemon/src/mem.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::fs; -use std::io::Write; - -pub struct MemInfo { - pub percent: u64, - pub used_gb: f64, - pub total_gb: f64, - pub avail_gb: f64, - pub cached_gb: f64, - pub buffers_gb: f64, -} - -pub fn parse_meminfo(input: &str) -> Option { - let mut total = 0u64; - let mut avail = 0u64; - let mut buffers = 0u64; - let mut cached = 0u64; - let mut sreclaimable = 0u64; - - for line in input.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 None; - } - - let used = total.saturating_sub(avail); - let cached_total = cached + sreclaimable; - let gb = |kb: u64| kb as f64 / 1_048_576.0; - - Some(MemInfo { - percent: used * 100 / total, - used_gb: gb(used), - total_gb: gb(total), - avail_gb: gb(avail), - cached_gb: gb(cached_total), - buffers_gb: gb(buffers), - }) -} - -pub fn emit_mem(out: &mut impl Write) { - let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); - if let Some(m) = parse_meminfo(&content) { - let _ = writeln!( - out, - "{{\"type\":\"mem\",\"percent\":{},\"used_gb\":{:.3},\"total_gb\":{:.3},\"avail_gb\":{:.3},\"cached_gb\":{:.3},\"buffers_gb\":{:.3}}}", - m.percent, m.used_gb, m.total_gb, m.avail_gb, m.cached_gb, m.buffers_gb, - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const MEMINFO_SAMPLE: &str = "\ -MemTotal: 16384000 kB -MemFree: 2048000 kB -MemAvailable: 4096000 kB -Buffers: 512000 kB -Cached: 3072000 kB -SReclaimable: 512000 kB -SwapTotal: 8192000 kB -SwapFree: 8192000 kB"; - - #[test] - fn parse_meminfo_percent() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - assert_eq!(m.percent, 75); - } - - #[test] - fn parse_meminfo_total_gb() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - assert!((m.total_gb - 15.625).abs() < 0.001); - } - - #[test] - fn parse_meminfo_cached_includes_sreclaimable() { - let m = parse_meminfo(MEMINFO_SAMPLE).unwrap(); - let expected = 3_584_000.0 / 1_048_576.0; - assert!((m.cached_gb - expected).abs() < 0.001); - } - - #[test] - fn parse_meminfo_zero_total_returns_none() { - assert!(parse_meminfo("MemFree: 1000 kB\n").is_none()); - } - - #[test] - fn parse_meminfo_empty_returns_none() { - assert!(parse_meminfo("").is_none()); - } -} diff --git a/stats-daemon/src/temp.rs b/stats-daemon/src/temp.rs deleted file mode 100644 index 54beb3c..0000000 --- a/stats-daemon/src/temp.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::fs; -use std::io::Write; - -pub fn read_temp_celsius() -> Option { - let mut max: Option = None; - for i in 0.. { - let path = format!("/sys/class/thermal/thermal_zone{i}/temp"); - match fs::read_to_string(&path) { - Ok(s) => { - if let Ok(millic) = s.trim().parse::() { - let c = millic / 1000; - max = Some(max.map_or(c, |m: i32| m.max(c))); - } - } - Err(_) => break, - } - } - max -} - -pub fn emit_temp(out: &mut impl Write) { - if let Some(c) = read_temp_celsius() { - let _ = writeln!(out, "{{\"type\":\"temp\",\"celsius\":{c}}}"); - } -}