diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 09:34:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 09:35:05 +0200 |
| commit | cb936e93be75bae93fbd5a81f39376cd31142e71 (patch) | |
| tree | 89df0e88f53b87170c6f1e4e24236c68b87ee20e /desktop/modules/perf/Perf.qml | |
| parent | cf89a5d88fb738883e9663b9e44afcc7b7f98807 (diff) | |
| download | quickshell-cb936e93be75bae93fbd5a81f39376cd31142e71.tar.gz quickshell-cb936e93be75bae93fbd5a81f39376cd31142e71.zip | |
docs(spec): user details in the drawer
A drawer row greets the login user and opens a page that edits name,
email, avatar, language and location, written where other programs
already read them: AccountsService (and through it the passwd GECOS
LibreOffice reads), KDE's emaildefaults and the global git config, with
the surname shortened to an initial for git.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/perf/Perf.qml')
| -rw-r--r-- | desktop/modules/perf/Perf.qml | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/desktop/modules/perf/Perf.qml b/desktop/modules/perf/Perf.qml new file mode 100644 index 0000000..53d8670 --- /dev/null +++ b/desktop/modules/perf/Perf.qml @@ -0,0 +1,303 @@ +// 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 is any PerfPanel existing (island or drawer). +// /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the +// fdinfo scan, ip and curl are Processes. +Singleton { + id: root + + // PerfPanel instances alive; each counts itself in and out. + property int viewers: 0 + readonly property bool detailed: viewers > 0 + + // 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 + readonly property string gpuName: "ARC B580" + + // 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; + } + + // Measured from the last attempt, not the last success, so a failing + // fetch (rate limited, offline) still waits 30 minutes; and never over + // one already running, which would kill it before it could answer. + property real pubAttempt: 0 + function fetchPubip() { + const now = Date.now() / 1000; + if (pubProc.running || !P.due(root.pub, now, 1800) || now - root.pubAttempt < 1800) return; + root.pubAttempt = now; + pubProc.running = true; + } + + onDetailedChanged: { + if (detailed) { + if (!root.lan) lanProc.running = true; + 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); } + if (!vramProc.running) 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", "-k", "2", "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: 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", "-4fsS", "--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)); + } + } + } +} |
