aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/perf/Perf.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-13 11:24:06 +0200
committerDanilo M. <danix@danix.xyz>2026-10-13 11:24:06 +0200
commit05c204284c135ae5ec6e00bfa997e188e6b6fbfb (patch)
tree484e984f0dfe7f4cd7347cca955520db1f9dd050 /desktop/island/perf/Perf.qml
parent215458d3fcc0bc57e5c2c7245f09bd108a7b461a (diff)
downloadquickshell-05c204284c135ae5ec6e00bfa997e188e6b6fbfb.tar.gz
quickshell-05c204284c135ae5ec6e00bfa997e188e6b6fbfb.zip
feat(perf): the performance service
CPU, RAM, temperatures and disks are sampled always, so the island's icon can warn with the panel closed; per-core load, the network rate, VRAM and the GPU fan only while the panel is open. hwmon chips are found by name, since their numbers move across boots. df runs under timeout so a dead NFS server keeps the last rows rather than blocking. The public IP keeps conky's cache file and 30 minute pace. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/island/perf/Perf.qml')
-rw-r--r--desktop/island/perf/Perf.qml295
1 files changed, 295 insertions, 0 deletions
diff --git a/desktop/island/perf/Perf.qml b/desktop/island/perf/Perf.qml
new file mode 100644
index 0000000..c2843f6
--- /dev/null
+++ b/desktop/island/perf/Perf.qml
@@ -0,0 +1,295 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQml
+import "../.."
+import "Perf.js" as P
+
+// The numbers behind the performance panel and the status pill's icon. CPU,
+// RAM, temperatures and disks are sampled always, so the icon can warn while
+// the panel is closed; per-core load, the network rate, VRAM and the fan only
+// while `detailed`, which the status pill binds to the panel being open.
+// /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the
+// fdinfo scan, ip and curl are Processes.
+Singleton {
+ id: root
+
+ property bool detailed: false
+
+ // ponytail: host constants, as conky had them. One mount per NFS server
+ // (Library and Slackware are one export, so are shared and backup); br0
+ // is a bridge, so VM-to-host copies show in the graph; xe puts no VRAM
+ // capacity in sysfs. Edit here when the hardware changes.
+ readonly property var mounts: ["/", "/home", "/data", "/mnt/nfs/Library", "/mnt/nfs/shared"]
+ readonly property string iface: "br0"
+ readonly property real vramTotal: 12 * 1024 * 1024 * 1024
+
+ // Conky's table. Ceilings are per sensor: 70C is unremarkable on a CPU
+ // package and alarming on an NVMe. GPU and VRAM are the GPU card's; the
+ // rest are the CPU card's rows.
+ readonly property var sensors: [
+ { chip: "k10temp", file: "temp1_input", label: "CPU", warn: 75, crit: 90 },
+ { chip: "k10temp", file: "temp3_input", label: "CCD1", warn: 75, crit: 90 },
+ { chip: "nvme", file: "temp1_input", label: "NVME", warn: 60, crit: 70 },
+ { chip: "gigabyte_wmi", file: "temp2_input", label: "BOARD", warn: 60, crit: 70 },
+ { chip: "gigabyte_wmi", file: "temp3_input", label: "BOARD", warn: 60, crit: 70 },
+ { chip: "xe", file: "temp2_input", label: "GPU", warn: 75, crit: 85 },
+ { chip: "xe", file: "temp3_input", label: "VRAM", warn: 80, crit: 90 },
+ ]
+
+ property var hwmon: ({}) // chip name -> /sys/class/hwmon/hwmonN
+ property string cpuModel: ""
+ property var load: null // aggregate percent
+ property var cores: [] // percent per core, while detailed
+ property var mem: null // {total, used} bytes
+ property var temps: [] // [{label, value, warn, crit}], value null if unread
+ property var disks: [] // Perf.js df rows
+ property var vram: null // bytes, while detailed
+ property var fan: null // rpm, while detailed
+ property var rx: [] // bytes/s, newest last, while detailed
+ property var tx: []
+ property string lan: ""
+ property var pub: null // {ip, fetched}
+ property real now: Date.now() / 1000
+
+ readonly property var ip: P.ipCheck(root.pub, root.now, Prefs.pinnedIp)
+
+ // The icon's dot. CPU load is left out: spikes would flicker it, and
+ // sustained load shows up as heat.
+ readonly property string severity: P.worst(
+ root.temps.map(t => P.band(t.value, t.warn, t.crit))
+ .concat([P.band(root.mem ? 100 * root.mem.used / root.mem.total : null, 85, 95)])
+ .concat(root.disks.map(d => P.band(d.pct, 90, 95)))
+ .concat([root.ip.mismatch ? "crit" : "ok"]))
+
+ readonly property string pubipPath:
+ (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/udt/pubip.txt"
+
+ property var prevCpu: []
+ property var prevNet: null // {rx, tx, t}
+
+ function num(view) {
+ const v = parseInt(view.text(), 10);
+ return isNaN(v) ? null : v;
+ }
+
+ function fetchPubip() {
+ pubProc.running = false;
+ pubProc.running = true;
+ }
+
+ onDetailedChanged: {
+ if (detailed) {
+ if (!root.lan) lanProc.running = true;
+ if (P.due(root.pub, Date.now() / 1000, 1800)) root.fetchPubip();
+ } else {
+ root.cores = [];
+ root.rx = [];
+ root.tx = [];
+ root.prevNet = null;
+ root.vram = null;
+ root.fan = null;
+ }
+ }
+
+ // --- /proc and /sys ---------------------------------------------------
+
+ FileView { id: stat; path: "/proc/stat"; blockLoading: true }
+ FileView { id: meminfo; path: "/proc/meminfo"; blockLoading: true }
+ FileView {
+ path: "/proc/cpuinfo"
+ blockLoading: true
+ onLoaded: root.cpuModel = P.cpuModel(text())
+ }
+
+ Instantiator {
+ id: tempViews
+ model: root.sensors
+ delegate: FileView {
+ required property var modelData
+ path: root.hwmon[modelData.chip] ? root.hwmon[modelData.chip] + "/" + modelData.file : ""
+ blockLoading: true
+ printErrors: false
+ }
+ }
+
+ FileView {
+ id: fanView
+ path: root.hwmon.xe ? root.hwmon.xe + "/fan1_input" : ""
+ blockLoading: true
+ printErrors: false
+ }
+
+ FileView {
+ id: rxView
+ path: "/sys/class/net/" + root.iface + "/statistics/rx_bytes"
+ blockLoading: true
+ printErrors: false
+ }
+
+ FileView {
+ id: txView
+ path: "/sys/class/net/" + root.iface + "/statistics/tx_bytes"
+ blockLoading: true
+ printErrors: false
+ }
+
+ Process {
+ running: true
+ command: ["sh", "-c", "grep -H . /sys/class/hwmon/hwmon*/name"]
+ stdout: StdioCollector { onStreamFinished: root.hwmon = P.hwmonMap(text) }
+ }
+
+ // CPU, RAM and temperatures always; the fan and VRAM while detailed.
+ Timer {
+ interval: 2000
+ running: true
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ root.now = Date.now() / 1000;
+
+ stat.reload();
+ const cur = P.cpuTimes(stat.text());
+ root.load = P.load(root.prevCpu[0], cur[0]);
+ root.cores = root.detailed
+ ? cur.slice(1).map((c, i) => P.load(root.prevCpu[i + 1], c) ?? 0)
+ : [];
+ root.prevCpu = cur;
+
+ meminfo.reload();
+ root.mem = P.meminfo(meminfo.text());
+
+ const t = [];
+ for (let i = 0; i < root.sensors.length; i++) {
+ const s = root.sensors[i];
+ const v = tempViews.objectAt(i);
+ let value = null;
+ if (v && v.path) {
+ v.reload();
+ const n = root.num(v);
+ value = n === null ? null : n / 1000;
+ }
+ t.push({ label: s.label, value: value, warn: s.warn, crit: s.crit });
+ }
+ root.temps = t;
+
+ if (root.detailed) {
+ if (fanView.path) { fanView.reload(); root.fan = root.num(fanView); }
+ vramProc.running = false;
+ vramProc.running = true;
+ }
+ }
+ }
+
+ // ponytail: a shell glob over every fd on the system, ~14ms; only while
+ // the panel is open. Only the user's own processes are readable, so a
+ // system service's GPU use is not counted, as in conky.
+ Process {
+ id: vramProc
+ command: ["sh", "-c", "grep -sH -e '^drm-driver' -e '^drm-client-id' "
+ + "-e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null"]
+ stdout: StdioCollector { onStreamFinished: root.vram = P.vram(text) }
+ }
+
+ // --- network ------------------------------------------------------------
+
+ Timer {
+ interval: 1000
+ running: root.detailed
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ rxView.reload();
+ txView.reload();
+ const cur = { rx: root.num(rxView), tx: root.num(txView), t: Date.now() / 1000 };
+ const p = root.prevNet;
+ if (p) {
+ const r = P.rate(p.rx, cur.rx, cur.t - p.t);
+ const s = P.rate(p.tx, cur.tx, cur.t - p.t);
+ if (r !== null) root.rx = P.push(root.rx, r, 60);
+ if (s !== null) root.tx = P.push(root.tx, s, 60);
+ }
+ root.prevNet = cur;
+ }
+ }
+
+ Process {
+ id: lanProc
+ command: ["ip", "-4", "-o", "addr", "show", root.iface]
+ stdout: StdioCollector { onStreamFinished: root.lan = P.lanAddr(text) }
+ }
+
+ // --- disks --------------------------------------------------------------
+
+ // df under timeout: an unreachable NFS server blocks statfs, and the
+ // whole call then yields nothing, so the last rows are kept.
+ Timer {
+ interval: 60000
+ running: true
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: { dfProc.running = false; dfProc.running = true; }
+ }
+
+ Process {
+ id: dfProc
+ command: ["timeout", "5", "/usr/bin/df", "-P", "-B1"].concat(root.mounts)
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const rows = P.df(text);
+ if (rows.length) root.disks = rows;
+ }
+ }
+ }
+
+ // --- public IP ----------------------------------------------------------
+
+ // conky's cache file, so the address survives a restart and the fetch
+ // keeps conky's 30 minute pace. Checked every minute, fetched when due.
+ FileView {
+ id: pubFile
+ path: root.pubipPath
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: root.pub = P.parsePubip(text())
+ }
+
+ Timer {
+ interval: 60000
+ running: true
+ repeat: true
+ onTriggered: if (P.due(root.pub, Date.now() / 1000, 1800)) root.fetchPubip()
+ }
+
+ // Anything but an IPv4 address (a failure, an error page) leaves the
+ // cache alone: the old value then goes stale rather than mismatching.
+ Process {
+ id: pubProc
+ command: ["curl", "-fsS", "--max-time", "10", "https://ipinfo.io/ip"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const ip = text.trim();
+ if (!P.isIPv4(ip)) return;
+ const at = Math.floor(Date.now() / 1000);
+ root.pub = { ip: ip, fetched: at };
+ pubFile.setText(P.formatPubip(ip, at));
+ }
+ }
+ }
+}