From ea9928c20c0a3b550f59904a2f2cb5b9911dde57 Mon Sep 17 00:00:00 2001 From: Jesse Gillespie Date: Mon, 12 Oct 2026 17:44:20 -0500 Subject: [PATCH 1/3] feat(metrics): add resource detail pages and regression coverage --- .../ruixen.notch/MetricDetailsPanel.qml | 281 ++++++++++++++++ .../ruixen.notch/MetricDrillButton.qml | 15 + bars/widgets/ruixen.notch/MetricsContent.qml | 83 ++++- bars/widgets/ruixen.notch/Overlay.qml | 3 + bars/widgets/ruixen.notch/metric-details.py | 310 ++++++++++++++++++ dev/README.md | 8 + docs/AGENT.md | 10 + docs/CONTROL.md | 31 ++ docs/INSTALLATION.md | 2 +- install.sh | 4 +- tests/js/MetricDetailsLifecycle.test.js | 151 +++++++++ tests/js/MetricNetwork.test.js | 48 +++ tests/metric-details.py | 162 +++++++++ tests/metric-details.sh | 4 + tests/run-all.sh | 1 + 15 files changed, 1095 insertions(+), 18 deletions(-) create mode 100644 bars/widgets/ruixen.notch/MetricDetailsPanel.qml create mode 100644 bars/widgets/ruixen.notch/MetricDrillButton.qml create mode 100644 bars/widgets/ruixen.notch/metric-details.py create mode 100644 tests/js/MetricDetailsLifecycle.test.js create mode 100644 tests/js/MetricNetwork.test.js create mode 100644 tests/metric-details.py create mode 100755 tests/metric-details.sh diff --git a/bars/widgets/ruixen.notch/MetricDetailsPanel.qml b/bars/widgets/ruixen.notch/MetricDetailsPanel.qml new file mode 100644 index 0000000..da88a29 --- /dev/null +++ b/bars/widgets/ruixen.notch/MetricDetailsPanel.qml @@ -0,0 +1,281 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Io + +Rectangle { + id: root + property string kind: "" + property bool active: false + property color textColor: "white" + property color accent: "#8aadf4" + property string fontFamily: "monospace" + property var rows: [] + property var summaries: [] + property bool relativeBars: false + readonly property real largestShare: rows.reduce(function (value, row) { + return Math.max(value, row.fraction || 0); + }, 0) + readonly property bool resourcePage: ["cpu", "memory", "gpu"].indexOf(kind) >= 0 + property string note: "" + property string errorText: "" + property string checkedAt: "" + property string requestedKind: "" + property int generation: 0 + property int requestedGeneration: -1 + property string queuedKind: "" + signal back + visible: kind !== "" + color: "transparent" + function refresh() { + if (!active || !visible) + return; + if (reader.running) { + queuedKind = kind; + return; + } + requestedKind = kind; + requestedGeneration = generation; + reader.command = ["python3", decodeURIComponent(Qt.resolvedUrl("metric-details.py").toString().replace(/^file:\/\//, "")), kind]; + reader.running = true; + } + onKindChanged: { + generation++; + relativeBars = false; + rows = []; + summaries = []; + note = ""; + errorText = ""; + checkedAt = ""; + queuedKind = ""; + Qt.callLater(refresh); + } + onActiveChanged: if (active) + Qt.callLater(refresh) + Timer { + interval: 3000 + repeat: true + running: root.active && root.visible + onTriggered: root.refresh() + } + Process { + id: reader + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + if (!root.active || root.kind !== root.requestedKind || root.generation !== root.requestedGeneration) + return; + try { + var result = JSON.parse(text); + root.rows = result.rows || []; + root.summaries = result.summaries || []; + root.note = result.note || ""; + root.errorText = result.error || ""; + root.checkedAt = Qt.formatTime(new Date(), "hh:mm:ss"); + } catch (e) { + root.errorText = "Unable to read system details"; + } + } + } + onExited: (code, status) => { + if (code !== 0 && root.active && root.kind === root.requestedKind && root.generation === root.requestedGeneration) + root.errorText = "Details helper failed"; + if (root.queuedKind) { + root.queuedKind = ""; + root.refresh(); + } + } + } + component DetailAction: Button { + font.family: root.fontFamily + font.pixelSize: 11 + padding: 8 + contentItem: Text { + text: parent.text + font: parent.font + color: root.accent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 6 + color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, parent.hovered || parent.activeFocus ? .2 : .1) + } + } + ColumnLayout { + anchors.fill: parent + spacing: 12 + RowLayout { + Layout.fillWidth: true + DetailAction { + text: "‹ Back" + onClicked: root.back() + Accessible.name: "Back to metrics overview" + } + Text { + text: root.kind.toUpperCase() + " details" + color: root.accent + font.family: root.fontFamily + font.pixelSize: 16 + Layout.fillWidth: true + } + Text { + text: root.checkedAt ? "Updated " + root.checkedAt : "Loading…" + color: root.textColor + opacity: .65 + font.pixelSize: 11 + } + DetailAction { + visible: root.resourcePage + text: root.relativeBars ? "Bars: compare" : "Bars: capacity" + onClicked: root.relativeBars = !root.relativeBars + } + DetailAction { + text: "Refresh" + onClicked: root.refresh() + } + } + Text { + text: root.errorText || (root.note + (root.resourcePage && root.relativeBars ? " Process bars scale to the largest shown; labels show actual usage." : "")) + color: root.errorText ? "#ff8080" : root.textColor + opacity: .8 + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: 11 + } + RowLayout { + visible: root.summaries.length > 0 + Layout.fillWidth: true + spacing: 8 + Repeater { + model: root.summaries + Rectangle { + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: summaryContent.implicitHeight + 16 + radius: 8 + color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, .1) + ColumnLayout { + id: summaryContent + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + RowLayout { + Layout.fillWidth: true + Text { + text: modelData.label + color: root.textColor + font.family: root.fontFamily + font.pixelSize: 11 + Layout.fillWidth: true + elide: Text.ElideRight + } + Text { + text: modelData.value + color: root.accent + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 5 + radius: 2 + color: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, .1) + Rectangle { + width: parent.width * Math.max(0, Math.min(1, modelData.fraction)) + height: parent.height + radius: 2 + color: root.accent + } + } + Text { + visible: !!modelData.detail + text: modelData.detail || "" + color: root.textColor + opacity: .75 + font.family: root.fontFamily + font.pixelSize: 10 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + } + } + } + } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ScrollBar.vertical.policy: ScrollBar.AsNeeded + Column { + width: parent.width + spacing: 4 + Repeater { + model: root.rows + Rectangle { + required property var modelData + required property int index + readonly property bool hasBar: modelData.fraction !== undefined && modelData.fraction !== null + width: parent.width + height: modelData.detail ? 60 : (hasBar ? 42 : 34) + radius: 6 + color: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, .05) + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + RowLayout { + Layout.fillWidth: true + spacing: 16 + Text { + text: (modelData.detail || modelData.fraction !== undefined ? (index + 1) + ". " : "") + modelData.label + color: root.textColor + font.family: root.fontFamily + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + Text { + text: modelData.value + color: root.accent + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: true + Layout.maximumWidth: root.width * .6 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + Text { + visible: !!modelData.detail + text: modelData.detail || "" + color: root.textColor + opacity: .65 + font.family: root.fontFamily + font.pixelSize: 10 + Layout.fillWidth: true + elide: Text.ElideRight + } + Rectangle { + visible: parent.parent.hasBar + Layout.fillWidth: true + Layout.preferredHeight: 5 + radius: 2 + color: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, .08) + Rectangle { + width: parent.width * Math.max(0, Math.min(1, (modelData.fraction || 0) / (root.relativeBars ? (root.largestShare || 1) : 1))) + height: parent.height + radius: 2 + color: root.accent + } + } + } + } + } + } + } + } +} diff --git a/bars/widgets/ruixen.notch/MetricDrillButton.qml b/bars/widgets/ruixen.notch/MetricDrillButton.qml new file mode 100644 index 0000000..85136fe --- /dev/null +++ b/bars/widgets/ruixen.notch/MetricDrillButton.qml @@ -0,0 +1,15 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: root + property string metricLabel: "" + property color accent: "#8aadf4" + Accessible.name: "Show " + metricLabel + " details" + padding: 0 + background: Rectangle { + radius: 10 + color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, root.hovered || root.activeFocus ? .08 : 0) + } + contentItem: Item {} +} diff --git a/bars/widgets/ruixen.notch/MetricsContent.qml b/bars/widgets/ruixen.notch/MetricsContent.qml index 9636c3f..1184b24 100644 --- a/bars/widgets/ruixen.notch/MetricsContent.qml +++ b/bars/widgets/ruixen.notch/MetricsContent.qml @@ -58,6 +58,21 @@ Item { } property bool active: false + property string detailKind: "" + function openDetails(kind) { + if (active && ["cpu", "gpu", "memory", "network", "storage"].indexOf(kind) >= 0) + detailKind = kind + } + IpcHandler { + target: "ruixen-metrics-details" + function open(kind: string): void { root.openDetails(kind) } + function close(): void { root.detailKind = "" } + function status(): string { + return JSON.stringify({kind: root.detailKind, active: root.active, + rows: detailsPanel.rows.length, error: detailsPanel.errorText, + updatedAt: detailsPanel.checkedAt}) + } + } // Bumped by Overlay.qml's own refreshAvatar IPC handler -- direct // follow-up ("on the expanded notch, health page, the avatar needs @@ -111,6 +126,7 @@ Item { // path was found or a read comes back empty -- gpuAvailable gates the // DialTile into an explicit "Unavailable" state instead. property string gpuRc6Path: "" + property bool gpuBusyPercent: false property bool gpuAvailable: false property bool gpuDiscoveryAttempted: false @@ -138,8 +154,8 @@ Item { // idle connection). function formatRate(bytesPerSec) { if (bytesPerSec < 1024) return Math.round(bytesPerSec) + " B/s" - if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + " KB/s" - return (bytesPerSec / 1024 / 1024).toFixed(1) + " MB/s" + if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + " KiB/s" + return (bytesPerSec / 1024 / 1024).toFixed(1) + " MiB/s" } // Same auto-scaling idea, no "/s" -- for the cumulative totals. @@ -174,7 +190,7 @@ Item { if (!versionProc.running) versionProc.running = true } - onActiveChanged: if (active) refreshIdentity() + onActiveChanged: {if (active) refreshIdentity();else detailKind=""} // Hardware/PC name + CPU model + GPU name -- fastfetch's real Host, // CPU, and GPU modules, confirmed by running `fastfetch --format @@ -310,7 +326,8 @@ Item { } } - // GPU usage -- same standard non-privileged Intel iGPU method used + // GPU usage -- prefer a driver-provided busy percentage (e.g. AMD), + // otherwise use the standard non-privileged Intel iGPU method used // (and later removed, then needed again for this tile) earlier in // this file's history: i915 exposes no plain "busy %" file without // root, but RC6 idle-residency compared between two samples against @@ -320,7 +337,7 @@ Item { // The card index is NOT hardcoded (used to be card1, this machine's // real path since there's no card0 here -- but that's this machine's // own PCI enumeration, not a rule). gpuDiscoveryProc finds whichever - // /sys/class/drm/card* actually has an rc6_residency_ms file -- + // /sys/class/drm/card* has a busy percentage or rc6_residency_ms file -- // connectors (card1-HDMI-A-1 etc) never have one (confirmed directly: // they expose autosuspend_delay_ms/control/runtime_* instead), and // neither does any non-Intel GPU, so file existence alone is a @@ -328,11 +345,12 @@ Item { // uevent for DRIVER=i915. Process { id: gpuDiscoveryProc - command: ["bash", "-c", "for d in /sys/class/drm/card*; do [ -f \"$d/power/rc6_residency_ms\" ] && echo \"$d/power/rc6_residency_ms\" && break; done"] + command: ["bash", "-c", "for d in /sys/class/drm/card[0-9]*; do if [ -f \"$d/device/gpu_busy_percent\" ]; then echo \"$d/device/gpu_busy_percent\"; break; elif [ -f \"$d/power/rc6_residency_ms\" ]; then echo \"$d/power/rc6_residency_ms\"; break; fi; done"] stdout: StdioCollector { waitForEnd: true onStreamFinished: { var path = String(text || "").trim() + root.gpuBusyPercent = path.indexOf("gpu_busy_percent") >= 0 root.gpuRc6Path = path root.gpuAvailable = path !== "" } @@ -353,6 +371,11 @@ Item { // "100 gpu usage" while btop showed 0%, traced to this coercion // firing every single poll against a permanently-wrong path). if (raw === "") return + if (root.gpuBusyPercent) { + var busy = Number(raw) + if (isFinite(busy) && busy>=0 && busy<=100) root.gpuUsage=busy/100 + return + } var rc6Ms = Number(raw) var nowMs = Date.now() if (root.prevGpuSample) { @@ -408,10 +431,26 @@ Item { var ifaceMatch = routeLine.match(/\bdev\s+(\S+)/) var iface = ifaceMatch ? ifaceMatch[1] : "" root.netInterface = iface - if (!iface) return - var lineMatch = devText.match(new RegExp("^\\s*" + iface + ":\\s*(.*)$", "m")) - if (!lineMatch) return - var fields = lineMatch[1].trim().split(/\s+/).map(Number) + if (!iface) { + root.netRxRate=0;root.netTxRate=0;root.prevNetSample=null + root.netRxTotalBytes=0;root.netTxTotalBytes=0 + return + } + var deviceLines = devText.split("\n") + var counters = "" + for (var d=0;d=0 && deviceLines[d].slice(0,separator).trim()===iface) { + counters=deviceLines[d].slice(separator+1).trim() + break + } + } + var fields = counters.split(/\s+/).map(Number) + if (fields.length<9 || !isFinite(fields[0]) || !isFinite(fields[8]) || fields[0]<0 || fields[8]<0) { + root.netRxRate=0;root.netTxRate=0;root.prevNetSample=null + root.netRxTotalBytes=0;root.netTxTotalBytes=0 + return + } var rxBytes = fields[0] var txBytes = fields[8] // Raw counters, not a delta -- this IS the lifetime total @@ -420,14 +459,15 @@ Item { root.netRxTotalBytes = rxBytes root.netTxTotalBytes = txBytes var nowMs = Date.now() - if (root.prevNetSample) { + if (root.prevNetSample && root.prevNetSample.iface === iface) { var wallDeltaS = (nowMs - root.prevNetSample.wallMs) / 1000 if (wallDeltaS > 0) { root.netRxRate = Math.max(0, (rxBytes - root.prevNetSample.rxBytes) / wallDeltaS) root.netTxRate = Math.max(0, (txBytes - root.prevNetSample.txBytes) / wallDeltaS) } } - root.prevNetSample = { rxBytes: rxBytes, txBytes: txBytes, wallMs: nowMs } + else {root.netRxRate=0;root.netTxRate=0} + root.prevNetSample = { iface:iface, rxBytes: rxBytes, txBytes: txBytes, wallMs: nowMs } } } } @@ -479,13 +519,13 @@ Item { Timer { interval: 2000 - running: root.active + running: root.active && root.detailKind === "" repeat: true triggeredOnStart: true onTriggered: { // Runs once (gpuDiscoveryAttempted latches it) rather than every // 2s -- the real GPU's sysfs path can't change mid-session, and a - // machine with no rc6-capable GPU at all (AMD/NVIDIA-only) would + // machine with no supported GPU sensor would // otherwise re-fork this forever for nothing. if (!root.gpuDiscoveryAttempted && !gpuDiscoveryProc.running) { root.gpuDiscoveryAttempted = true @@ -501,6 +541,7 @@ Item { } RowLayout { + visible: root.detailKind === "" anchors.fill: parent // 8 -> 16 -- per direct feedback the stat-tiles section sat too // close to the CPU list beside it. @@ -1281,6 +1322,7 @@ Item { } } } + MetricDrillButton {anchors.fill:parent;metricLabel:tile.title;accent:root.accent;onClicked:root.openDetails(tile.title.toLowerCase())} } // Memory's own dial variant -- per direct request ("it should be @@ -1417,6 +1459,7 @@ Item { Item { Layout.fillHeight: true } } + MetricDrillButton {anchors.fill:parent;metricLabel:"Memory";accent:root.accent;onClicked:root.openDetails("memory")} } RowLayout { Layout.fillWidth: true @@ -1673,6 +1716,7 @@ Item { // own matching top+bottom spacers keep its content. Item { Layout.fillHeight: true } } + MetricDrillButton {anchors.fill:parent;metricLabel:"Network";accent:root.accent;onClicked:root.openDetails("network")} } MemoryDialTile { @@ -1745,7 +1789,7 @@ Item { } Text { - text: diskRow.modelData.usedGB.toFixed(0) + " / " + diskRow.modelData.totalGB.toFixed(0) + " GB" + text: diskRow.modelData.usedGB.toFixed(0) + " / " + diskRow.modelData.totalGB.toFixed(0) + " GiB" font.family: root.fontFamily font.pixelSize: 11 color: root.muted @@ -1766,9 +1810,18 @@ Item { } } } + MetricDrillButton {anchors.fill:parent;metricLabel:"Storage";accent:root.accent;onClicked:root.openDetails("storage")} } Item { Layout.fillHeight: true } } } + MetricDetailsPanel { + id: detailsPanel + anchors.fill:parent + kind:root.detailKind + active:root.active + textColor:root.textColor;accent:root.accent;fontFamily:root.fontFamily + onBack:root.detailKind="" + } } diff --git a/bars/widgets/ruixen.notch/Overlay.qml b/bars/widgets/ruixen.notch/Overlay.qml index 8b0e8b9..970c30d 100644 --- a/bars/widgets/ruixen.notch/Overlay.qml +++ b/bars/widgets/ruixen.notch/Overlay.qml @@ -1349,6 +1349,9 @@ Item { IpcHandler { target: "ruixen.notch" + function status(): string { + return JSON.stringify({visible:panel.visible,fullscreen:root.fullscreenActive,barHidden:root.barHidden,fullbarStyle:root.fullbarStyle,screen:panel.screen ? panel.screen.name : "",screens:Quickshell.screens.map(function(screen){return screen.name})}) + } function openLauncher(): void { panel.launcherOpen = true } function closeLauncher(): void { panel.launcherOpen = false } function toggleLauncher(): void { panel.launcherOpen = !panel.launcherOpen } diff --git a/bars/widgets/ruixen.notch/metric-details.py b/bars/widgets/ruixen.notch/metric-details.py new file mode 100644 index 0000000..2588141 --- /dev/null +++ b/bars/widgets/ruixen.notch/metric-details.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Read-only, bounded system details for the dashboard's open detail page.""" +import argparse +import json +import os +import re +import selectors +from itertools import islice +from pathlib import Path +import subprocess +import time + + +def command(args): + # Bound the buffer while reading, rather than checking after capture_output. + environment = dict(os.environ, LC_ALL="C") + with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env=environment) as process: + try: + output = bytearray() + deadline = time.monotonic() + 3 + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0 or not selector.select(remaining): + raise subprocess.TimeoutExpired(args, 3) + chunk = os.read(process.stdout.fileno(), 65536) + if not chunk: + break + output.extend(chunk) + if len(output) > 1024 * 1024: + raise ValueError("Output too large") + code = process.wait(timeout=max(.001, deadline - time.monotonic())) + if code: + raise subprocess.CalledProcessError(code, args) + return output.decode("utf-8", errors="replace") + finally: + if process.poll() is None: + process.kill() + process.wait() + + +def size(value): + value = float(value) + for unit in ["B", "KiB", "MiB", "GiB", "TiB"]: + if value < 1024 or unit == "TiB": + return f"{value:.1f} {unit}" + value /= 1024 + + +def processes(order, capacity=None): + rows = [] + output = command(["ps", "-eo", "pid=,comm=,pcpu=,rss=", "--sort=-" + order]) + for line in output.splitlines()[:10]: + parts = line.split() + if len(parts) < 4: + continue + pid, name, cpu, rss = parts[0], " ".join(parts[1:-2]), parts[-2], parts[-1] + amount = int(rss) * 1024 + row = {"label": f"{name} · PID {pid}", "value": f"{cpu}% CPU · {size(amount)} RAM"} + if capacity: + share = amount / capacity + row.update(value=size(amount) + f" · {share * 100:.1f}% of RAM", fraction=share) + rows.append(row) + return rows + + +def cpu_snapshot(): + values = [int(v) for v in Path("/proc/stat").read_text().splitlines()[0].split()[1:9]] + total, idle = sum(values), values[3] + values[4] + items = {} + for proc in islice(Path("/proc").iterdir(), 8192): + if not proc.name.isdigit(): + continue + try: + stat = (proc / "stat").read_text() + end = stat.rfind(")") + fields = stat[end + 2:].split() + # PID plus start time prevents attributing a reused PID's old ticks. + items[(proc.name, fields[19])] = (stat[stat.index("(") + 1:end], int(fields[11]) + int(fields[12])) + except (OSError, ValueError, IndexError): + continue + return total, idle, items + + +def rank_cpu(before, after, elapsed, cores, ticks, capacity_ticks=None, limit=10): + rows = [] + for identity, (name, counter) in after.items(): + if identity not in before: + continue + delta = max(0, counter - before[identity][1]) + used_cores = delta / ticks / elapsed + share = min(1, delta / capacity_ticks) if capacity_ticks else min(1, used_cores / cores) + if capacity_ticks: + used_cores = share * cores + rows.append({"label": name + " · PID " + identity[0], + "value": f"{share * 100:.1f}% of total CPU", "fraction": share, + "detail": f"{used_cores:.2f} cores · {cores} logical CPUs available"}) + return sorted(rows, key=lambda row: row["fraction"], reverse=True)[:limit] + + +def drm_fields(text): + fields = dict(line.split(":", 1) for line in text.splitlines() if line.startswith("drm-") and ":" in line) + fields = {k: v.strip() for k, v in fields.items()} + if "drm-client-id" not in fields or "drm-driver" not in fields: + return None + engines, capacities, memory = {}, {}, {} + for key, value in fields.items(): + if key.startswith("drm-engine-capacity-"): + capacities[key.removeprefix("drm-engine-capacity-")] = max(1, int(value)) + elif key.startswith("drm-engine-") and value.endswith(" ns"): + engines[key.removeprefix("drm-engine-")] = int(value.split()[0]) + for region in ["vram", "gtt", "system", "memory"]: + value = fields.get("drm-resident-" + region, fields.get("drm-memory-" + region)) + if value is not None: + parts = value.split() + factor = {"B": 1, "KiB": 1024, "MiB": 1024 ** 2}.get(parts[1] if len(parts) > 1 else "B") + if factor is not None: + memory[region] = int(parts[0]) * factor + identity = (fields.get("drm-pdev", fields["drm-driver"]), fields["drm-client-id"]) + return identity, engines, capacities, memory + + +def gpu_snapshot(): + clients = {} + deadline = time.monotonic() + 2 + for proc in islice(Path("/proc").iterdir(), 8192): + if not proc.name.isdigit(): + continue + if time.monotonic() > deadline: + break + try: + with os.scandir(proc / "fd") as descriptors: + for index, fd in enumerate(descriptors): + if index >= 512 or time.monotonic() > deadline: + break + try: + if not os.readlink(fd.path).startswith("/dev/dri/"): + continue + parsed = drm_fields((proc / "fdinfo" / fd.name).read_text()) + if not parsed or parsed[0] in clients: + continue + identity, engines, capacities, memory = parsed + clients[identity] = {"pid": proc.name, "name": (proc / "comm").read_text().strip(), + "sampledAt": time.monotonic(), + "engines": engines, "capacities": capacities, "memory": memory} + except (OSError, ValueError): + continue + except OSError: + continue + return clients + + +def rank_gpu(before, after, elapsed): + processes = {} + for identity, client in after.items(): + proc = processes.setdefault(client["pid"], {"name": client["name"], "engines": {}, "vram": 0, "shared": 0}) + proc["vram"] += client["memory"].get("vram", 0) + proc["shared"] += client["memory"].get("gtt", client["memory"].get("system", client["memory"].get("memory", 0))) + previous = before.get(identity) + if not previous or previous["pid"] != client["pid"]: + continue + interval = client.get("sampledAt", elapsed) - previous.get("sampledAt", 0) + if interval <= 0: + continue + for engine, counter in client["engines"].items(): + if engine not in previous["engines"]: + continue + share = max(0, counter - previous["engines"][engine]) / interval / 1e9 / client["capacities"].get(engine, 1) + key = identity[0] + ":" + engine + proc["engines"][key] = proc["engines"].get(key, 0) + share + rows = [] + for pid, proc in processes.items(): + share = min(1, max(proc["engines"].values())) if proc["engines"] else None + rows.append({"label": proc["name"] + " · PID " + pid, + "value": f"{share * 100:.1f}% busiest engine" if share is not None else "Engine usage unreported", + "fraction": share, + "detail": size(proc["vram"]) + " VRAM · " + size(proc["shared"]) + " shared GPU RAM", + "memoryBytes": proc["vram"] + proc["shared"]}) + return sorted(rows, key=lambda row: (row["fraction"] or 0, row["memoryBytes"]), reverse=True)[:10] + + +def collect(kind): + rows = [] + summaries = [] + note = "Read-only snapshot; refreshed while this page is open." + if kind == "cpu": + first = cpu_snapshot() + start = time.monotonic() + time.sleep(.4) + second = cpu_snapshot() + elapsed = time.monotonic() - start + cores = os.cpu_count() or 1 + total_delta = second[0] - first[0] + busy = max(0, min(1, 1 - (second[1] - first[1]) / total_delta)) if total_delta > 0 else 0 + all_rows = rank_cpu(first[2], second[2], elapsed, cores, os.sysconf("SC_CLK_TCK"), capacity_ticks=total_delta, limit=8192) + rows = all_rows[:10] + listed = sum(row["fraction"] for row in rows) + other = sum(row["fraction"] for row in all_rows[10:]) + remainder = max(0, busy - listed - other) + detail = f"Listed processes: {listed * 100:.1f}% · Other readable processes: {other * 100:.1f}% · Unattributed/system: {remainder * 100:.1f}%" + if listed + other > busy + .005: + detail += " · counter timing differs slightly" + summaries = [{"label": "Whole-machine CPU in use", "value": f"{busy * 100:.1f}%", "fraction": busy, "detail": detail, + "breakdown": {"listed": listed, "other": other, "unattributed": remainder}}] + rows += [{"label": "Logical CPUs", "value": str(cores)}, + {"label": "Load averages · 1 / 5 / 15 min", "value": " / ".join(f"{v:.2f}" for v in os.getloadavg())}] + note = "0.4-second sample · percentages use total CPU capacity. Unattributed/system includes time outside matched readable processes; row values are rounded." + elif kind == "memory": + data = {} + for line in Path("/proc/meminfo").read_text().splitlines(): + key, value = line.split(":", 1) + data[key] = int(value.split()[0]) * 1024 + for label, key in [("Physical memory", "MemTotal"), ("Available", "MemAvailable"), + ("Page cache", "Cached"), ("Swap total", "SwapTotal"), ("Swap free", "SwapFree")]: + rows.append({"label": label, "value": size(data.get(key, 0))}) + total = data.get("MemTotal", 0) + available = data.get("MemAvailable") + if total <= 0 or available is None or not 0 <= available <= total: + raise ValueError("Missing or invalid RAM capacity") + used = total - available + summaries = [{"label": "RAM in use / total", "value": size(used) + " / " + size(total), "fraction": used / total, + "detail": "Used = total − available. Process RSS rows overlap and do not sum to this total."}] + rows = processes("rss", total) + rows + note = "Resident RAM · percentages use physical RAM. Shared pages can appear in multiple processes." + elif kind == "network": + routes = json.loads(command(["ip", "-j", "route", "show", "default"])) + for route in routes[:8]: + rows.append({"label": "Default route · " + route.get("dev", "Unknown"), "value": route.get("gateway", "Direct route")}) + for device in json.loads(command(["ip", "-j", "address", "show"]))[:32]: + name = device.get("ifname", "Unknown") + rows.append({"label": name, "value": device.get("operstate", "Unknown")}) + for addr in device.get("addr_info", [])[:8]: + rows.append({"label": " " + addr.get("family", "IP"), "value": str(addr.get("local", "")) + "/" + str(addr.get("prefixlen", ""))}) + note = "Interface state and addresses. Dashboard transfer totals are counters since the interface came up, not a billing-period total." + elif kind == "storage": + seen = set() + for line in command(["df", "-B1", "--output=source,target,size,used,avail,pcent"]).splitlines()[1:][:128]: + parts = line.rsplit(None, 4) + if len(parts) != 5: + continue + source_mount, total, used, available, percent = parts + source, mount = source_mount.split(None, 1) + if not source.startswith("/dev/") or source in seen: + continue + seen.add(source) + total_bytes, used_bytes, available_bytes = int(total), int(used), int(available) + if total_bytes <= 0 or used_bytes < 0 or available_bytes < 0: + continue + rows.extend([{"label": mount + " · " + source, "value": f"{used_bytes / total_bytes * 100:.1f}% capacity used"}, + {"label": " Used / total", "value": size(used) + " / " + size(total)}, + {"label": " Available", "value": size(available)}]) + note = "Device-backed filesystems · used ÷ total capacity, matching the overview. Available space can exclude reserved space. Amounts use binary units." + elif kind == "gpu": + seen = set() + for card in sorted(Path("/sys/class/drm").glob("card[0-9]*")): + if not re.fullmatch(r"card\d+", card.name): + continue + device = card / "device" + if not device.exists() or device.resolve() in seen: + continue + seen.add(device.resolve()) + driver = device / "driver" + rows.append({"label": card.name + " · driver", "value": driver.resolve().name if driver.exists() else "Unknown"}) + for label, filename, formatter in [ + ("Busy", "gpu_busy_percent", lambda v: v + "%"), + ("Dedicated VRAM total", "mem_info_vram_total", size), + ("Dedicated VRAM used", "mem_info_vram_used", size), + ]: + try: + raw = (device / filename).read_text().strip() + value = formatter(raw) + if filename == "gpu_busy_percent": + busy = int(raw) + if not 0 <= busy <= 100: + raise ValueError("Invalid GPU busy reading") + summaries.append({"label": card.name + " · device GPU busy", "value": value, "fraction": busy / 100, + "detail": "Device-wide driver reading. Process engine percentages are not additive."}) + except (OSError, ValueError): + value = "Not exposed by this driver" + rows.append({"label": " " + label, "value": value}) + for sensor in sorted(device.glob("hwmon/hwmon*/temp1_input"))[:4]: + try: + rows.append({"label": " Device temperature", "value": f"{int(sensor.read_text()) / 1000:.1f} °C"}) + except (OSError, ValueError): + pass + before = gpu_snapshot() + start = time.monotonic() + time.sleep(.4) + after = gpu_snapshot() + elapsed = time.monotonic() - start + ranked = rank_gpu(before, after, elapsed) + rows = ranked + rows + note = "Accessible GPU processes · percentages use the busiest engine, not additive whole-device usage. Shared buffers may be counted more than once." + if not ranked: + note = "Per-process GPU counters are inaccessible or not exposed. Device sensors are shown below." + return {"kind": kind, "rows": rows, "summaries": summaries, "note": note, "error": ""} + + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("kind", choices=["cpu", "gpu", "memory", "network", "storage"]) + args = parser.parse_args() + try: + result = collect(args.kind) + except Exception: + result = {"kind": args.kind, "rows": [], "note": "", "error": "Unable to read system details. Try refreshing."} + print(json.dumps(result)) diff --git a/dev/README.md b/dev/README.md index 72c994f..55e340a 100644 --- a/dev/README.md +++ b/dev/README.md @@ -104,6 +104,14 @@ config/uninstall-restore tests. Each suite can also be run on its own -- see `tests/*.sh`, every file has its own header comment explaining what it covers. +For metrics regressions, run `./tests/metric-details.sh` for CPU/RAM/GPU +accounting, missing counters, and helper output/time limits. Run +`node tests/js/MetricDetailsLifecycle.test.js` for stale results, page +switches, reopen races, queued refreshes, and polling; run +`node tests/js/MetricNetwork.test.js` for interface changes and invalid +network counters. The JS tests execute production QML handlers with mocked +runtime objects; they do not replace a real shell restart for QML rendering. + CI runs one additional step this doesn't: `tests/host-contract- regression.sh` (issue #34), which fetches real source from `github.com/basecamp/omarchy` at the exact commit `COMPATIBILITY.md` diff --git a/docs/AGENT.md b/docs/AGENT.md index dc7c74b..feaf820 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -52,6 +52,16 @@ The notch at the top-center of the bar. 4 dashboard tabs: `widgets`, | `openLauncher` / `closeLauncher` / `toggleLauncher` | The quick app launcher. | | `toggleWallpapers` | Wallpapers tab. | | `refreshAvatar` | Re-read the avatar image. | +| `status` | Read-only visibility, fullscreen/bar state, and screen names. | + +## `ruixen-metrics-details` — system resource detail pages + +First open the Metrics tab with `ruixen.notch openDashboardTab metrics`. +`open ` selects a detail page, `close` returns +to the overview, and `status` reports the current page, active state, row +count, error, and update time. Snapshots are read-only and poll only while +the detail page is open. See [CONTROL.md](CONTROL.md#metrics-detail-pages) +for measurement definitions and attribution limits. ## `ruixen.notch` — Kanban board diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 6e8d1ba..ebf502a 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -209,6 +209,37 @@ Defaults are disabled, `~/Pictures/ruixen-wallpapers`, and 15 minutes. Changing settings or returning from media playback starts a fresh interval. Disabling rotation does not change the current wallpaper. +## Metrics detail pages + +Click the CPU, GPU, Memory, Network, or storage card in the notch's Metrics tab +to open details; **Back** returns to the overview. CPU/memory pages rank the ten +largest processes with bars and explicit amounts. CPU uses a fresh 0.4-second +sample and divides process ticks by the same total CPU-counter delta as the +summary, with cores used underneath. Its summary separates listed processes, +other readable processes, and unattributed/system time; short-lived or +inaccessible processes and sampling skew prevent full process attribution. +Memory shows resident bytes and percentage of physical RAM; +shared pages can appear in multiple processes. GPU ranks accessible DRM clients +by their busiest engine and shows resident VRAM/shared GPU RAM. Engine bars are +not additive whole-device percentages; unavailable counters remain unreported. +Device totals have their own summary bars. Resource bars default to capacity. +**Bars: compare/capacity** switches between relative +ranking and absolute capacity. Numeric labels always show actual usage. +RAM RSS rows do not sum to system-used RAM, and GPU engine percentages do not +sum to device busy. Storage percentages use used/total capacity, matching the +overview; available space can exclude reserved blocks. Network rates use binary +KiB/s and MiB/s, and restart their baseline when the interface changes. +Network shows +interface state and addresses; storage shows mounted-device capacity; GPU shows +driver-exposed sensors without requiring privileges. Detail snapshots refresh +every three seconds only while the page is open. Closing the tab stops polling. + +Open the Metrics tab with `omarchy-shell ruixen.notch openDashboardTab metrics`. +`ruixen-metrics-details` exposes `open `, `close`, +and `status` (current page, row count, error, and last update). Calls to `open` +require an active Metrics tab. The overview also supports AMD's kernel busy +percentage sensor alongside its existing Intel residency counter. + ## Local wallpaper preview `ruixen-wallpaper-preview-launcher` exposes `preview `, diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 19da130..0a1bbee 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -213,6 +213,6 @@ rather than discovering it later when a feature quietly doesn't work: | `ffmpeg` | Video/gif wallpaper poster generation (current/background and the lock screen won't reflect the active video/gif; the moving wallpaper itself is unaffected by this one) | | `qt6-multimedia` (package, not command -- install a backend with it, e.g. `qt6-multimedia-ffmpeg`) | Video AND gif wallpaper playback both fail silently to start -- not part of Omarchy's own base install, only present if some other app happened to pull it in | | `curl` | Weather data, avatar image download in Settings | -| `python3` | The bar's docked-mode toggle | +| `python3` | The bar's docked-mode toggle and system metrics detail pages | | `fastfetch` | Less detail on the health page's system-info panel | | `cava` | The Desktop audio visualizer; the rest of Ruixen remains usable | diff --git a/install.sh b/install.sh index f78db64..851da4e 100755 --- a/install.sh +++ b/install.sh @@ -110,7 +110,7 @@ if [[ "$dry_run" == true ]]; then printf '\nOptional dependencies:\n' for pair in "ffmpeg:video/gif wallpaper poster generation" "curl:weather data and avatar download" \ - "python3:the bar's docked-mode toggle" "fastfetch:extra system-info detail" \ + "python3:the bar's docked-mode toggle and metrics detail pages" "fastfetch:extra system-info detail" \ "cava:the Desktop audio visualizer"; do cmd="${pair%%:*}"; feature="${pair#*:}" if command -v "$cmd" >/dev/null 2>&1; then @@ -379,7 +379,7 @@ warn_optional_pkg() { warn_optional_dep ffmpeg "video/gif wallpaper POSTER generation will be unavailable (current/background and the lock screen won't reflect the active video/gif; the moving wallpaper itself is unaffected by this specific dependency)" warn_optional_pkg qt6-multimedia "video AND gif wallpaper playback will both silently fail to start (ruixen.wallpaper's whole backend fails to load without this, not just video) -- pacman -S --needed qt6-multimedia-ffmpeg pulls this in with hardware-accelerated decode" warn_optional_dep curl "weather data and avatar image download in Settings will be unavailable" -warn_optional_dep python3 "the bar's docked-mode toggle will silently no-op" +warn_optional_dep python3 "the bar's docked-mode toggle and metrics detail pages will be unavailable" warn_optional_dep fastfetch "the health page's system-info panel will show less detail" warn_optional_dep cava "the Desktop audio visualizer will be unavailable; the rest of Ruixen remains usable" if [[ "$optional_dep_warned" -eq 0 ]]; then diff --git a/tests/js/MetricDetailsLifecycle.test.js b/tests/js/MetricDetailsLifecycle.test.js new file mode 100644 index 0000000..3627229 --- /dev/null +++ b/tests/js/MetricDetailsLifecycle.test.js @@ -0,0 +1,151 @@ +"use strict"; +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +// Run the actual QML handlers with a fake asynchronous Process. This is a +// lifecycle test, not a second implementation of the generation rules. +const qml = fs.readFileSync(path.join(__dirname, + "../../bars/widgets/ruixen.notch/MetricDetailsPanel.qml"), "utf8"); +function body(marker) { + const markerAt = qml.indexOf(marker); + assert(markerAt >= 0, "Missing production handler: " + marker); + const start = qml.indexOf("{", markerAt + marker.length); + let depth = 1; + let end = start + 1; + for (; depth && end < qml.length; end++) { + if (qml[end] === "{") depth++; + if (qml[end] === "}") depth--; + } + assert.equal(depth, 0, "Unclosed production handler"); + return qml.slice(start + 1, end - 1); +} +const refreshBody = body("function refresh()"); +const changedBody = body("onKindChanged:"); +const resultBody = body("onStreamFinished:"); +const exitBody = body("onExited: (code, status) =>"); +const pollExpression = qml.match(/Timer\s*\{[^}]*running:\s*([^\n]+)/)[1]; + +function harness() { + const deferred = []; + const state = { + kind: "cpu", active: true, generation: 0, requestedGeneration: -1, + requestedKind: "", queuedKind: "", rows: [], summaries: [], note: "", + errorText: "", checkedAt: "", relativeBars: false, + reader: {running: false, command: []}, + Qt: { + callLater: fn => deferred.push(fn), + resolvedUrl: () => ({toString: () => "file:///tmp/plugin%20dir/metric-details.py"}), + formatTime: () => "12:00:00" + } + }; + state.root = state; + Object.defineProperty(state, "visible", {get: () => state.kind !== ""}); + const context = vm.createContext(state); + vm.runInContext("function refresh(){" + refreshBody + "}\n" + + "function changed(){" + changedBody + "}\n" + + "function result(text){" + resultBody + "}\n" + + "function exited(code,status){" + exitBody + "}", context); + return { + state, + refresh: () => state.refresh(), + select: kind => { + state.kind = kind; + state.changed(); + while (deferred.length) deferred.shift()(); + }, + publish: rows => state.result(JSON.stringify({rows, summaries: [], note: "snapshot"})), + finish: code => {state.reader.running = false; state.exited(code, 0);}, + polling: () => vm.runInContext(pollExpression, context) + }; +} + +let passed = 0; +function test(name, fn) { + fn(); + passed++; + console.log("ok - " + name); +} +test("starts the helper with a decoded plugin path", () => { + const h = harness(); h.refresh(); + assert.deepEqual(Array.from(h.state.reader.command), + ["python3", "/tmp/plugin dir/metric-details.py", "cpu"]); + assert.equal(h.state.reader.running, true); +}); +test("publishes the current request and update time", () => { + const h = harness(); h.refresh(); h.publish([{label: "current"}]); + assert.equal(h.state.rows[0].label, "current"); + assert.equal(h.state.checkedAt, "12:00:00"); +}); +test("page switch rejects old results and waits for the real worker exit", () => { + const h = harness(); h.refresh(); h.select("memory"); + h.publish([{label: "old CPU"}]); + assert.equal(h.state.rows.length, 0); + assert.equal(h.state.requestedKind, "cpu"); + h.finish(0); + assert.equal(h.state.requestedKind, "memory"); + assert.equal(h.state.reader.running, true); +}); +test("CPU to memory to CPU rejects an old CPU result by generation", () => { + const h = harness(); h.refresh(); h.select("memory"); h.select("cpu"); + h.publish([{label: "stale CPU"}]); + assert.equal(h.state.rows.length, 0); + h.finish(0); h.publish([{label: "fresh CPU"}]); + assert.equal(h.state.rows[0].label, "fresh CPU"); +}); +test("closing and reopening the same page rejects the previous request", () => { + const h = harness(); h.refresh(); h.select(""); h.select("cpu"); + h.publish([{label: "before close"}]); + assert.equal(h.state.rows.length, 0); + h.finish(0); h.publish([{label: "after reopen"}]); + assert.equal(h.state.rows[0].label, "after reopen"); +}); +test("repeated refreshes coalesce to one follow-up request", () => { + const h = harness(); h.refresh(); + h.refresh(); h.refresh(); h.refresh(); + h.finish(0); + assert.equal(h.state.reader.running, true); + assert.equal(h.state.queuedKind, ""); + h.finish(0); + assert.equal(h.state.reader.running, false); +}); +test("closing clears queued work and prevents another worker", () => { + const h = harness(); h.refresh(); h.refresh(); h.select(""); + assert.equal(h.state.queuedKind, ""); + h.publish([{label: "closed"}]); h.finish(0); + assert.equal(h.state.rows.length, 0); + assert.equal(h.state.reader.running, false); +}); +test("inactive tab rejects results and queued refreshes", () => { + const h = harness(); h.refresh(); h.refresh(); h.state.active = false; + h.publish([{label: "inactive"}]); h.finish(0); + assert.equal(h.state.rows.length, 0); + assert.equal(h.state.reader.running, false); +}); +test("stale worker errors cannot poison the new page", () => { + const h = harness(); h.refresh(); h.select("gpu"); h.finish(1); + assert.equal(h.state.errorText, ""); + assert.equal(h.state.requestedKind, "gpu"); +}); +test("current worker failures and malformed output are reported", () => { + const h = harness(); h.refresh(); h.state.result("broken JSON"); + assert.equal(h.state.errorText, "Unable to read system details"); + h.finish(1); + assert.equal(h.state.errorText, "Details helper failed"); +}); +test("page switch clears previous presentation and comparison mode", () => { + const h = harness(); h.state.relativeBars = true; + h.state.rows = [{label: "old"}]; h.state.errorText = "old error"; + h.state.checkedAt = "old time"; h.select("gpu"); + assert.equal(h.state.relativeBars, false); + assert.equal(h.state.rows.length, 0); + assert.equal(h.state.errorText, ""); + assert.equal(h.state.checkedAt, ""); +}); +test("polling requires both an active tab and a visible detail page", () => { + const h = harness(); assert.equal(h.polling(), true); + h.state.active = false; assert.equal(h.polling(), false); + h.state.active = true; h.select(""); assert.equal(h.polling(), false); +}); +console.log("\n" + passed + " passed, 0 failed"); diff --git a/tests/js/MetricNetwork.test.js b/tests/js/MetricNetwork.test.js new file mode 100644 index 0000000..d814a93 --- /dev/null +++ b/tests/js/MetricNetwork.test.js @@ -0,0 +1,48 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); +// Exercise the production stream handler rather than duplicating its formulas. +const qml = fs.readFileSync(path.join(__dirname,"../../bars/widgets/ruixen.notch/MetricsContent.qml"),"utf8"); +const start = qml.indexOf('var parts = String(text || "").split("---\\n")'); +const end = qml.indexOf("\n",qml.indexOf("root.prevNetSample = {",start)); +assert(start>=0 && end>start); +let clock = 1000; +const root = {}; +const context = vm.createContext({root, Date:{now:()=>clock}, isFinite}); +vm.runInContext("function apply(text){"+qml.slice(start,end)+"}",context); +function sample(iface,rx,tx) { + context.text="default via 192.0.2.1 dev "+iface+"\n---\n"+iface+": "+rx+" 0 0 0 0 0 0 0 "+tx+" 0 0 0 0 0 0 0\n"; + vm.runInContext("apply(text)",context); +} +sample("test.1",100,200); +assert.equal(root.netRxRate,0); +clock=2000; sample("test.1",1124,2248); +assert.equal(root.netRxRate,1024); +assert.equal(root.netTxRate,2048); +clock=3000; sample("other0",1000000,2000000); +assert.equal(root.netRxRate,0); +assert.equal(root.netTxRate,0); +clock=4000; sample("other0",10,20); +assert.equal(root.netRxRate,0); +context.text="\n---\n"; +vm.runInContext("apply(text)",context); +assert.equal(root.prevNetSample,null); +assert.equal(root.netInterface,""); +assert.equal(root.netRxTotalBytes,0); +assert.equal(root.netTxTotalBytes,0); +// A similarly named interface must not match a dotted VLAN name. +context.text="default via 192.0.2.1 dev test.1\n---\ntestx1: 100 0 0 0 0 0 0 0 200\n"; +vm.runInContext("apply(text)",context); +assert.equal(root.prevNetSample,null); +assert.equal(root.netRxTotalBytes,0); +// Malformed counters must clear a valid sample, not show NaN or stale totals. +sample("test.1",100,200); +sample("test.1","invalid",200); +assert.equal(root.prevNetSample,null); +assert.equal(root.netRxRate,0); +assert.equal(root.netTxRate,0); +assert.equal(root.netRxTotalBytes,0); +assert.equal(root.netTxTotalBytes,0); +console.log("7 passed, 0 failed"); diff --git a/tests/metric-details.py b/tests/metric-details.py new file mode 100644 index 0000000..196dd77 --- /dev/null +++ b/tests/metric-details.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import tempfile +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("metric_details", ROOT / "bars/widgets/ruixen.notch/metric-details.py") +details = importlib.util.module_from_spec(spec) +spec.loader.exec_module(details) + + +class DetailsTests(unittest.TestCase): + def test_missing_or_invalid_available_ram_does_not_report_fake_usage(self): + from unittest.mock import Mock + for fixture in ["MemTotal: 1024 kB\n", "MemTotal: 1024 kB\nMemAvailable: 2048 kB\n"]: + with self.subTest(fixture=fixture), patch.object(details, "Path", return_value=Mock(read_text=lambda: fixture)): + with self.assertRaises(ValueError): + details.collect("memory") + + def test_gpu_without_engine_counters_remains_unreported(self): + client = {"pid": "42", "name": "app", "engines": {}, "capacities": {}, "memory": {"vram": 1024}} + row = details.rank_gpu({}, {("pci0", "1"): client}, elapsed=.4)[0] + self.assertIsNone(row["fraction"]) + self.assertEqual(row["value"], "Engine usage unreported") + + def test_gpu_counter_reset_cannot_produce_negative_usage(self): + identity = ("pci0", "1") + first = {"pid": "42", "name": "app", "engines": {"gfx": 900}, "capacities": {}, "memory": {}} + second = dict(first, engines={"gfx": 10}) + row = details.rank_gpu({identity: first}, {identity: second}, elapsed=.4)[0] + self.assertEqual(row["fraction"], 0) + + def test_command_bounds_output_during_read(self): + with self.assertRaises(ValueError): + details.command([sys.executable, "-c", "import sys; sys.stdout.write('x' * (2 * 1024 * 1024))"]) + + def test_command_reports_nonzero_exit(self): + with self.assertRaises(details.subprocess.CalledProcessError): + details.command([sys.executable, "-c", "raise SystemExit(2)"]) + + def test_command_times_out_and_reaps_worker(self): + with self.assertRaises(details.subprocess.TimeoutExpired): + details.command([sys.executable, "-c", "import time; time.sleep(30)"]) + + def test_gpu_uses_each_clients_counter_interval(self): + identity = ("pci0", "1") + first = {"pid": "42", "name": "app", "engines": {"gfx": 0}, "capacities": {}, "memory": {}, "sampledAt": 10} + second = dict(first, engines={"gfx": 500000000}, sampledAt=12) + row = details.rank_gpu({identity: first}, {identity: second}, elapsed=.4)[0] + self.assertEqual(row["fraction"], .25) + + def test_memory_summary_uses_available_and_rows_use_physical_total(self): + fixture = "MemTotal: 1024 kB\nMemAvailable: 512 kB\nCached: 128 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n" + from unittest.mock import Mock + with patch.object(details, "Path", return_value=Mock(read_text=lambda: fixture)), patch.object(details, "command", return_value="42 worker 0.0 256\n"): + result = details.collect("memory") + self.assertEqual(result["summaries"][0]["fraction"], .5) + self.assertEqual(result["rows"][0]["fraction"], .25) + + def test_storage_percentage_uses_total_not_df_reserved_space_percent(self): + with patch.object(details, "command", return_value="header\n/dev/a / 100 20 20 50%\n"): + result = details.collect("storage") + self.assertEqual(result["rows"][0]["value"], "20.0% capacity used") + + def test_gpu_duplicate_file_descriptors_do_not_double_count_memory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + proc = root / "42" + (proc / "fd").mkdir(parents=True) + (proc / "fdinfo").mkdir() + (proc / "comm").write_text("worker") + for fd in ["1", "2"]: + (proc / "fd" / fd).symlink_to("/dev/dri/renderD128") + (proc / "fdinfo" / fd).write_text("drm-driver: amdgpu\ndrm-client-id: 9\ndrm-pdev: pci0\ndrm-resident-vram: 4 MiB\n") + with patch.object(details, "Path", side_effect=lambda path: root if path == "/proc" else Path(path)): + snapshot = details.gpu_snapshot() + self.assertEqual(len(snapshot), 1) + self.assertEqual(next(iter(snapshot.values()))["memory"]["vram"], 4 * 1024 * 1024) + + def test_cpu_processes_use_same_total_tick_denominator_as_summary(self): + rows = details.rank_cpu({("42", "1"): ("worker", 100)}, {("42", "1"): ("worker", 300)}, elapsed=.8, cores=4, ticks=100, capacity_ticks=1000) + self.assertEqual(rows[0]["fraction"], .2) + self.assertIn("0.80 cores", rows[0]["detail"]) + + def test_cpu_breakdown_explains_unattributed_and_other_processes(self): + before = (1000, 500, {(str(i), "1"): ("worker", 0) for i in range(12)}) + after = (2000, 1100, {(str(i), "1"): ("worker", 10) for i in range(12)}) + with patch.object(details, "cpu_snapshot", side_effect=[before, after]), patch.object(details.time, "sleep"), patch.object(details.os, "getloadavg", return_value=(0,0,0)): + result = details.collect("cpu") + parts = result["summaries"][0]["breakdown"] + self.assertAlmostEqual(parts["listed"], .1) + self.assertAlmostEqual(parts["other"], .02) + self.assertAlmostEqual(parts["unattributed"], .28) + self.assertAlmostEqual(sum(parts.values()), result["summaries"][0]["fraction"]) + + def test_cpu_uses_current_sample_and_total_capacity(self): + before = {("42", "100"): ("worker", 100), ("99", "200"): ("old", 1000)} + after = {("42", "100"): ("worker", 300), ("99", "201"): ("new", 5000)} + rows = details.rank_cpu(before, after, elapsed=1, cores=4, ticks=100) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["fraction"], .5) + self.assertIn("2.00 cores", rows[0]["detail"]) + + def test_memory_bar_uses_physical_ram_capacity(self): + with patch.object(details, "command", return_value="123 worker 1.0 1024\n"): + row = details.processes("rss", capacity=4 * 1024 * 1024)[0] + self.assertEqual(row["fraction"], .25) + self.assertIn("25.0% of RAM", row["value"]) + + def test_gpu_uses_busiest_engine_not_sum_of_parallel_engines(self): + identity = ("0000:04:00.0", "10") + first = {"pid": "42", "name": "app", "engines": {"gfx": 0, "compute": 0}, "capacities": {}, "memory": {"vram": 1024}} + second = dict(first, engines={"gfx": 500000000, "compute": 900000000}) + row = details.rank_gpu({identity: first}, {identity: second}, elapsed=1)[0] + self.assertEqual(row["fraction"], .9) + self.assertIn("90.0% busiest engine", row["value"]) + + def test_gpu_engine_capacity_and_resident_alias(self): + parsed = details.drm_fields("drm-driver: amdgpu\ndrm-client-id: 2\ndrm-pdev: pci0\ndrm-engine-gfx: 100 ns\ndrm-engine-capacity-gfx: 2\ndrm-resident-vram: 4 MiB\ndrm-memory-vram: 4096 KiB\n") + self.assertEqual(parsed[3]["vram"], 4 * 1024 * 1024) + self.assertEqual(parsed[2]["gfx"], 2) + + def test_process_names_with_spaces_and_limit(self): + line = "123 worker process 2.5 1024\n" + with patch.object(details, "command", return_value=line * 12): + rows = details.processes("rss") + self.assertEqual(len(rows), 10) + self.assertEqual(rows[0]["label"], "worker process · PID 123") + self.assertIn("1.0 MiB", rows[0]["value"]) + + def test_storage_deduplicates_devices_and_preserves_mount_spaces(self): + fixture = "header\n/dev/a / 10240 2048 8192 20%\n/dev/a /home 10240 2048 8192 20%\n/dev/b /media/My Disk 4096 1024 3072 25%\ntmpfs /run 1024 100 924 10%\n" + with patch.object(details, "command", return_value=fixture): + rows = details.collect("storage")["rows"] + self.assertEqual(len(rows), 6) + self.assertEqual(rows[3]["label"], "/media/My Disk · /dev/b") + + def test_network_keeps_multiple_addresses(self): + fixture = '[{"ifname":"test0","operstate":"UP","addr_info":[{"family":"inet","local":"192.0.2.1","prefixlen":24},{"family":"inet6","local":"2001:db8::1","prefixlen":64}]}]' + with patch.object(details, "command", side_effect=["[]", fixture]): + rows = details.collect("network")["rows"] + self.assertEqual(rows[-1]["value"], "2001:db8::1/64") + + def test_gpu_ignores_connectors_and_reports_missing_sensor(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + device = root / "card1/device" + device.mkdir(parents=True) + (device / "gpu_busy_percent").write_text("42") + (root / "card1-DP-1/device").mkdir(parents=True) + with patch.object(details, "Path", side_effect=lambda path: root if path == "/sys/class/drm" else Path(path)), patch.object(details, "gpu_snapshot", return_value={}): + rows = details.collect("gpu")["rows"] + self.assertFalse(any("DP-1" in row["label"] for row in rows)) + self.assertEqual(rows[1]["value"], "42%") + self.assertEqual(rows[2]["value"], "Not exposed by this driver") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/metric-details.sh b/tests/metric-details.sh new file mode 100755 index 0000000..f13c665 --- /dev/null +++ b/tests/metric-details.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +python3 "$script_dir/metric-details.py" diff --git a/tests/run-all.sh b/tests/run-all.sh index 1bedd10..4506ea2 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -45,6 +45,7 @@ suites=( "$script_dir/wallpaper-sidebar-style.sh" "$script_dir/wallpaper-effects.sh" "$script_dir/wallpaper-rotation.sh" + "$script_dir/metric-details.sh" "$script_dir/update-safety.sh" "$script_dir/gif-poster-fallback.sh" "$script_dir/poster-cache-pruning.sh" From 714645b038707253052d19f365787cec7ffe2846 Mon Sep 17 00:00:00 2001 From: Jesse Gillespie Date: Thu, 15 Oct 2026 02:45:01 -0500 Subject: [PATCH 2/3] Bound metric-details process and filesystem inspection and cover lifecycle edge cases --- .../ruixen.notch/MetricDetailsPanel.qml | 21 ++- bars/widgets/ruixen.notch/metric-details.py | 131 ++++++++++++------ docs/CONTROL.md | 1 + tests/js/MetricDetailsLifecycle.test.js | 28 +++- tests/metric-details.py | 100 +++++++++++-- 5 files changed, 221 insertions(+), 60 deletions(-) diff --git a/bars/widgets/ruixen.notch/MetricDetailsPanel.qml b/bars/widgets/ruixen.notch/MetricDetailsPanel.qml index da88a29..df8aaf4 100644 --- a/bars/widgets/ruixen.notch/MetricDetailsPanel.qml +++ b/bars/widgets/ruixen.notch/MetricDetailsPanel.qml @@ -23,6 +23,7 @@ Rectangle { property string requestedKind: "" property int generation: 0 property int requestedGeneration: -1 + property bool requestTimedOut: false property string queuedKind: "" signal back visible: kind !== "" @@ -36,9 +37,20 @@ Rectangle { } requestedKind = kind; requestedGeneration = generation; + requestTimedOut = false; reader.command = ["python3", decodeURIComponent(Qt.resolvedUrl("metric-details.py").toString().replace(/^file:\/\//, "")), kind]; reader.running = true; } + function timeoutReader() { + if (!reader.running) + return; + requestTimedOut = true; + if (active && kind === requestedKind && generation === requestedGeneration) + errorText = "System details timed out"; + // Signalling preserves running until the actual exit; a queued refresh + // must not reuse the worker while cancellation is still pending. + reader.signal(9); + } onKindChanged: { generation++; relativeBars = false; @@ -58,12 +70,17 @@ Rectangle { running: root.active && root.visible onTriggered: root.refresh() } + Timer { + interval: 8000 + running: reader.running + onTriggered: root.timeoutReader() + } Process { id: reader stdout: StdioCollector { waitForEnd: true onStreamFinished: { - if (!root.active || root.kind !== root.requestedKind || root.generation !== root.requestedGeneration) + if (!root.active || root.requestTimedOut || root.kind !== root.requestedKind || root.generation !== root.requestedGeneration) return; try { var result = JSON.parse(text); @@ -78,7 +95,7 @@ Rectangle { } } onExited: (code, status) => { - if (code !== 0 && root.active && root.kind === root.requestedKind && root.generation === root.requestedGeneration) + if (code !== 0 && !root.requestTimedOut && root.active && root.kind === root.requestedKind && root.generation === root.requestedGeneration) root.errorText = "Details helper failed"; if (root.queuedKind) { root.queuedKind = ""; diff --git a/bars/widgets/ruixen.notch/metric-details.py b/bars/widgets/ruixen.notch/metric-details.py index 2588141..f0a033c 100644 --- a/bars/widgets/ruixen.notch/metric-details.py +++ b/bars/widgets/ruixen.notch/metric-details.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 """Read-only, bounded system details for the dashboard's open detail page.""" -import argparse +import heapq import json import os import re -import selectors +import sys from itertools import islice from pathlib import Path -import subprocess import time def command(args): + # Only the network page needs command execution; other pages avoid these + # imports and child processes on each refresh. + import selectors + import subprocess # Bound the buffer while reading, rather than checking after capture_output. environment = dict(os.environ, LC_ALL="C") with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, @@ -49,37 +52,52 @@ def size(value): value /= 1024 -def processes(order, capacity=None): - rows = [] - output = command(["ps", "-eo", "pid=,comm=,pcpu=,rss=", "--sort=-" + order]) - for line in output.splitlines()[:10]: - parts = line.split() - if len(parts) < 4: +def process_stats(): + """One bounded read per process, shared by CPU and resident RAM readers.""" + for proc in islice(Path("/proc").iterdir(), 8192): + if not proc.name.isdigit(): + continue + try: + stat = (proc / "stat").read_text() + end = stat.rfind(")") + fields = stat[end + 2:].split() + if len(fields) < 22: + continue + yield proc.name, stat[stat.index("(") + 1:end], fields + except (OSError, ValueError): + continue + + +def memory_processes(capacity): + page_size = os.sysconf("SC_PAGE_SIZE") + candidates = [] + for pid, name, fields in process_stats(): + try: + amount = int(fields[21]) * page_size + except ValueError: continue - pid, name, cpu, rss = parts[0], " ".join(parts[1:-2]), parts[-2], parts[-1] - amount = int(rss) * 1024 - row = {"label": f"{name} · PID {pid}", "value": f"{cpu}% CPU · {size(amount)} RAM"} - if capacity: - share = amount / capacity - row.update(value=size(amount) + f" · {share * 100:.1f}% of RAM", fraction=share) - rows.append(row) - return rows + if amount >= 0: + candidates.append((amount, pid, name)) + return [{"label": f"{name} · PID {pid}", + "value": size(amount) + f" · {amount / capacity * 100:.1f}% of RAM", + "fraction": amount / capacity} + for amount, pid, name in heapq.nlargest(10, candidates, key=lambda row: row[0])] + + +def mount_lines(): + with Path("/proc/self/mounts").open() as mounts: + yield from islice(mounts, 128) def cpu_snapshot(): values = [int(v) for v in Path("/proc/stat").read_text().splitlines()[0].split()[1:9]] total, idle = sum(values), values[3] + values[4] items = {} - for proc in islice(Path("/proc").iterdir(), 8192): - if not proc.name.isdigit(): - continue + for pid, name, fields in process_stats(): try: - stat = (proc / "stat").read_text() - end = stat.rfind(")") - fields = stat[end + 2:].split() # PID plus start time prevents attributing a reused PID's old ticks. - items[(proc.name, fields[19])] = (stat[stat.index("(") + 1:end], int(fields[11]) + int(fields[12])) - except (OSError, ValueError, IndexError): + items[(pid, fields[19])] = (name, int(fields[11]) + int(fields[12])) + except ValueError: continue return total, idle, items @@ -122,7 +140,21 @@ def drm_fields(text): return identity, engines, capacities, memory -def gpu_snapshot(): +def gpu_snapshot(previous=None): + if previous is not None: + # Discovery is repeated next refresh. The second counter read only + # needs the clients found 0.4 seconds ago; validate identity in case an + # FD closed or was reused rather than attributing a different client. + clients = {} + for identity, client in previous.items(): + try: + parsed = drm_fields(Path(client["fdinfo"]).read_text()) + if parsed and parsed[0] == identity: + clients[identity] = dict(client, sampledAt=time.monotonic(), + engines=parsed[1], capacities=parsed[2], memory=parsed[3]) + except (OSError, ValueError): + continue + return clients clients = {} deadline = time.monotonic() + 2 for proc in islice(Path("/proc").iterdir(), 8192): @@ -143,6 +175,7 @@ def gpu_snapshot(): continue identity, engines, capacities, memory = parsed clients[identity] = {"pid": proc.name, "name": (proc / "comm").read_text().strip(), + "fdinfo": str(proc / "fdinfo" / fd.name), "sampledAt": time.monotonic(), "engines": engines, "capacities": capacities, "memory": memory} except (OSError, ValueError): @@ -222,7 +255,7 @@ def collect(kind): used = total - available summaries = [{"label": "RAM in use / total", "value": size(used) + " / " + size(total), "fraction": used / total, "detail": "Used = total − available. Process RSS rows overlap and do not sum to this total."}] - rows = processes("rss", total) + rows + rows = memory_processes(total) + rows note = "Resident RAM · percentages use physical RAM. Shared pages can appear in multiple processes." elif kind == "network": routes = json.loads(command(["ip", "-j", "route", "show", "default"])) @@ -236,21 +269,27 @@ def collect(kind): note = "Interface state and addresses. Dashboard transfer totals are counters since the interface came up, not a billing-period total." elif kind == "storage": seen = set() - for line in command(["df", "-B1", "--output=source,target,size,used,avail,pcent"]).splitlines()[1:][:128]: - parts = line.rsplit(None, 4) - if len(parts) != 5: + for line in mount_lines(): + parts = line.split() + if len(parts) < 2: continue - source_mount, total, used, available, percent = parts - source, mount = source_mount.split(None, 1) + source, mount = [re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), value) for value in parts[:2]] if not source.startswith("/dev/") or source in seen: continue - seen.add(source) - total_bytes, used_bytes, available_bytes = int(total), int(used), int(available) - if total_bytes <= 0 or used_bytes < 0 or available_bytes < 0: + try: + stat = os.statvfs(mount) + except OSError: + continue + block_size = stat.f_frsize or stat.f_bsize + total_bytes = stat.f_blocks * block_size + used_bytes = (stat.f_blocks - stat.f_bfree) * block_size + available_bytes = stat.f_bavail * block_size + if total_bytes <= 0 or not 0 <= used_bytes <= total_bytes or not 0 <= available_bytes <= total_bytes: continue + seen.add(source) rows.extend([{"label": mount + " · " + source, "value": f"{used_bytes / total_bytes * 100:.1f}% capacity used"}, - {"label": " Used / total", "value": size(used) + " / " + size(total)}, - {"label": " Available", "value": size(available)}]) + {"label": " Used / total", "value": size(used_bytes) + " / " + size(total_bytes)}, + {"label": " Available", "value": size(available_bytes)}]) note = "Device-backed filesystems · used ÷ total capacity, matching the overview. Available space can exclude reserved space. Amounts use binary units." elif kind == "gpu": seen = set() @@ -288,7 +327,7 @@ def collect(kind): before = gpu_snapshot() start = time.monotonic() time.sleep(.4) - after = gpu_snapshot() + after = gpu_snapshot(before) elapsed = time.monotonic() - start ranked = rank_gpu(before, after, elapsed) rows = ranked + rows @@ -300,11 +339,17 @@ def collect(kind): if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("kind", choices=["cpu", "gpu", "memory", "network", "storage"]) - args = parser.parse_args() + kinds = ("cpu", "gpu", "memory", "network", "storage") + usage = "Usage: metric-details.py <" + "|".join(kinds) + ">" + if sys.argv[1:] in [["-h"], ["--help"]]: + print(usage) + sys.exit(0) + if len(sys.argv) != 2 or sys.argv[1] not in kinds: + print(usage, file=sys.stderr) + sys.exit(2) + kind = sys.argv[1] try: - result = collect(args.kind) + result = collect(kind) except Exception: - result = {"kind": args.kind, "rows": [], "note": "", "error": "Unable to read system details. Try refreshing."} + result = {"kind": kind, "rows": [], "note": "", "error": "Unable to read system details. Try refreshing."} print(json.dumps(result)) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index ebf502a..1f0d8a8 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -233,6 +233,7 @@ Network shows interface state and addresses; storage shows mounted-device capacity; GPU shows driver-exposed sensors without requiring privileges. Detail snapshots refresh every three seconds only while the page is open. Closing the tab stops polling. +A stalled worker is terminated after eight seconds. Open the Metrics tab with `omarchy-shell ruixen.notch openDashboardTab metrics`. `ruixen-metrics-details` exposes `open `, `close`, diff --git a/tests/js/MetricDetailsLifecycle.test.js b/tests/js/MetricDetailsLifecycle.test.js index 3627229..0dfb1ab 100644 --- a/tests/js/MetricDetailsLifecycle.test.js +++ b/tests/js/MetricDetailsLifecycle.test.js @@ -25,15 +25,16 @@ const refreshBody = body("function refresh()"); const changedBody = body("onKindChanged:"); const resultBody = body("onStreamFinished:"); const exitBody = body("onExited: (code, status) =>"); +const timeoutBody = body("function timeoutReader()"); const pollExpression = qml.match(/Timer\s*\{[^}]*running:\s*([^\n]+)/)[1]; function harness() { const deferred = []; const state = { - kind: "cpu", active: true, generation: 0, requestedGeneration: -1, + kind: "cpu", active: true, generation: 0, requestedGeneration: -1, requestTimedOut: false, requestedKind: "", queuedKind: "", rows: [], summaries: [], note: "", errorText: "", checkedAt: "", relativeBars: false, - reader: {running: false, command: []}, + reader: {running: false, command: [], signals: [], signal(value) {this.signals.push(value);}}, Qt: { callLater: fn => deferred.push(fn), resolvedUrl: () => ({toString: () => "file:///tmp/plugin%20dir/metric-details.py"}), @@ -46,7 +47,8 @@ function harness() { vm.runInContext("function refresh(){" + refreshBody + "}\n" + "function changed(){" + changedBody + "}\n" + "function result(text){" + resultBody + "}\n" + - "function exited(code,status){" + exitBody + "}", context); + "function exited(code,status){" + exitBody + "}\n" + + "function timeoutReader(){" + timeoutBody + "}", context); return { state, refresh: () => state.refresh(), @@ -148,4 +150,24 @@ test("polling requires both an active tab and a visible detail page", () => { h.state.active = false; assert.equal(h.polling(), false); h.state.active = true; h.select(""); assert.equal(h.polling(), false); }); +test("timeout rejects output and waits for real exit before a queued restart", () => { + const h = harness(); h.refresh(); h.state.timeoutReader(); + assert.deepEqual(h.state.reader.signals, [9]); + assert.equal(h.state.reader.running, true); + h.publish([{label: "timed out"}]); + assert.equal(h.state.rows.length, 0); + h.refresh(); h.finish(-1); + assert.equal(h.state.reader.running, true); + assert.equal(h.state.requestTimedOut, false); + h.publish([{label: "fresh"}]); + assert.equal(h.state.rows[0].label, "fresh"); +}); +test("timeout keeps the specific error and does not poison a different page", () => { + const h = harness(); h.refresh(); h.state.timeoutReader(); h.finish(-1); + assert.equal(h.state.errorText, "System details timed out"); + const next = harness(); next.refresh(); next.select("memory"); next.state.timeoutReader(); + assert.equal(next.state.errorText, ""); + next.finish(-1); + assert.equal(next.state.requestedKind, "memory"); +}); console.log("\n" + passed + " passed, 0 failed"); diff --git a/tests/metric-details.py b/tests/metric-details.py index 196dd77..908ae5d 100644 --- a/tests/metric-details.py +++ b/tests/metric-details.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 import importlib.util +import io from pathlib import Path import tempfile import sys +import subprocess +from types import SimpleNamespace import unittest from unittest.mock import patch @@ -12,7 +15,80 @@ spec.loader.exec_module(details) +def rss_fields(pages): + fields = ["0"] * 22 + fields[21] = str(pages) + return fields + + +def filesystem(total, free, available): + return SimpleNamespace(f_frsize=1, f_bsize=1, f_blocks=total, f_bfree=free, f_bavail=available) + + class DetailsTests(unittest.TestCase): + def test_mount_reader_streams_a_bounded_number_of_lines(self): + class MountStream(io.StringIO): + def read(self, *args): + raise AssertionError("Mount input must not be buffered whole") + def readlines(self, *args): + raise AssertionError("Mount input must not be buffered whole") + fixture = MountStream("/dev/a / ext4 rw 0 0\n" * 200) + with patch.object(details.Path, "open", return_value=fixture): + self.assertEqual(len(list(details.mount_lines())), 128) + + def test_process_reader_preserves_parentheses_and_skips_short_stats(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "42").mkdir() + (root / "42/stat").write_text("42 (worker (nested) process) " + " ".join(rss_fields(8))) + (root / "99").mkdir() + (root / "99/stat").write_text("99 (short) S 1") + with patch.object(details, "Path", side_effect=lambda value: root if value == "/proc" else Path(value)): + entries = list(details.process_stats()) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0][1], "worker (nested) process") + + def test_memory_ranks_resident_pages_and_ignores_invalid_counts(self): + fixture = [("1", "small", rss_fields(1)), ("2", "large", rss_fields(5)), + ("3", "invalid", rss_fields("invalid")), ("4", "negative", rss_fields(-1))] + with patch.object(details, "process_stats", return_value=iter(fixture)), patch.object(details.os, "sysconf", return_value=4096): + rows = details.memory_processes(4096 * 10) + self.assertEqual([row["label"] for row in rows], ["large · PID 2", "small · PID 1"]) + self.assertEqual(rows[0]["fraction"], .5) + + def test_gpu_second_sample_reuses_descriptors_but_rejects_replaced_clients(self): + with tempfile.TemporaryDirectory() as directory: + info = Path(directory) / "fdinfo" + text = "drm-driver: amdgpu\ndrm-client-id: 9\ndrm-pdev: pci0\ndrm-engine-gfx: 100 ns\n" + info.write_text(text) + identity = ("pci0", "9") + previous = {identity: {"pid": "42", "name": "worker", "fdinfo": str(info), "sampledAt": 1}} + with patch.object(details.os, "scandir", side_effect=AssertionError("Must not rediscover descriptors")): + second = details.gpu_snapshot(previous) + self.assertEqual(second[identity]["engines"]["gfx"], 100) + info.write_text(text.replace("id: 9", "id: 10")) + self.assertEqual(details.gpu_snapshot(previous), {}) + info.unlink() + self.assertEqual(details.gpu_snapshot(previous), {}) + + def test_storage_skips_inaccessible_mount_and_uses_block_size_fallback(self): + fixture = "/dev/a /gone ext4 rw 0 0\n/dev/a /ok ext4 rw 0 0\n" + stat = filesystem(100, 80, 10) + stat.f_frsize = 0 + stat.f_bsize = 4096 + with patch.object(details, "mount_lines", return_value=iter(fixture.splitlines())), patch.object(details.os, "statvfs", side_effect=[OSError(), stat]): + rows = details.collect("storage")["rows"] + self.assertEqual(len(rows), 3) + self.assertEqual(rows[0]["value"], "20.0% capacity used") + self.assertEqual(rows[1]["value"], "80.0 KiB / 400.0 KiB") + + def test_cli_accepts_only_known_kinds_and_has_help(self): + script = ROOT / "bars/widgets/ruixen.notch/metric-details.py" + for args, code in [([], 2), (["wrong"], 2), (["cpu", "extra"], 2), (["--help"], 0)]: + result = subprocess.run([sys.executable, str(script), *args], capture_output=True, text=True) + self.assertEqual(result.returncode, code) + self.assertIn("Usage:", result.stdout + result.stderr) + def test_missing_or_invalid_available_ram_does_not_report_fake_usage(self): from unittest.mock import Mock for fixture in ["MemTotal: 1024 kB\n", "MemTotal: 1024 kB\nMemAvailable: 2048 kB\n"]: @@ -38,11 +114,11 @@ def test_command_bounds_output_during_read(self): details.command([sys.executable, "-c", "import sys; sys.stdout.write('x' * (2 * 1024 * 1024))"]) def test_command_reports_nonzero_exit(self): - with self.assertRaises(details.subprocess.CalledProcessError): + with self.assertRaises(subprocess.CalledProcessError): details.command([sys.executable, "-c", "raise SystemExit(2)"]) def test_command_times_out_and_reaps_worker(self): - with self.assertRaises(details.subprocess.TimeoutExpired): + with self.assertRaises(subprocess.TimeoutExpired): details.command([sys.executable, "-c", "import time; time.sleep(30)"]) def test_gpu_uses_each_clients_counter_interval(self): @@ -55,13 +131,13 @@ def test_gpu_uses_each_clients_counter_interval(self): def test_memory_summary_uses_available_and_rows_use_physical_total(self): fixture = "MemTotal: 1024 kB\nMemAvailable: 512 kB\nCached: 128 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n" from unittest.mock import Mock - with patch.object(details, "Path", return_value=Mock(read_text=lambda: fixture)), patch.object(details, "command", return_value="42 worker 0.0 256\n"): + with patch.object(details, "Path", return_value=Mock(read_text=lambda: fixture)), patch.object(details, "process_stats", return_value=iter([("42", "worker", rss_fields(64))])), patch.object(details.os, "sysconf", return_value=4096): result = details.collect("memory") self.assertEqual(result["summaries"][0]["fraction"], .5) self.assertEqual(result["rows"][0]["fraction"], .25) def test_storage_percentage_uses_total_not_df_reserved_space_percent(self): - with patch.object(details, "command", return_value="header\n/dev/a / 100 20 20 50%\n"): + with patch.object(details, "mount_lines", return_value=iter(["/dev/a / ext4 rw 0 0"])), patch.object(details.os, "statvfs", return_value=filesystem(100, 80, 20)): result = details.collect("storage") self.assertEqual(result["rows"][0]["value"], "20.0% capacity used") @@ -105,8 +181,8 @@ def test_cpu_uses_current_sample_and_total_capacity(self): self.assertIn("2.00 cores", rows[0]["detail"]) def test_memory_bar_uses_physical_ram_capacity(self): - with patch.object(details, "command", return_value="123 worker 1.0 1024\n"): - row = details.processes("rss", capacity=4 * 1024 * 1024)[0] + with patch.object(details, "process_stats", return_value=iter([("123", "worker", rss_fields(256))])), patch.object(details.os, "sysconf", return_value=4096): + row = details.memory_processes(4 * 1024 * 1024)[0] self.assertEqual(row["fraction"], .25) self.assertIn("25.0% of RAM", row["value"]) @@ -124,16 +200,16 @@ def test_gpu_engine_capacity_and_resident_alias(self): self.assertEqual(parsed[2]["gfx"], 2) def test_process_names_with_spaces_and_limit(self): - line = "123 worker process 2.5 1024\n" - with patch.object(details, "command", return_value=line * 12): - rows = details.processes("rss") + fixture = [(str(i), "worker process", rss_fields(256)) for i in range(12)] + with patch.object(details, "process_stats", return_value=iter(fixture)), patch.object(details.os, "sysconf", return_value=4096): + rows = details.memory_processes(4 * 1024 * 1024) self.assertEqual(len(rows), 10) - self.assertEqual(rows[0]["label"], "worker process · PID 123") + self.assertEqual(rows[0]["label"], "worker process · PID 0") self.assertIn("1.0 MiB", rows[0]["value"]) def test_storage_deduplicates_devices_and_preserves_mount_spaces(self): - fixture = "header\n/dev/a / 10240 2048 8192 20%\n/dev/a /home 10240 2048 8192 20%\n/dev/b /media/My Disk 4096 1024 3072 25%\ntmpfs /run 1024 100 924 10%\n" - with patch.object(details, "command", return_value=fixture): + fixture = "/dev/a / ext4 rw 0 0\n/dev/a /home ext4 rw 0 0\n/dev/b /media/My\\040Disk ext4 rw 0 0\ntmpfs /run tmpfs rw 0 0\n" + with patch.object(details, "mount_lines", return_value=iter(fixture.splitlines())), patch.object(details.os, "statvfs", return_value=filesystem(10240, 8192, 8192)): rows = details.collect("storage")["rows"] self.assertEqual(len(rows), 6) self.assertEqual(rows[3]["label"], "/media/My Disk · /dev/b") From 1696070788146419e55e381586a3589ee25a9224 Mon Sep 17 00:00:00 2001 From: Jesse Gillespie Date: Sun, 18 Oct 2026 01:05:36 -0500 Subject: [PATCH 3/3] Fix ruixen-repair.sh git checkout detection in linked worktrees --- ruixen-repair.sh | 4 +++- tests/repair-drift-detection.sh | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ruixen-repair.sh b/ruixen-repair.sh index aba30be..d14b67a 100755 --- a/ruixen-repair.sh +++ b/ruixen-repair.sh @@ -39,7 +39,9 @@ for arg in "$@"; do esac done -[[ -d "$script_dir/.git" ]] || { +# Git validates both a normal checkout's directory and a linked worktree's +# gitfile. Checking this exact path also rejects copies inside another repo. +git rev-parse --resolve-git-dir "$script_dir/.git" >/dev/null 2>&1 || { printf 'ruixen-repair: %s is not a git checkout -- clone the repo with git instead of copying files out of it\n' "$script_dir" >&2 exit 1 } diff --git a/tests/repair-drift-detection.sh b/tests/repair-drift-detection.sh index 6b4ac65..47ddecd 100755 --- a/tests/repair-drift-detection.sh +++ b/tests/repair-drift-detection.sh @@ -83,5 +83,30 @@ out5="$(run_repair --dry-run)" check "after real repair: a follow-up dry-run reports healthy again" \ "$(grep -c 'Healthy -- every plugin file matches' <<<"$out5")" "1" +# Exercise checkout metadata independently of whichever checkout runs this suite. +fixture_repo="$fake_home/checkout" +git init -q "$fixture_repo" +cp "$repo_dir/ruixen-repair.sh" "$fixture_repo/" +git -C "$fixture_repo" add ruixen-repair.sh +git -C "$fixture_repo" -c user.name=Test -c user.email=test@example.com \ + -c commit.gpgsign=false commit -qm 'Repair fixture' +git -C "$fixture_repo" worktree add -q --detach "$fake_home/worktree" HEAD +for checkout in "$fixture_repo" "$fake_home/worktree"; do + status=0 + HOME="$fake_home" bash "$checkout/ruixen-repair.sh" --dry-run \ + >"$fake_home/checkout.out" 2>&1 || status=$? + check "$(basename "$checkout"): valid checkout accepted" "$status" "0" +done + +# A copied script must not pass just because a parent directory is a repo. +mkdir "$fixture_repo/copied" +cp "$repo_dir/ruixen-repair.sh" "$fixture_repo/copied/" +status=0 +HOME="$fake_home" bash "$fixture_repo/copied/ruixen-repair.sh" --dry-run \ + >"$fake_home/copied.out" 2>&1 || status=$? +check "copied script without its own Git metadata is rejected" "$status" "1" +check "copied script explains the checkout requirement" \ + "$(grep -c 'is not a git checkout' "$fake_home/copied.out")" "1" + printf '\n%d passed, %d failed\n' "$pass" "$fail_count" [[ "$fail_count" -eq 0 ]]