Compare commits
No commits in common. "cf5581657ba2a9eb8fedb2f6f3468730a4b18f13" and "23c70619d86c24fcc9f4ef86510983aa0c9ebc43" have entirely different histories.
cf5581657b
...
23c70619d8
14 changed files with 497 additions and 936 deletions
|
|
@ -104,7 +104,7 @@ programs.nova-shell.modules = {
|
||||||
Each module is an object with `enable` (default `true`) and optional extra
|
Each module is an object with `enable` (default `true`) and optional extra
|
||||||
settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`,
|
settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`,
|
||||||
`notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`,
|
`notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`,
|
||||||
`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`,
|
`powerProfile`, `idleInhibitor`, `weather`, `temperature`, `cpu`, `memory`,
|
||||||
`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`.
|
`disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`.
|
||||||
|
|
||||||
### Theme
|
### Theme
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,6 @@ PanelWindow {
|
||||||
M.Memory {
|
M.Memory {
|
||||||
visible: M.Modules.memory.enable
|
visible: M.Modules.memory.enable
|
||||||
}
|
}
|
||||||
M.Gpu {}
|
|
||||||
M.Temperature {
|
M.Temperature {
|
||||||
visible: M.Modules.temperature.enable
|
visible: M.Modules.temperature.enable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
276
modules/Gpu.qml
276
modules/Gpu.qml
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,9 +58,6 @@ QtObject {
|
||||||
warm: 80,
|
warm: 80,
|
||||||
hot: 90
|
hot: 90
|
||||||
})
|
})
|
||||||
property var gpu: ({
|
|
||||||
enable: true
|
|
||||||
})
|
|
||||||
property var cpu: ({
|
property var cpu: ({
|
||||||
enable: true
|
enable: true
|
||||||
})
|
})
|
||||||
|
|
@ -97,7 +94,7 @@ QtObject {
|
||||||
|
|
||||||
// All module keys that have an enable flag — used to default-enable anything
|
// All module keys that have an enable flag — used to default-enable anything
|
||||||
// not explicitly mentioned in modules.json
|
// 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
|
// Fallback: if modules.json doesn't exist, enable everything
|
||||||
Component.onCompleted: _apply("{}")
|
Component.onCompleted: _apply("{}")
|
||||||
|
|
|
||||||
|
|
@ -125,29 +125,6 @@ Item {
|
||||||
anchors.topMargin: 8
|
anchors.topMargin: 8
|
||||||
spacing: 2
|
spacing: 2
|
||||||
|
|
||||||
// Text section — tappable for default action
|
|
||||||
Item {
|
|
||||||
id: _textSection
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: _textCol
|
|
||||||
width: parent.width
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
// App name + time row (optional)
|
// App name + time row (optional)
|
||||||
Row {
|
Row {
|
||||||
visible: root.showAppName
|
visible: root.showAppName
|
||||||
|
|
@ -220,17 +197,14 @@ Item {
|
||||||
maximumLineCount: root.bodyMaxLines
|
maximumLineCount: root.bodyMaxLines
|
||||||
visible: text !== ""
|
visible: text !== ""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action buttons — filter "default" (click-notification convention) and empty labels
|
// Action buttons
|
||||||
Row {
|
Row {
|
||||||
spacing: 6
|
spacing: 6
|
||||||
visible: _actionRepeater.count > 0
|
visible: !!(root.notif?.actions?.length)
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
id: _actionRepeater
|
model: root.notif?.actions ?? []
|
||||||
model: (root.notif?.actions ?? []).filter(a => a.text && a.identifier !== "default")
|
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: _actText.implicitWidth + 12
|
width: _actText.implicitWidth + 12
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,18 @@ M.HoverPanel {
|
||||||
|
|
||||||
// ---- Individual notification ----
|
// ---- 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 {
|
M.NotifCard {
|
||||||
id: _notifCard
|
id: _notifCard
|
||||||
visible: notifDelegate._type === "notif"
|
visible: notifDelegate._type === "notif"
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,6 @@ QtObject {
|
||||||
return d > 0 ? d + "d" : h + "h";
|
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() {
|
function beginDismiss() {
|
||||||
if (state === "visible")
|
if (state === "visible")
|
||||||
state = "dismissing";
|
state = "dismissing";
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,6 @@ QtObject {
|
||||||
property int tempCelsius: 0
|
property int tempCelsius: 0
|
||||||
property var tempHistory: [] // 150 samples @ 4s each ≈ 10 min
|
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 ───────────────────────────────────────────────────────────
|
// ── Memory ───────────────────────────────────────────────────────────
|
||||||
property int memPercent: 0
|
property int memPercent: 0
|
||||||
property real memUsedGb: 0
|
property real memUsedGb: 0
|
||||||
|
|
@ -95,15 +86,6 @@ QtObject {
|
||||||
root.tempCelsius = ev.celsius;
|
root.tempCelsius = ev.celsius;
|
||||||
const th = root.tempHistory.concat([ev.celsius]);
|
const th = root.tempHistory.concat([ev.celsius]);
|
||||||
root.tempHistory = th.length > 150 ? th.slice(th.length - 150) : th;
|
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") {
|
} else if (ev.type === "mem") {
|
||||||
root.memPercent = ev.percent;
|
root.memPercent = ev.percent;
|
||||||
root.memUsedGb = ev.used_gb;
|
root.memUsedGb = ev.used_gb;
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,6 @@ in
|
||||||
"bluetooth"
|
"bluetooth"
|
||||||
"network"
|
"network"
|
||||||
"powerProfile"
|
"powerProfile"
|
||||||
"gpu"
|
|
||||||
"cpu"
|
"cpu"
|
||||||
"memory"
|
"memory"
|
||||||
"idleInhibitor"
|
"idleInhibitor"
|
||||||
|
|
|
||||||
|
|
@ -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<Sample> {
|
|
||||||
input
|
|
||||||
.lines()
|
|
||||||
.filter(|l| l.starts_with("cpu"))
|
|
||||||
.map(|l| {
|
|
||||||
let vals: Vec<u64> = l
|
|
||||||
.split_whitespace()
|
|
||||||
.skip(1)
|
|
||||||
.filter_map(|s| s.parse().ok())
|
|
||||||
.collect();
|
|
||||||
let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0);
|
|
||||||
let total = vals.iter().sum();
|
|
||||||
Sample { idle, total }
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Sample> {
|
|
||||||
parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_core_freqs() -> Vec<f64> {
|
|
||||||
let mut freqs = Vec::new();
|
|
||||||
for i in 0.. {
|
|
||||||
let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq");
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(s) => match s.trim().parse::<u64>() {
|
|
||||||
Ok(khz) => freqs.push(khz as f64 / 1_000_000.0),
|
|
||||||
Err(_) => break,
|
|
||||||
},
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freqs
|
|
||||||
}
|
|
||||||
|
|
||||||
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<u32> = prev
|
|
||||||
.iter()
|
|
||||||
.skip(1)
|
|
||||||
.zip(curr.iter().skip(1))
|
|
||||||
.map(|(p, c)| pct(p, c))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let avg_freq = if freqs.is_empty() {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
freqs.iter().sum::<f64>() / freqs.len() as f64
|
|
||||||
};
|
|
||||||
|
|
||||||
let 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
},
|
|
||||||
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<String> {
|
|
||||||
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<String>) -> Option<GpuInfo> {
|
|
||||||
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::<i32>().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<GpuInfo> {
|
|
||||||
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::<f64>().ok()? / 1024.0,
|
|
||||||
vram_total_gb: p[2].parse::<f64>().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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,186 @@
|
||||||
|
use std::fs;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
mod cpu;
|
struct Sample {
|
||||||
mod gpu;
|
idle: u64,
|
||||||
mod mem;
|
total: u64,
|
||||||
mod temp;
|
}
|
||||||
|
|
||||||
|
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<Sample> {
|
||||||
|
input
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with("cpu"))
|
||||||
|
.map(|l| {
|
||||||
|
let vals: Vec<u64> = l
|
||||||
|
.split_whitespace()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|s| s.parse().ok())
|
||||||
|
.collect();
|
||||||
|
let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0);
|
||||||
|
let total = vals.iter().sum();
|
||||||
|
Sample { idle, total }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_meminfo(input: &str) -> Option<MemInfo> {
|
||||||
|
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<Sample> {
|
||||||
|
parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_temp_celsius() -> Option<i32> {
|
||||||
|
let mut max: Option<i32> = 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::<i32>() {
|
||||||
|
let c = millic / 1000;
|
||||||
|
max = Some(max.map_or(c, |m: i32| m.max(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
max
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_core_freqs() -> Vec<f64> {
|
||||||
|
let mut freqs = Vec::new();
|
||||||
|
for i in 0.. {
|
||||||
|
let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq");
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => match s.trim().parse::<u64>() {
|
||||||
|
Ok(khz) => freqs.push(khz as f64 / 1_000_000.0),
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freqs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_cpu(out: &mut impl Write, prev: &[Sample], curr: &[Sample], freqs: &[f64]) {
|
||||||
|
if curr.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage = prev
|
||||||
|
.first()
|
||||||
|
.zip(curr.first())
|
||||||
|
.map(|(p, c)| pct(p, c))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let core_usage: Vec<u32> = prev
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.zip(curr.iter().skip(1))
|
||||||
|
.map(|(p, c)| pct(p, c))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let avg_freq = if freqs.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
freqs.iter().sum::<f64>() / freqs.len() as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let 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 {
|
fn parse_interval_ms() -> u64 {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
@ -25,30 +200,23 @@ fn main() {
|
||||||
let interval = Duration::from_millis(parse_interval_ms());
|
let interval = Duration::from_millis(parse_interval_ms());
|
||||||
let stdout = io::stdout();
|
let stdout = io::stdout();
|
||||||
let mut out = io::BufWriter::new(stdout.lock());
|
let mut out = io::BufWriter::new(stdout.lock());
|
||||||
let mut prev: Vec<cpu::Sample> = vec![];
|
let mut prev: Vec<Sample> = vec![];
|
||||||
let mut freqs: Vec<f64> = vec![];
|
let mut freqs: Vec<f64> = vec![];
|
||||||
let gpu = gpu::detect_gpu();
|
|
||||||
let mut tick = 0u64;
|
let mut tick = 0u64;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
|
|
||||||
let curr = cpu::read_stat();
|
let curr = read_stat();
|
||||||
if tick.is_multiple_of(2) {
|
if tick.is_multiple_of(2) {
|
||||||
freqs = cpu::read_core_freqs();
|
freqs = read_core_freqs();
|
||||||
mem::emit_mem(&mut out);
|
emit_mem(&mut out);
|
||||||
}
|
}
|
||||||
cpu::emit_cpu(&mut out, &prev, &curr, &freqs);
|
emit_cpu(&mut out, &prev, &curr, &freqs);
|
||||||
prev = curr;
|
prev = curr;
|
||||||
|
|
||||||
if tick.is_multiple_of(4) {
|
if tick.is_multiple_of(4) {
|
||||||
temp::emit_temp(&mut out);
|
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();
|
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('}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<MemInfo> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
pub fn read_temp_celsius() -> Option<i32> {
|
|
||||||
let mut max: Option<i32> = 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::<i32>() {
|
|
||||||
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}}}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue