aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 09:50:42 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 09:50:42 +0200
commitecf306d043b62c1646a95027b4e2144be7c69178 (patch)
treea6671d804040f7563ebee9c2e05c6e344918ceef
parentdc8c94052e491f7cb1d1f8095afbcee574510d42 (diff)
downloadquickshell-ecf306d043b62c1646a95027b4e2144be7c69178.tar.gz
quickshell-ecf306d043b62c1646a95027b4e2144be7c69178.zip
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 <noreply@anthropic.com>
-rw-r--r--AGENTS.md2
-rw-r--r--desktop/README.md2
-rw-r--r--desktop/island/Island.qml7
-rw-r--r--desktop/island/README.md40
-rw-r--r--desktop/island/StatusPill.qml57
-rw-r--r--desktop/modules/perf/Perf.js14
-rw-r--r--desktop/modules/perf/Perf.qml2
-rw-r--r--desktop/modules/perf/PerfModule.qml8
-rw-r--r--desktop/modules/perf/PerfPanel.qml153
-rw-r--r--desktop/modules/perf/README.md36
-rw-r--r--desktop/modules/perf/test_perf.js10
11 files changed, 175 insertions, 156 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 863a7e5..fcdd1e9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ modules.
desktop/ the drawer (SUPER alone): sound, mail, VMs, status, performance,
clipboard, updates, appearance
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
- calendar, performance
+ calendar
appearance/ wallpaper picker and colour scheme switcher
window-switcher/ expose: open windows scattered as live previews, on ALT+TAB
or the top-right hot corner of DP-1
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 <dir>`: {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)
@@ -219,11 +235,6 @@ GridLayout {
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");