diff --git a/README.md b/README.md index 7324f9a..cab99da 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`, `cpu`, `memory`, +`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`, `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`. ### Theme diff --git a/modules/Bar.qml b/modules/Bar.qml index 809edad..f8318a6 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -207,6 +207,7 @@ 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 new file mode 100644 index 0000000..5f43c4b --- /dev/null +++ b/modules/Gpu.qml @@ -0,0 +1,276 @@ +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 70a2d5d..4a0053e 100644 --- a/modules/Modules.qml +++ b/modules/Modules.qml @@ -58,6 +58,9 @@ QtObject { warm: 80, hot: 90 }) + property var gpu: ({ + enable: true + }) property var cpu: ({ enable: true }) @@ -94,7 +97,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", "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", "gpu", "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 e908572..eb77208 100644 --- a/modules/NotifCard.qml +++ b/modules/NotifCard.qml @@ -125,86 +125,112 @@ Item { anchors.topMargin: 8 spacing: 2 - // App name + time row (optional) - Row { - visible: root.showAppName + // Text section — tappable for default action + Item { + id: _textSection width: parent.width + height: _textCol.implicitHeight + implicitHeight: _textCol.implicitHeight - 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 + 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 { - id: _timeText - text: root.notif?.timeStr ?? "" - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily + 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 !== "" + } } } - // 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 + // Action buttons — filter "default" (click-notification convention) and empty labels Row { spacing: 6 - visible: !!(root.notif?.actions?.length) + visible: _actionRepeater.count > 0 Repeater { - model: root.notif?.actions ?? [] + id: _actionRepeater + model: (root.notif?.actions ?? []).filter(a => a.text && a.identifier !== "default") delegate: Rectangle { required property var modelData width: _actText.implicitWidth + 12 diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index a8f72aa..40c21a3 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -411,18 +411,6 @@ 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 1f5199f..d3e96b9 100644 --- a/modules/NotifItem.qml +++ b/modules/NotifItem.qml @@ -42,6 +42,14 @@ 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 9ee7adf..a668be4 100644 --- a/modules/SystemStats.qml +++ b/modules/SystemStats.qml @@ -34,6 +34,15 @@ 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 @@ -86,6 +95,15 @@ 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 33cbc0f..7911c9b 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -87,6 +87,7 @@ in "bluetooth" "network" "powerProfile" + "gpu" "cpu" "memory" "idleInhibitor" diff --git a/stats-daemon/src/cpu.rs b/stats-daemon/src/cpu.rs new file mode 100644 index 0000000..34c8863 --- /dev/null +++ b/stats-daemon/src/cpu.rs @@ -0,0 +1,250 @@ +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 new file mode 100644 index 0000000..3c2a989 --- /dev/null +++ b/stats-daemon/src/gpu.rs @@ -0,0 +1,128 @@ +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 838d32f..572b864 100644 --- a/stats-daemon/src/main.rs +++ b/stats-daemon/src/main.rs @@ -1,186 +1,11 @@ -use std::fs; use std::io::{self, Write}; use std::thread; use std::time::{Duration, Instant}; -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, - ); - } -} +mod cpu; +mod gpu; +mod mem; +mod temp; fn parse_interval_ms() -> u64 { let args: Vec = std::env::args().collect(); @@ -200,23 +25,30 @@ 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 = read_stat(); + let curr = cpu::read_stat(); if tick.is_multiple_of(2) { - freqs = read_core_freqs(); - emit_mem(&mut out); + freqs = cpu::read_core_freqs(); + mem::emit_mem(&mut out); } - emit_cpu(&mut out, &prev, &curr, &freqs); + cpu::emit_cpu(&mut out, &prev, &curr, &freqs); prev = curr; if tick.is_multiple_of(4) { - emit_temp(&mut out); + 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), + _ => {} + } } let _ = out.flush(); @@ -228,232 +60,3 @@ 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 new file mode 100644 index 0000000..36fc2ab --- /dev/null +++ b/stats-daemon/src/mem.rs @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..54beb3c --- /dev/null +++ b/stats-daemon/src/temp.rs @@ -0,0 +1,25 @@ +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}}}"); + } +}