From ecf306d043b62c1646a95027b4e2144be7c69178 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 09:50:42 +0200 Subject: feat(perf): the performance page moves to the drawer, with ~/.cache The island's performance icon and panel are gone: the drawer page covers them. With only one host left, the panel drops its three-column grid for one card per row at a larger size, since the page scrolls; temperatures get a card of their own as tiles. The files move from island/perf to modules/perf. A cache card ports conky's: ~/.cache and its five largest children, measured with du each time the page opens (28ms over 2 GiB here) rather than read from conky's 15-minute sampler file. The largest is yellow, red past half the total, as conky had it. The warning dot now shows only on the drawer tile. Co-Authored-By: Claude Opus 5.5 --- desktop/README.md | 2 +- desktop/island/Island.qml | 7 +- desktop/island/README.md | 40 +--------- desktop/island/StatusPill.qml | 57 ++------------ desktop/modules/perf/Perf.js | 14 ++++ desktop/modules/perf/Perf.qml | 2 +- desktop/modules/perf/PerfModule.qml | 8 +- desktop/modules/perf/PerfPanel.qml | 153 +++++++++++++++++++++++------------- desktop/modules/perf/README.md | 36 +++++++++ desktop/modules/perf/test_perf.js | 10 ++- 10 files changed, 174 insertions(+), 155 deletions(-) create mode 100644 desktop/modules/perf/README.md (limited to 'desktop') diff --git a/desktop/README.md b/desktop/README.md index b0d3a2a..be22c48 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -33,7 +33,7 @@ the IPC layer rejects. The page-less grid entry is therefore the zero-argument modules/vm/ libvirt state, live stats, snapshots modules/appearance/ a tile that opens the separate appearance shell modules/ai/ local AI engines, start/stop for the apps using them - modules/perf/ the island's performance panel at drawer size + modules/perf/ CPU, temperatures, GPU, network, disks, ~/.cache modules/clipboard/ cliphist history: search, copy, delete, clear all Sound, mail, vm, network, bluetooth, kdeconnect and ai each carry their own README. diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml index 1807c82..8532652 100644 --- a/desktop/island/Island.qml +++ b/desktop/island/Island.qml @@ -28,8 +28,8 @@ PanelWindow { screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] anchors { top: true; left: true; right: true } - // Tall enough for the largest panel, the performance grid; the mask keeps - // everything outside the pills click-through. + // Tall enough for the largest panel; the mask keeps everything outside + // the pills click-through. implicitHeight: 480 color: "transparent" exclusionMode: ExclusionMode.Ignore @@ -38,7 +38,7 @@ PanelWindow { WlrLayershell.namespace: "quickshell-island" WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - // "", "sound", "perf" or "calendar". + // "", "sound" or "calendar". property string panel: "" // --- reveal and hide ------------------------------------------------- @@ -212,6 +212,5 @@ PanelWindow { onMenuShown: win.menuHeld = true onOpenSound: win.panel = win.panel === "sound" ? "" : "sound" onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); } - onOpenPerf: win.panel = win.panel === "perf" ? "" : "perf" } } diff --git a/desktop/island/README.md b/desktop/island/README.md index 4ad1050..c202e61 100644 --- a/desktop/island/README.md +++ b/desktop/island/README.md @@ -46,10 +46,10 @@ is open or not. Click opens the calendar panel. ## Status pill -Left to right: performance icon, volume icon, tray, layout flag. +Left to right: volume icon, tray, layout flag. The performance panel that +used to open from here is the drawer's Performance page now +(`desktop/modules/perf/`). -- **Performance icon** opens the performance panel, see below. A dot on its - corner warns when something needs attention. - **Volume icon** shows the default sink (muted, low, medium, high). Click opens the sound panel. - **Tray** shows `SystemTray` items. Left click activates, right click opens @@ -68,40 +68,6 @@ alone opens the drawer instead. `Prefs.qml` keeps only the pinned IP; a `controls` key left in `desktop.json` by the old panel is kept on write and otherwise ignored. -## Performance panel - -The system monitor icon, first in the status pill, opens a card grid of the -figures the conky dashboard used to show: CPU load with a bar per core, the -CPU model, RAM and five temperatures; the Arc B580's temperatures, VRAM and -fan (no utilisation, xe exposes none); download and upload over the last -minute, the LAN address and the public IP; and the five mounts with their -usage. `perf/Perf.qml` reads them; `perf/Perf.js` holds the parsing, tested by -`node desktop/island/perf/test_perf.js`. - -A dot on the icon shows the worst of these, yellow or red: - -| Input | Yellow | Red | -| --- | --- | --- | -| each temperature | its warn | its crit | -| RAM | 85% | 95% | -| each disk | 90% | 95% | -| public IP | | differs from the pinned one | - -CPU load is left out, since spikes would flicker it. There is no hysteresis, -so a sensor sitting on its threshold can toggle the dot. The pin beside the -public IP stores the address shown as `perf.pinnedIp` in -`$XDG_STATE_HOME/quickshell/desktop.json`; the check exists because the VPN -into the LAN depends on the ISP's fixed address. The fetch is -`curl -4 https://ipinfo.io/ip` every 30 minutes from the last attempt, into -`~/.cache/udt/pubip.txt`, the file conky's sampler used. A failed fetch is -never a mismatch: the old value goes stale after 4 hours and shows as a dash. - -CPU, RAM, temperatures and disks are sampled always, so the dot works with -the panel closed. Per-core load, the network rate, VRAM (a scan of every -fdinfo, ~14ms) and the fan run only while the panel is open, and the panel -itself is loaded only then. The mount list, `br0`, the GPU name and the -12 GiB VRAM size are constants in `Perf.qml`. - ## Calendar panel The clock pill opens a month grid beside the selected day's agenda. Dots diff --git a/desktop/island/StatusPill.qml b/desktop/island/StatusPill.qml index 953b713..2202747 100644 --- a/desktop/island/StatusPill.qml +++ b/desktop/island/StatusPill.qml @@ -14,13 +14,11 @@ import Quickshell.Widgets import Quickshell.Services.SystemTray import QtQuick import ".." -import "perf" import "Logic.js" as Logic -// Performance icon, volume icon, tray, layout flag. The performance and -// volume icons open their panels in this capsule; -// the tray is a plain tray; the flag switches layout on scroll and ignores -// clicks. +// Volume icon, tray, layout flag. The volume icon opens its panel in this +// capsule; the tray is a plain tray; the flag switches layout on scroll and +// ignores clicks. Capsule { id: pill @@ -30,16 +28,14 @@ Capsule { property string panel: "" signal openSound() - signal openPerf() signal openDrawer() // Emitted just before a tray menu opens; the menu cannot report its close. signal menuShown() - expanded: panel === "sound" || panel === "perf" + expanded: panel === "sound" restWidth: restRow.implicitWidth + 24 - panelWidth: panel === "perf" ? 600 : 320 - panelHeight: (panel === "perf" ? (perfPanel.item ? perfPanel.item.implicitHeight : 0) - : soundPanel.implicitHeight) + 28 + panelWidth: 320 + panelHeight: soundPanel.implicitHeight + 28 Row { @@ -50,36 +46,6 @@ Capsule { visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: 120 } } - // The worst severity as a dot on the corner: IconImage cannot be - // recoloured without an effect layer. Never the accent (AGENTS.md). - Item { - anchors.verticalCenter: parent.verticalCenter - width: 18 - height: 18 - - IconImage { - anchors.fill: parent - source: Quickshell.iconPath("utilities-system-monitor", true) - } - - Rectangle { - anchors { right: parent.right; top: parent.top; rightMargin: -2; topMargin: -2 } - width: 8 - height: 8 - radius: 4 - visible: Perf.severity !== "ok" - color: Perf.severity === "crit" ? Theme.red : Theme.yellow - border.width: 1 - border.color: Theme.base - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: pill.openPerf() - } - } - IconImage { anchors.verticalCenter: parent.verticalCenter implicitSize: 18 @@ -156,15 +122,4 @@ Capsule { Behavior on opacity { NumberAnimation { duration: 160 } } onOpenDrawer: pill.openDrawer() } - - // Loaded only while open: its models are fresh arrays every sample, and - // a hidden instance would rebuild its rows every 2s for the whole session. - Loader { - id: perfPanel - x: 14 - y: 14 - width: 600 - 28 - active: pill.panel === "perf" - sourceComponent: PerfPanel { width: 600 - 28 } - } } diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js index b853f50..d84c694 100644 --- a/desktop/modules/perf/Perf.js +++ b/desktop/modules/perf/Perf.js @@ -72,6 +72,20 @@ function df(text) { return out; } +// `du -B1 -d1 `: {total, items} with items largest first by basename, +// or null when du printed nothing. du prints the directory itself last, and +// it is also the largest line, so the total is the maximum. +function du(text) { + const rows = []; + for (const line of text.split("\n")) { + const m = /^(\d+)\t(.*)$/.exec(line); + if (m) rows.push({ name: m[2].replace(/.*\//, ""), bytes: Number(m[1]) }); + } + if (!rows.length) return null; + rows.sort((x, y) => y.bytes - x.bytes); + return { total: rows[0].bytes, items: rows.slice(1) }; +} + // VRAM in bytes from the fdinfo scan: drm-resident-vram0 of every xe fd, // counted once per drm-client-id because a client holds several fds. function vram(text) { diff --git a/desktop/modules/perf/Perf.qml b/desktop/modules/perf/Perf.qml index 53d8670..6216959 100644 --- a/desktop/modules/perf/Perf.qml +++ b/desktop/modules/perf/Perf.qml @@ -21,7 +21,7 @@ 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). +// while `detailed`, which is any PerfPanel existing (the drawer page). // /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the // fdinfo scan, ip and curl are Processes. Singleton { diff --git a/desktop/modules/perf/PerfModule.qml b/desktop/modules/perf/PerfModule.qml index d0ddf35..e596f9a 100644 --- a/desktop/modules/perf/PerfModule.qml +++ b/desktop/modules/perf/PerfModule.qml @@ -11,11 +11,9 @@ import QtQuick import "../.." -import "../../island/perf" -// The island's performance panel at drawer size. Perf.qml stays in -// island/perf and is shared: its always-on sampling already runs for the -// island's warning dot, so this module adds no service of its own. +// The figures the conky dashboard used to show, as a scrolling page. Perf.qml +// samples them; the tile's dot is its severity. Module { id: mod @@ -38,7 +36,7 @@ Module { + " · RAM " + (Perf.mem ? Math.round(100 * Perf.mem.used / Perf.mem.total) + "%" : "--") } - // The same dot as the island icon. Never the accent (AGENTS.md). + // The worst severity. Never the accent (AGENTS.md). Rectangle { anchors.verticalCenter: parent.verticalCenter width: 8 diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml index f149d82..de9021b 100644 --- a/desktop/modules/perf/PerfPanel.qml +++ b/desktop/modules/perf/PerfPanel.qml @@ -10,24 +10,21 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import QtQuick -import QtQuick.Layouts import "../.." import "Perf.js" as P -// CPU, GPU, network and disks as cards in three columns: CPU down the left, -// GPU and network beside it, disks under those two. Numbers come from Perf; -// colours follow conky's bands, so they read as they did there. -GridLayout { +// CPU, temperatures, GPU, network, disks and ~/.cache as cards, one per row: +// the drawer page scrolls, so nothing has to share a line. Numbers come from +// Perf; colours follow conky's bands, so they read as they did there. +Column { id: panel - columns: 3 - columnSpacing: 8 - rowSpacing: 8 + spacing: 12 - readonly property real col: (width - 2 * columnSpacing) / 3 + readonly property int fs: Theme.fontSize - 2 readonly property var gpuTemp: sensor("GPU") - readonly property var vramTemp: sensor("VRAM") function tone(v, warn, crit) { const b = P.band(v, warn, crit); @@ -37,10 +34,8 @@ GridLayout { function sensor(label) { return Perf.temps.find(t => t.label === label) || { value: null, warn: 0, crit: 0 }; } - // Detailed sampling (per-core, network, VRAM, fan) runs while any panel - // exists. The island loads one only while open and the drawer page only - // while shown, so counting instances is the whole gate, and two of them - // never clear it under each other. + // Detailed sampling (per-core, network, VRAM, fan) runs while the panel + // exists, which is while the drawer page is shown. Component.onCompleted: Perf.viewers++ Component.onDestruction: Perf.viewers-- @@ -53,7 +48,7 @@ GridLayout { } component Label: Text { - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + font { family: Theme.fontFamily; pixelSize: panel.fs } color: Theme.text } @@ -72,7 +67,7 @@ GridLayout { Text { visible: text !== "" text: parent.parent.glyph - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + font { family: Theme.iconFamily; pixelSize: panel.fs + 2 } color: parent.parent.labelColor } Label { text: parent.parent.label; color: parent.parent.labelColor } @@ -89,7 +84,7 @@ GridLayout { property real frac: 0 property color fill: Theme.green width: parent ? parent.width : 0 - height: 5 + height: 6 radius: 3 color: Theme.surfaceAlt Rectangle { @@ -105,17 +100,17 @@ GridLayout { property string title: "" property string headline: "" property color headlineColor: Theme.text + width: panel.width radius: 10 color: Qt.alpha(Theme.surface, 0.6) - implicitHeight: body.implicitHeight + 20 - Layout.fillHeight: true + implicitHeight: body.implicitHeight + 28 Column { id: body - x: 10 - y: 10 - width: parent.width - 20 - spacing: 5 + x: 14 + y: 14 + width: parent.width - 28 + spacing: 8 Item { width: parent.width @@ -124,7 +119,7 @@ GridLayout { id: titleText anchors.verticalCenter: parent.verticalCenter text: parent.parent.parent.title - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + font { family: Theme.fontFamily; pixelSize: panel.fs; bold: true } color: Theme.subtext } Text { @@ -141,10 +136,6 @@ GridLayout { // --- CPU ---------------------------------------------------------------- Card { - Layout.row: 0 - Layout.column: 0 - Layout.rowSpan: 2 - Layout.preferredWidth: panel.col title: "CPU" headline: Perf.load === null ? "--" : Math.round(Perf.load) + "%" headlineColor: panel.tone(Perf.load, 25, 75) @@ -160,7 +151,7 @@ GridLayout { Row { id: eq width: parent.width - height: 40 + height: 64 spacing: 2 Repeater { model: Perf.cores @@ -187,14 +178,42 @@ GridLayout { frac: Perf.mem ? Perf.mem.used / Perf.mem.total : 0 fill: panel.tone(panel.pct(Perf.mem ? Perf.mem.used : null, Perf.mem ? Perf.mem.total : 0), 25, 75) } + } - Repeater { - model: Perf.temps.filter(t => t.label !== "GPU" && t.label !== "VRAM") - delegate: Pair { - required property var modelData - label: modelData.label - value: panel.deg(modelData.value) - valueColor: panel.tone(modelData.value, modelData.warn, modelData.crit) + // --- temperatures ------------------------------------------------------- + + Card { + title: "TEMPERATURES" + + Flow { + id: temps + width: parent.width + spacing: 8 + Repeater { + model: Perf.temps + delegate: Rectangle { + required property var modelData + width: (temps.width - 3 * temps.spacing) / 4 + height: tempCol.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surfaceAlt, 0.5) + Column { + id: tempCol + anchors.centerIn: parent + spacing: 2 + Label { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + color: Theme.subtext + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: panel.deg(modelData.value) + font { family: Theme.fontFamily; pixelSize: panel.fs + 6; bold: true } + color: panel.tone(modelData.value, modelData.warn, modelData.crit) + } + } + } } } } @@ -203,9 +222,6 @@ GridLayout { // No utilisation: xe exposes no busy percentage. Card { - Layout.row: 0 - Layout.column: 1 - Layout.preferredWidth: panel.col title: "GPU · " + Perf.gpuName headline: panel.deg(panel.gpuTemp.value) headlineColor: panel.tone(panel.gpuTemp.value, panel.gpuTemp.warn, panel.gpuTemp.crit) @@ -218,11 +234,6 @@ GridLayout { frac: (Perf.vram ?? 0) / Perf.vramTotal fill: panel.tone(panel.pct(Perf.vram, Perf.vramTotal), 25, 75) } - Pair { - label: "VRAM temp" - value: panel.deg(panel.vramTemp.value) - valueColor: panel.tone(panel.vramTemp.value, panel.vramTemp.warn, panel.vramTemp.crit) - } Pair { label: "Fan" value: Perf.fan === null ? "--" : Perf.fan + " rpm" @@ -232,15 +243,12 @@ GridLayout { // --- network ------------------------------------------------------------ Card { - Layout.row: 0 - Layout.column: 2 - Layout.preferredWidth: panel.col title: "NET · " + Perf.iface Canvas { id: graph width: parent.width - height: 44 + height: 80 onPaint: { const ctx = getContext("2d"); ctx.reset(); @@ -291,7 +299,7 @@ GridLayout { id: pin anchors { right: parent.right; verticalCenter: pubText.verticalCenter } text: String.fromCodePoint(0xF0403) - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + font { family: Theme.iconFamily; pixelSize: panel.fs + 2 } color: Perf.ip.ip !== "" && Perf.ip.ip === Prefs.pinnedIp ? Theme.text : Theme.overlay MouseArea { @@ -308,10 +316,6 @@ GridLayout { // --- disks -------------------------------------------------------------- Card { - Layout.row: 1 - Layout.column: 1 - Layout.columnSpan: 2 - Layout.preferredWidth: 2 * panel.col + panel.columnSpacing title: "DISKS" Repeater { @@ -319,7 +323,7 @@ GridLayout { delegate: Column { required property var modelData width: parent.width - spacing: 2 + spacing: 4 Pair { glyph: panel.glyph(modelData) label: modelData.host || modelData.mount @@ -333,4 +337,45 @@ GridLayout { } } } + + // --- cache -------------------------------------------------------------- + + // ~/.cache and its five largest children, measured once per page open: + // du over the tree took 28ms, so conky's 15-minute sampler file is not + // needed. The largest is coloured by its share of the total, as conky + // did, so the eye lands on the one worth deleting. + property var cache: null // {total, items: [{name, bytes}]} + + Process { + running: true + command: ["du", "-B1", "-d1", (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache")] + stdout: StdioCollector { + onStreamFinished: panel.cache = P.du(text) + } + } + + Card { + title: "CACHE" + headline: panel.cache ? P.bytes(panel.cache.total) : "--" + + Repeater { + model: panel.cache ? panel.cache.items.slice(0, 5) : [] + delegate: Column { + required property var modelData + required property int index + readonly property real share: modelData.bytes / panel.cache.total + width: parent.width + spacing: 4 + Pair { + label: modelData.name + value: P.bytes(modelData.bytes) + valueColor: index === 0 ? (share > 0.5 ? Theme.red : Theme.yellow) : Theme.text + } + Bar { + frac: share + fill: index === 0 ? (share > 0.5 ? Theme.red : Theme.yellow) : Theme.overlay + } + } + } + } } diff --git a/desktop/modules/perf/README.md b/desktop/modules/perf/README.md new file mode 100644 index 0000000..dea5a7e --- /dev/null +++ b/desktop/modules/perf/README.md @@ -0,0 +1,36 @@ +# perf + +The figures the conky dashboard used to show, as a drawer page of cards, one +per row; the page scrolls. CPU load with a bar per core, the CPU model and +RAM; every temperature as a tile; the Arc B580's VRAM and fan (no +utilisation, xe exposes none); download and upload over the last minute, the +LAN address and the public IP; the five mounts with their usage; and +`~/.cache` with its five largest children. `Perf.qml` reads them; `Perf.js` +holds the parsing, tested by `node desktop/modules/perf/test_perf.js`. + +The tile shows CPU and RAM, and a dot for the worst of these, yellow or red: + +| Input | Yellow | Red | +| --- | --- | --- | +| each temperature | its warn | its crit | +| RAM | 85% | 95% | +| each disk | 90% | 95% | +| public IP | | differs from the pinned one | + +CPU load is left out, since spikes would flicker it. There is no hysteresis, +so a sensor sitting on its threshold can toggle the dot. The pin beside the +public IP stores the address shown as `perf.pinnedIp` in +`$XDG_STATE_HOME/quickshell/desktop.json`; the check exists because the VPN +into the LAN depends on the ISP's fixed address. The fetch is +`curl -4 https://ipinfo.io/ip` every 30 minutes from the last attempt, into +`~/.cache/udt/pubip.txt`, the file conky's sampler used. A failed fetch is +never a mismatch: the old value goes stale after 4 hours and shows as a dash. + +CPU, RAM, temperatures and disks are sampled always. Per-core load, the +network rate, VRAM (a scan of every fdinfo, ~14ms) and the fan run only while +the page is open. The mount list, `br0`, the GPU name and the 12 GiB VRAM +size are constants in `Perf.qml`. + +The cache card runs `du -B1 -d1` once each time the page opens, 28ms over +2 GiB, instead of reading conky's 15-minute `cachesize.txt`. The largest +child is yellow, red when it holds over half the total, as conky had it. diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js index 5f39f5d..ac5859d 100644 --- a/desktop/modules/perf/test_perf.js +++ b/desktop/modules/perf/test_perf.js @@ -9,7 +9,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -// Run: node desktop/island/perf/test_perf.js +// Run: node desktop/modules/perf/test_perf.js // Perf.js is a QML library, so its .pragma line is stripped before node // evaluates it, as test_controls.js does for Controls.js. @@ -19,7 +19,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Perf.js", "utf8") .replace(/^\.pragma library$/m, ""); const P = new Function(src + "\nreturn { cpuTimes, load, meminfo, df, vram, rate, " + - "hwmonMap, lanAddr, cpuModel, band, worst, isIPv4, parsePubip, formatPubip, " + + "hwmonMap, lanAddr, cpuModel, du, band, worst, isIPv4, parsePubip, formatPubip, " + "ipCheck, due, push, bytes, speed };")(); // cpuTimes: aggregate first, then cores; idle is idle + iowait. @@ -150,4 +150,10 @@ assert.strictEqual(P.bytes(null), "--"); assert.strictEqual(P.speed(1024 * 1024 * 1.2), "1.2M/s"); assert.strictEqual(P.speed(null), "--"); +// du: total is the directory's own (largest) line; children largest first. +const cache = P.du("5\t/c/b\n9\t/c/a b\n20\t/c\n"); +assert.strictEqual(cache.total, 20); +assert.deepStrictEqual(cache.items, [{ name: "a b", bytes: 9 }, { name: "b", bytes: 5 }]); +assert.strictEqual(P.du(""), null); + console.log("test_perf: ok"); -- cgit v1.2.3