From cb936e93be75bae93fbd5a81f39376cd31142e71 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 09:34:51 +0200 Subject: docs(spec): user details in the drawer A drawer row greets the login user and opens a page that edits name, email, avatar, language and location, written where other programs already read them: AccountsService (and through it the passwd GECOS LibreOffice reads), KDE's emaildefaults and the global git config, with the surname shortened to an initial for git. Co-Authored-By: Claude Opus 5.5 --- desktop/island/perf/Perf.js | 187 --------------------- desktop/island/perf/Perf.qml | 303 --------------------------------- desktop/island/perf/PerfPanel.qml | 336 ------------------------------------- desktop/island/perf/test_perf.js | 153 ----------------- desktop/modules/perf/Perf.js | 187 +++++++++++++++++++++ desktop/modules/perf/Perf.qml | 303 +++++++++++++++++++++++++++++++++ desktop/modules/perf/PerfPanel.qml | 336 +++++++++++++++++++++++++++++++++++++ desktop/modules/perf/test_perf.js | 153 +++++++++++++++++ 8 files changed, 979 insertions(+), 979 deletions(-) delete mode 100644 desktop/island/perf/Perf.js delete mode 100644 desktop/island/perf/Perf.qml delete mode 100644 desktop/island/perf/PerfPanel.qml delete mode 100644 desktop/island/perf/test_perf.js create mode 100644 desktop/modules/perf/Perf.js create mode 100644 desktop/modules/perf/Perf.qml create mode 100644 desktop/modules/perf/PerfPanel.qml create mode 100644 desktop/modules/perf/test_perf.js (limited to 'desktop') diff --git a/desktop/island/perf/Perf.js b/desktop/island/perf/Perf.js deleted file mode 100644 index b853f50..0000000 --- a/desktop/island/perf/Perf.js +++ /dev/null @@ -1,187 +0,0 @@ -.pragma library - -// Copyright (C) 2026 Danilo M. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License version 2 as -// published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// The performance panel's parsing and arithmetic, kept pure so it runs under -// node (test_perf.js). Perf.qml does the reading; everything here takes text -// or numbers and returns values. A reading that cannot be made is null, and -// the panel draws null as a dash. - -const UNITS = { "": 1, B: 1, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3 }; - -// A public IP older than this is not shown: by then the fetch has failed -// for hours, and an address that may no longer be ours is worse than a dash. -const STALE = 4 * 3600; - -// One {total, idle} per cpu line of /proc/stat; index 0 is the aggregate, -// then the cores in file order. idle counts iowait, as conky did. Only the -// first eight fields are summed: guest and guest_nice are already inside -// user and nice, and adding them again overstates load while VMs run. -function cpuTimes(stat) { - const out = []; - for (const line of stat.split("\n")) { - const m = /^cpu\d*\s+(.*)$/.exec(line); - if (!m) continue; - const v = m[1].trim().split(/\s+/).map(Number); - if (v.length < 5) continue; - out.push({ total: v.slice(0, 8).reduce((a, b) => a + b, 0), idle: v[3] + v[4] }); - } - return out; -} - -// Percent busy between two samples. -function load(prev, cur) { - if (!prev || !cur) return null; - const dt = cur.total - prev.total; - if (dt <= 0) return null; - return Math.max(0, Math.min(100, 100 * (1 - (cur.idle - prev.idle) / dt))); -} - -// {total, used} in bytes; used is MemTotal - MemAvailable. -function meminfo(text) { - const kb = key => { - const m = new RegExp("^" + key + ":\\s+(\\d+)", "m").exec(text); - return m ? Number(m[1]) * 1024 : null; - }; - const total = kb("MemTotal"), avail = kb("MemAvailable"); - if (total === null || avail === null) return null; - return { total: total, used: total - avail }; -} - -// Rows of `df -P -B1`. The header fails the percent match and is skipped. -// host is the NFS server from the device field, "" for a local disk, so the -// panel labels shares without an address in the repo. -function df(text) { - const out = []; - for (const line of text.split("\n")) { - const m = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(\S+)\s*$/.exec(line); - if (!m) continue; - const h = /^([^/:]+):/.exec(m[1]); - out.push({ mount: m[6], host: h ? h[1] : "", size: Number(m[2]), - used: Number(m[3]), pct: Number(m[5]) }); - } - return out; -} - -// 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) { - const fds = {}; - for (const line of text.split("\n")) { - const m = /^([^:]*):(drm-[\w-]+):\s*(.*)$/.exec(line); - if (!m) continue; - (fds[m[1]] = fds[m[1]] || {})[m[2]] = m[3]; - } - const seen = {}; - let total = null; - for (const path in fds) { - const fd = fds[path]; - const id = fd["drm-client-id"]; - const n = /^(\d+)\s*(\w*)/.exec(fd["drm-resident-vram0"] || ""); - if (fd["drm-driver"] !== "xe" || !id || !n || seen[id]) continue; - seen[id] = true; - total = (total || 0) + Number(n[1]) * (UNITS[n[2]] || 1); - } - return total; -} - -// Bytes per second between two counter readings. A counter that went -// backwards (interface reset) gives null rather than a negative spike. -function rate(prev, cur, secs) { - if (prev === null || cur === null || !(secs > 0) || cur < prev) return null; - return (cur - prev) / secs; -} - -// `grep -H . /sys/class/hwmon/hwmon*/name` -> {chip: dir}. hwmonN numbers -// change across boots, so chips are found by name. -function hwmonMap(text) { - const map = {}; - for (const line of text.split("\n")) { - const m = /^(.*)\/name:(.+)$/.exec(line); - if (m && !(m[2] in map)) map[m[2]] = m[1]; - } - return map; -} - -// `ip -4 -o addr show ` -> the first address, without its prefix. -function lanAddr(text) { - const m = /\binet (\d+\.\d+\.\d+\.\d+)\//.exec(text); - return m ? m[1] : ""; -} - -// The first model name in /proc/cpuinfo, trimmed the way conky trimmed it. -function cpuModel(cpuinfo) { - const m = /^model name\s*:\s*(.+)$/m.exec(cpuinfo); - if (!m) return ""; - return m[1].replace(/\((R|TM|tm)\)/g, "") - .replace(/^\s*\d+th Gen\s+/, "") - .replace(/^\s*(AMD|Intel)\s+/, "") - .replace(/\s+\d+-Core Processor.*$/, "") - .replace(/\s+/g, " ").trim(); -} - -// "ok", "warn", "crit", or "" for a value that could not be read. -function band(v, warn, crit) { - if (v === null || v === undefined) return ""; - return v >= crit ? "crit" : v >= warn ? "warn" : "ok"; -} - -// The worst of a list of bands; unknowns never raise it. -function worst(bands) { - if (bands.indexOf("crit") >= 0) return "crit"; - if (bands.indexOf("warn") >= 0) return "warn"; - return "ok"; -} - -function isIPv4(s) { - return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split(".").every(o => Number(o) <= 255); -} - -// conky's cache format: "ip " and "fetched " lines. -function parsePubip(text) { - const ip = /^ip (\S+)$/m.exec(text), at = /^fetched (\d+)$/m.exec(text); - return ip && at && isIPv4(ip[1]) ? { ip: ip[1], fetched: Number(at[1]) } : null; -} - -function formatPubip(ip, fetched) { - return "ip " + ip + "\nfetched " + fetched + "\n"; -} - -// What the panel shows and whether the icon warns. Only a fresh value can -// mismatch: a failed fetch leaves the old one to go stale, never to alarm. -function ipCheck(pub, now, pin) { - const fresh = !!pub && now - pub.fetched < STALE; - return { ip: fresh ? pub.ip : "", mismatch: fresh && !!pin && pub.ip !== pin }; -} - -// Whether a fetch is due: no value yet, or one at least `every` seconds old. -function due(pub, now, every) { - return !pub || now - pub.fetched >= every; -} - -// A history with v appended, oldest dropped past n. -function push(list, v, n) { - const out = list.concat([v]); - return out.length > n ? out.slice(out.length - n) : out; -} - -function bytes(n) { - if (n === null || n === undefined) return "--"; - const u = ["B", "K", "M", "G", "T"]; - let i = 0; - while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } - return (i ? n.toFixed(1) : String(Math.round(n))) + u[i]; -} - -function speed(n) { - return n === null || n === undefined ? "--" : bytes(n) + "/s"; -} diff --git a/desktop/island/perf/Perf.qml b/desktop/island/perf/Perf.qml deleted file mode 100644 index 53d8670..0000000 --- a/desktop/island/perf/Perf.qml +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License version 2 as -// published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -pragma Singleton - -import Quickshell -import Quickshell.Io -import QtQuick -import QtQml -import "../.." -import "Perf.js" as P - -// The numbers behind the performance panel and the status pill's icon. CPU, -// RAM, temperatures and disks are sampled always, so the icon can warn while -// the panel is closed; per-core load, the network rate, VRAM and the fan only -// while `detailed`, which is any PerfPanel existing (island or drawer). -// /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the -// fdinfo scan, ip and curl are Processes. -Singleton { - id: root - - // PerfPanel instances alive; each counts itself in and out. - property int viewers: 0 - readonly property bool detailed: viewers > 0 - - // ponytail: host constants, as conky had them. One mount per NFS server - // (Library and Slackware are one export, so are shared and backup); br0 - // is a bridge, so VM-to-host copies show in the graph; xe puts no VRAM - // capacity in sysfs. Edit here when the hardware changes. - readonly property var mounts: ["/", "/home", "/data", "/mnt/nfs/Library", "/mnt/nfs/shared"] - readonly property string iface: "br0" - readonly property real vramTotal: 12 * 1024 * 1024 * 1024 - readonly property string gpuName: "ARC B580" - - // Conky's table. Ceilings are per sensor: 70C is unremarkable on a CPU - // package and alarming on an NVMe. GPU and VRAM are the GPU card's; the - // rest are the CPU card's rows. - readonly property var sensors: [ - { chip: "k10temp", file: "temp1_input", label: "CPU", warn: 75, crit: 90 }, - { chip: "k10temp", file: "temp3_input", label: "CCD1", warn: 75, crit: 90 }, - { chip: "nvme", file: "temp1_input", label: "NVME", warn: 60, crit: 70 }, - { chip: "gigabyte_wmi", file: "temp2_input", label: "BOARD", warn: 60, crit: 70 }, - { chip: "gigabyte_wmi", file: "temp3_input", label: "BOARD", warn: 60, crit: 70 }, - { chip: "xe", file: "temp2_input", label: "GPU", warn: 75, crit: 85 }, - { chip: "xe", file: "temp3_input", label: "VRAM", warn: 80, crit: 90 }, - ] - - property var hwmon: ({}) // chip name -> /sys/class/hwmon/hwmonN - property string cpuModel: "" - property var load: null // aggregate percent - property var cores: [] // percent per core, while detailed - property var mem: null // {total, used} bytes - property var temps: [] // [{label, value, warn, crit}], value null if unread - property var disks: [] // Perf.js df rows - property var vram: null // bytes, while detailed - property var fan: null // rpm, while detailed - property var rx: [] // bytes/s, newest last, while detailed - property var tx: [] - property string lan: "" - property var pub: null // {ip, fetched} - property real now: Date.now() / 1000 - - readonly property var ip: P.ipCheck(root.pub, root.now, Prefs.pinnedIp) - - // The icon's dot. CPU load is left out: spikes would flicker it, and - // sustained load shows up as heat. - readonly property string severity: P.worst( - root.temps.map(t => P.band(t.value, t.warn, t.crit)) - .concat([P.band(root.mem ? 100 * root.mem.used / root.mem.total : null, 85, 95)]) - .concat(root.disks.map(d => P.band(d.pct, 90, 95))) - .concat([root.ip.mismatch ? "crit" : "ok"])) - - readonly property string pubipPath: - (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/udt/pubip.txt" - - property var prevCpu: [] - property var prevNet: null // {rx, tx, t} - - function num(view) { - const v = parseInt(view.text(), 10); - return isNaN(v) ? null : v; - } - - // Measured from the last attempt, not the last success, so a failing - // fetch (rate limited, offline) still waits 30 minutes; and never over - // one already running, which would kill it before it could answer. - property real pubAttempt: 0 - function fetchPubip() { - const now = Date.now() / 1000; - if (pubProc.running || !P.due(root.pub, now, 1800) || now - root.pubAttempt < 1800) return; - root.pubAttempt = now; - pubProc.running = true; - } - - onDetailedChanged: { - if (detailed) { - if (!root.lan) lanProc.running = true; - root.fetchPubip(); - } else { - root.cores = []; - root.rx = []; - root.tx = []; - root.prevNet = null; - root.vram = null; - root.fan = null; - } - } - - // --- /proc and /sys --------------------------------------------------- - - FileView { id: stat; path: "/proc/stat"; blockLoading: true } - FileView { id: meminfo; path: "/proc/meminfo"; blockLoading: true } - FileView { - path: "/proc/cpuinfo" - blockLoading: true - onLoaded: root.cpuModel = P.cpuModel(text()) - } - - Instantiator { - id: tempViews - model: root.sensors - delegate: FileView { - required property var modelData - path: root.hwmon[modelData.chip] ? root.hwmon[modelData.chip] + "/" + modelData.file : "" - blockLoading: true - printErrors: false - } - } - - FileView { - id: fanView - path: root.hwmon.xe ? root.hwmon.xe + "/fan1_input" : "" - blockLoading: true - printErrors: false - } - - FileView { - id: rxView - path: "/sys/class/net/" + root.iface + "/statistics/rx_bytes" - blockLoading: true - printErrors: false - } - - FileView { - id: txView - path: "/sys/class/net/" + root.iface + "/statistics/tx_bytes" - blockLoading: true - printErrors: false - } - - Process { - running: true - command: ["sh", "-c", "grep -H . /sys/class/hwmon/hwmon*/name"] - stdout: StdioCollector { onStreamFinished: root.hwmon = P.hwmonMap(text) } - } - - // CPU, RAM and temperatures always; the fan and VRAM while detailed. - Timer { - interval: 2000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: { - root.now = Date.now() / 1000; - - stat.reload(); - const cur = P.cpuTimes(stat.text()); - root.load = P.load(root.prevCpu[0], cur[0]); - root.cores = root.detailed - ? cur.slice(1).map((c, i) => P.load(root.prevCpu[i + 1], c) ?? 0) - : []; - root.prevCpu = cur; - - meminfo.reload(); - root.mem = P.meminfo(meminfo.text()); - - const t = []; - for (let i = 0; i < root.sensors.length; i++) { - const s = root.sensors[i]; - const v = tempViews.objectAt(i); - let value = null; - if (v && v.path) { - v.reload(); - const n = root.num(v); - value = n === null ? null : n / 1000; - } - t.push({ label: s.label, value: value, warn: s.warn, crit: s.crit }); - } - root.temps = t; - - if (root.detailed) { - if (fanView.path) { fanView.reload(); root.fan = root.num(fanView); } - if (!vramProc.running) vramProc.running = true; - } - } - } - - // ponytail: a shell glob over every fd on the system, ~14ms; only while - // the panel is open. Only the user's own processes are readable, so a - // system service's GPU use is not counted, as in conky. - Process { - id: vramProc - command: ["sh", "-c", "grep -sH -e '^drm-driver' -e '^drm-client-id' " - + "-e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null"] - stdout: StdioCollector { onStreamFinished: root.vram = P.vram(text) } - } - - // --- network ------------------------------------------------------------ - - Timer { - interval: 1000 - running: root.detailed - repeat: true - triggeredOnStart: true - onTriggered: { - rxView.reload(); - txView.reload(); - const cur = { rx: root.num(rxView), tx: root.num(txView), t: Date.now() / 1000 }; - const p = root.prevNet; - if (p) { - const r = P.rate(p.rx, cur.rx, cur.t - p.t); - const s = P.rate(p.tx, cur.tx, cur.t - p.t); - if (r !== null) root.rx = P.push(root.rx, r, 60); - if (s !== null) root.tx = P.push(root.tx, s, 60); - } - root.prevNet = cur; - } - } - - Process { - id: lanProc - command: ["ip", "-4", "-o", "addr", "show", root.iface] - stdout: StdioCollector { onStreamFinished: root.lan = P.lanAddr(text) } - } - - // --- disks -------------------------------------------------------------- - - // df under timeout: an unreachable NFS server blocks statfs, and the - // whole call then yields nothing, so the last rows are kept. - Timer { - interval: 60000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: { dfProc.running = false; dfProc.running = true; } - } - - Process { - id: dfProc - command: ["timeout", "-k", "2", "5", "/usr/bin/df", "-P", "-B1"].concat(root.mounts) - stdout: StdioCollector { - onStreamFinished: { - const rows = P.df(text); - if (rows.length) root.disks = rows; - } - } - } - - // --- public IP ---------------------------------------------------------- - - // conky's cache file, so the address survives a restart and the fetch - // keeps conky's 30 minute pace. Checked every minute, fetched when due. - FileView { - id: pubFile - path: root.pubipPath - atomicWrites: true - watchChanges: true - printErrors: false - onFileChanged: reload() - onLoaded: root.pub = P.parsePubip(text()) - } - - Timer { - interval: 60000 - running: true - repeat: true - onTriggered: root.fetchPubip() - } - - // Anything but an IPv4 address (a failure, an error page) leaves the - // cache alone: the old value then goes stale rather than mismatching. - Process { - id: pubProc - command: ["curl", "-4fsS", "--max-time", "10", "https://ipinfo.io/ip"] - stdout: StdioCollector { - onStreamFinished: { - const ip = text.trim(); - if (!P.isIPv4(ip)) return; - const at = Math.floor(Date.now() / 1000); - root.pub = { ip: ip, fetched: at }; - pubFile.setText(P.formatPubip(ip, at)); - } - } - } -} diff --git a/desktop/island/perf/PerfPanel.qml b/desktop/island/perf/PerfPanel.qml deleted file mode 100644 index f149d82..0000000 --- a/desktop/island/perf/PerfPanel.qml +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License version 2 as -// published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -import Quickshell -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 { - id: panel - - columns: 3 - columnSpacing: 8 - rowSpacing: 8 - - readonly property real col: (width - 2 * columnSpacing) / 3 - readonly property var gpuTemp: sensor("GPU") - readonly property var vramTemp: sensor("VRAM") - - function tone(v, warn, crit) { - const b = P.band(v, warn, crit); - return b === "crit" ? Theme.red : b === "warn" ? Theme.yellow : b === "ok" ? Theme.green : Theme.subtext; - } - function deg(v) { return v === null ? "--" : Math.round(v) + "°"; } - 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. - Component.onCompleted: Perf.viewers++ - Component.onDestruction: Perf.viewers-- - - function pct(part, whole) { return part === null || !whole ? null : 100 * part / whole; } - function last(list) { return list.length ? list[list.length - 1] : null; } - // Conky's glyphs, re-checked in Inconsolata Nerd Font. - function glyph(d) { - if (d.host) return String.fromCodePoint(0xF06F3); // network share - return String.fromCodePoint({ "/": 0xF318, "/home": 0xF015 }[d.mount] ?? 0xF02CA); - } - - component Label: Text { - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - color: Theme.text - } - - // A label left, a value right, an optional glyph before the label. - component Pair: Item { - property string glyph: "" - property string label: "" - property string value: "" - property color labelColor: Theme.subtext - property color valueColor: Theme.text - width: parent ? parent.width : 0 - implicitHeight: valueText.implicitHeight - - Row { - spacing: 6 - Text { - visible: text !== "" - text: parent.parent.glyph - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } - color: parent.parent.labelColor - } - Label { text: parent.parent.label; color: parent.parent.labelColor } - } - Label { - id: valueText - anchors.right: parent.right - text: parent.value - color: parent.valueColor - } - } - - component Bar: Rectangle { - property real frac: 0 - property color fill: Theme.green - width: parent ? parent.width : 0 - height: 5 - radius: 3 - color: Theme.surfaceAlt - Rectangle { - width: parent.width * Math.max(0, Math.min(1, parent.frac)) - height: parent.height - radius: 3 - color: parent.fill - } - } - - component Card: Rectangle { - default property alias content: body.data - property string title: "" - property string headline: "" - property color headlineColor: Theme.text - radius: 10 - color: Qt.alpha(Theme.surface, 0.6) - implicitHeight: body.implicitHeight + 20 - Layout.fillHeight: true - - Column { - id: body - x: 10 - y: 10 - width: parent.width - 20 - spacing: 5 - - Item { - width: parent.width - height: Math.max(titleText.implicitHeight, headText.implicitHeight) - Text { - id: titleText - anchors.verticalCenter: parent.verticalCenter - text: parent.parent.parent.title - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } - color: Theme.subtext - } - Text { - id: headText - anchors.right: parent.right - text: parent.parent.parent.headline - font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 4; bold: true } - color: parent.parent.parent.headlineColor - } - } - } - } - - // --- 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) - - Label { - width: parent.width - text: Perf.cpuModel - color: Theme.subtext - elide: Text.ElideRight - } - - // One bar per core, rising from a common baseline. - Row { - id: eq - width: parent.width - height: 40 - spacing: 2 - Repeater { - model: Perf.cores - delegate: Item { - required property var modelData - width: (eq.width - eq.spacing * (Perf.cores.length - 1)) / Perf.cores.length - height: eq.height - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: Math.max(1, parent.height * modelData / 100) - radius: 1 - color: panel.tone(modelData, 25, 75) - } - } - } - } - - Pair { - label: "RAM" - value: Perf.mem ? P.bytes(Perf.mem.used) + " / " + P.bytes(Perf.mem.total) : "--" - } - Bar { - 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) - } - } - } - - // --- GPU ---------------------------------------------------------------- - - // 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) - - Pair { - label: "VRAM" - value: Perf.vram === null ? "--" : P.bytes(Perf.vram) + " / " + P.bytes(Perf.vramTotal) - } - Bar { - frac: (Perf.vram ?? 0) / Perf.vramTotal - fill: panel.tone(panel.pct(Perf.vram, Perf.vramTotal), 25, 75) - } - Pair { - label: "VRAM temp" - value: panel.deg(panel.vramTemp.value) - valueColor: panel.tone(panel.vramTemp.value, panel.vramTemp.warn, panel.vramTemp.crit) - } - Pair { - label: "Fan" - value: Perf.fan === null ? "--" : Perf.fan + " rpm" - } - } - - // --- network ------------------------------------------------------------ - - Card { - Layout.row: 0 - Layout.column: 2 - Layout.preferredWidth: panel.col - title: "NET · " + Perf.iface - - Canvas { - id: graph - width: parent.width - height: 44 - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); - const max = Math.max(1, ...Perf.rx, ...Perf.tx); - const line = (data, color) => { - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.beginPath(); - data.forEach((v, i) => { - const x = width * i / 59; - const y = height - 1 - (height - 2) * v / max; - if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); - }); - ctx.stroke(); - }; - line(Perf.rx, Theme.teal); - line(Perf.tx, Theme.peach); - } - Connections { - target: Perf - function onRxChanged() { graph.requestPaint(); } - function onTxChanged() { graph.requestPaint(); } - } - } - - Pair { - label: "↓ " + P.speed(panel.last(Perf.rx)) - labelColor: Theme.teal - value: "↑ " + P.speed(panel.last(Perf.tx)) - valueColor: Theme.peach - } - Pair { label: "LAN"; value: Perf.lan || "--" } - - // The public IP, red when it differs from the pinned one. The pin - // stores the address shown; it is lit while they are the same. - Item { - width: parent.width - height: pubText.implicitHeight - - Label { text: "Public"; color: Theme.subtext } - Label { - id: pubText - anchors { right: pin.left; rightMargin: 6 } - text: Perf.ip.ip || "--" - color: Perf.ip.mismatch ? Theme.red : Theme.text - } - Text { - id: pin - anchors { right: parent.right; verticalCenter: pubText.verticalCenter } - text: String.fromCodePoint(0xF0403) - font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } - color: Perf.ip.ip !== "" && Perf.ip.ip === Prefs.pinnedIp ? Theme.text : Theme.overlay - - MouseArea { - anchors.fill: parent - anchors.margins: -4 - enabled: Perf.ip.ip !== "" - cursorShape: Qt.PointingHandCursor - onClicked: Prefs.setPinnedIp(Perf.ip.ip) - } - } - } - } - - // --- disks -------------------------------------------------------------- - - Card { - Layout.row: 1 - Layout.column: 1 - Layout.columnSpan: 2 - Layout.preferredWidth: 2 * panel.col + panel.columnSpacing - title: "DISKS" - - Repeater { - model: Perf.disks - delegate: Column { - required property var modelData - width: parent.width - spacing: 2 - Pair { - glyph: panel.glyph(modelData) - label: modelData.host || modelData.mount - value: modelData.pct + "% · " + P.bytes(modelData.used) + " / " + P.bytes(modelData.size) - valueColor: panel.tone(modelData.pct, 25, 75) - } - Bar { - frac: modelData.pct / 100 - fill: panel.tone(modelData.pct, 25, 75) - } - } - } - } -} diff --git a/desktop/island/perf/test_perf.js b/desktop/island/perf/test_perf.js deleted file mode 100644 index 5f39f5d..0000000 --- a/desktop/island/perf/test_perf.js +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (C) 2026 Danilo M. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License version 2 as -// published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// Run: node desktop/island/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. - -const fs = require("fs"); -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, " + - "ipCheck, due, push, bytes, speed };")(); - -// cpuTimes: aggregate first, then cores; idle is idle + iowait. -const stat = [ - "cpu 100 0 50 800 50 0 0 0 0 0", - "cpu0 50 0 25 400 25 0 0 0 0 0", - "cpu1 50 0 25 400 25 0 0 0 0 0", - "intr 12345", - "ctxt 999", -].join("\n"); -const t = P.cpuTimes(stat); -assert.strictEqual(t.length, 3); -assert.deepStrictEqual(t[0], { total: 1000, idle: 850 }); -assert.deepStrictEqual(t[1], { total: 500, idle: 425 }); -assert.deepStrictEqual(P.cpuTimes(""), []); -// guest and guest_nice are already inside user and nice: not added twice. -assert.deepStrictEqual(P.cpuTimes("cpu 100 0 0 100 0 0 0 0 80 0"), [{ total: 200, idle: 100 }]); - -// load: busy share of the delta; no prev, or no time passed, is null. -assert.strictEqual(P.load({ total: 1000, idle: 850 }, { total: 1100, idle: 925 }), 25); -assert.strictEqual(P.load(undefined, { total: 1, idle: 1 }), null); -assert.strictEqual(P.load({ total: 5, idle: 5 }, { total: 5, idle: 5 }), null); -assert.strictEqual(P.load({ total: 0, idle: 0 }, { total: 100, idle: 0 }), 100); - -// meminfo: used is total - available, in bytes. -const mem = "MemTotal: 32000000 kB\nMemFree: 1000 kB\nMemAvailable: 24000000 kB\n"; -assert.deepStrictEqual(P.meminfo(mem), { total: 32000000 * 1024, used: 8000000 * 1024 }); -assert.strictEqual(P.meminfo("MemTotal: 5 kB\n"), null); - -// df: header skipped, NFS host from the device field, bytes as numbers. -const dfOut = [ - "Filesystem 1-blocks Used Available Capacity Mounted on", - "/dev/mapper/vg-root 263088050176 143921766400 105727733760 58% /", - "192.0.2.11:/Volume1/Library 3926310780928 3185272160256 541516365824 86% /mnt/nfs/Library", -].join("\n"); -const d = P.df(dfOut); -assert.strictEqual(d.length, 2); -assert.deepStrictEqual(d[0], { mount: "/", host: "", size: 263088050176, used: 143921766400, pct: 58 }); -assert.strictEqual(d[1].host, "192.0.2.11"); -assert.strictEqual(d[1].mount, "/mnt/nfs/Library"); -assert.deepStrictEqual(P.df(""), []); - -// vram: summed once per xe client id, units honoured, other drivers ignored. -const fd = [ - "/proc/1/fdinfo/7:drm-driver:\txe", - "/proc/1/fdinfo/7:drm-client-id:\t38", - "/proc/1/fdinfo/7:drm-resident-vram0:\t1024 KiB", - "/proc/1/fdinfo/10:drm-driver:\txe", - "/proc/1/fdinfo/10:drm-client-id:\t38", - "/proc/1/fdinfo/10:drm-resident-vram0:\t1024 KiB", - "/proc/2/fdinfo/5:drm-driver:\txe", - "/proc/2/fdinfo/5:drm-client-id:\t40", - "/proc/2/fdinfo/5:drm-resident-vram0:\t2 MiB", - "/proc/3/fdinfo/4:drm-driver:\tamdgpu", - "/proc/3/fdinfo/4:drm-client-id:\t1", - "/proc/3/fdinfo/4:drm-resident-vram0:\t9 GiB", -].join("\n"); -assert.strictEqual(P.vram(fd), 1024 * 1024 + 2 * 1024 * 1024); -assert.strictEqual(P.vram(""), null); - -// rate: bytes per second; a counter that went backwards is null. -assert.strictEqual(P.rate(1000, 3000, 2), 1000); -assert.strictEqual(P.rate(3000, 1000, 1), null); -assert.strictEqual(P.rate(null, 1000, 1), null); -assert.strictEqual(P.rate(1000, 2000, 0), null); - -// hwmonMap: chip name -> directory, first one wins. -const names = "/sys/class/hwmon/hwmon1/name:nvme\n/sys/class/hwmon/hwmon5/name:xe\n/sys/class/hwmon/hwmon7/name:nvme\n"; -assert.deepStrictEqual(P.hwmonMap(names), - { nvme: "/sys/class/hwmon/hwmon1", xe: "/sys/class/hwmon/hwmon5" }); - -// lanAddr: the address without its prefix length. -assert.strictEqual(P.lanAddr("4: br0 inet 192.0.2.5/24 brd 192.0.2.255 scope global br0\n"), "192.0.2.5"); -assert.strictEqual(P.lanAddr(""), ""); - -// cpuModel: vendor, trademarks and the core-count tail dropped. -assert.strictEqual(P.cpuModel("processor\t: 0\nmodel name\t: AMD Ryzen 9 7950X 16-Core Processor\n"), "Ryzen 9 7950X"); -assert.strictEqual(P.cpuModel("model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n"), "Core i7-1165G7 @ 2.80GHz"); -assert.strictEqual(P.cpuModel(""), ""); - -// band and worst: null is "" (unknown), which never raises the worst. -assert.strictEqual(P.band(10, 25, 75), "ok"); -assert.strictEqual(P.band(25, 25, 75), "warn"); -assert.strictEqual(P.band(75, 25, 75), "crit"); -assert.strictEqual(P.band(null, 25, 75), ""); -assert.strictEqual(P.worst(["ok", "", "warn"]), "warn"); -assert.strictEqual(P.worst(["warn", "crit", "ok"]), "crit"); -assert.strictEqual(P.worst(["", ""]), "ok"); -assert.strictEqual(P.worst([]), "ok"); - -// isIPv4 -assert.ok(P.isIPv4("203.0.113.5")); -assert.ok(!P.isIPv4("203.0.113.256")); -assert.ok(!P.isIPv4("")); -assert.ok(!P.isIPv4("")); - -// The pubip cache, in conky's format, both ways. -assert.deepStrictEqual(P.parsePubip("ip 203.0.113.5\nfetched 1700000000\n"), { ip: "203.0.113.5", fetched: 1700000000 }); -assert.strictEqual(P.parsePubip("ip nonsense\nfetched 1\n"), null); -assert.strictEqual(P.parsePubip(""), null); -assert.strictEqual(P.formatPubip("203.0.113.5", 1700000000), "ip 203.0.113.5\nfetched 1700000000\n"); -assert.deepStrictEqual(P.parsePubip(P.formatPubip("198.51.100.7", 42)), { ip: "198.51.100.7", fetched: 42 }); - -// ipCheck: no pin ok; match ok; mismatch only when fresh; stale is a dash. -const now = 1700010000; -const fresh = { ip: "203.0.113.5", fetched: now - 60 }; -const stale = { ip: "198.51.100.7", fetched: now - 5 * 3600 }; -assert.deepStrictEqual(P.ipCheck(fresh, now, ""), { ip: "203.0.113.5", mismatch: false }); -assert.deepStrictEqual(P.ipCheck(fresh, now, "203.0.113.5"), { ip: "203.0.113.5", mismatch: false }); -assert.deepStrictEqual(P.ipCheck(fresh, now, "198.51.100.7"), { ip: "203.0.113.5", mismatch: true }); -assert.deepStrictEqual(P.ipCheck(stale, now, "203.0.113.5"), { ip: "", mismatch: false }); -assert.deepStrictEqual(P.ipCheck(null, now, "203.0.113.5"), { ip: "", mismatch: false }); - -// due: no value, or one at least `every` seconds old. -assert.ok(P.due(null, now, 1800)); -assert.ok(P.due({ ip: "x", fetched: now - 1800 }, now, 1800)); -assert.ok(!P.due({ ip: "x", fetched: now - 1799 }, now, 1800)); - -// push: newest last, capped. -assert.deepStrictEqual(P.push([1, 2, 3], 4, 3), [2, 3, 4]); -assert.deepStrictEqual(P.push([], 1, 3), [1]); - -// bytes and speed -assert.strictEqual(P.bytes(512), "512B"); -assert.strictEqual(P.bytes(1536), "1.5K"); -assert.strictEqual(P.bytes(9.1 * 1024 ** 3), "9.1G"); -assert.strictEqual(P.bytes(null), "--"); -assert.strictEqual(P.speed(1024 * 1024 * 1.2), "1.2M/s"); -assert.strictEqual(P.speed(null), "--"); - -console.log("test_perf: ok"); diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js new file mode 100644 index 0000000..b853f50 --- /dev/null +++ b/desktop/modules/perf/Perf.js @@ -0,0 +1,187 @@ +.pragma library + +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// The performance panel's parsing and arithmetic, kept pure so it runs under +// node (test_perf.js). Perf.qml does the reading; everything here takes text +// or numbers and returns values. A reading that cannot be made is null, and +// the panel draws null as a dash. + +const UNITS = { "": 1, B: 1, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3 }; + +// A public IP older than this is not shown: by then the fetch has failed +// for hours, and an address that may no longer be ours is worse than a dash. +const STALE = 4 * 3600; + +// One {total, idle} per cpu line of /proc/stat; index 0 is the aggregate, +// then the cores in file order. idle counts iowait, as conky did. Only the +// first eight fields are summed: guest and guest_nice are already inside +// user and nice, and adding them again overstates load while VMs run. +function cpuTimes(stat) { + const out = []; + for (const line of stat.split("\n")) { + const m = /^cpu\d*\s+(.*)$/.exec(line); + if (!m) continue; + const v = m[1].trim().split(/\s+/).map(Number); + if (v.length < 5) continue; + out.push({ total: v.slice(0, 8).reduce((a, b) => a + b, 0), idle: v[3] + v[4] }); + } + return out; +} + +// Percent busy between two samples. +function load(prev, cur) { + if (!prev || !cur) return null; + const dt = cur.total - prev.total; + if (dt <= 0) return null; + return Math.max(0, Math.min(100, 100 * (1 - (cur.idle - prev.idle) / dt))); +} + +// {total, used} in bytes; used is MemTotal - MemAvailable. +function meminfo(text) { + const kb = key => { + const m = new RegExp("^" + key + ":\\s+(\\d+)", "m").exec(text); + return m ? Number(m[1]) * 1024 : null; + }; + const total = kb("MemTotal"), avail = kb("MemAvailable"); + if (total === null || avail === null) return null; + return { total: total, used: total - avail }; +} + +// Rows of `df -P -B1`. The header fails the percent match and is skipped. +// host is the NFS server from the device field, "" for a local disk, so the +// panel labels shares without an address in the repo. +function df(text) { + const out = []; + for (const line of text.split("\n")) { + const m = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(\S+)\s*$/.exec(line); + if (!m) continue; + const h = /^([^/:]+):/.exec(m[1]); + out.push({ mount: m[6], host: h ? h[1] : "", size: Number(m[2]), + used: Number(m[3]), pct: Number(m[5]) }); + } + return out; +} + +// 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) { + const fds = {}; + for (const line of text.split("\n")) { + const m = /^([^:]*):(drm-[\w-]+):\s*(.*)$/.exec(line); + if (!m) continue; + (fds[m[1]] = fds[m[1]] || {})[m[2]] = m[3]; + } + const seen = {}; + let total = null; + for (const path in fds) { + const fd = fds[path]; + const id = fd["drm-client-id"]; + const n = /^(\d+)\s*(\w*)/.exec(fd["drm-resident-vram0"] || ""); + if (fd["drm-driver"] !== "xe" || !id || !n || seen[id]) continue; + seen[id] = true; + total = (total || 0) + Number(n[1]) * (UNITS[n[2]] || 1); + } + return total; +} + +// Bytes per second between two counter readings. A counter that went +// backwards (interface reset) gives null rather than a negative spike. +function rate(prev, cur, secs) { + if (prev === null || cur === null || !(secs > 0) || cur < prev) return null; + return (cur - prev) / secs; +} + +// `grep -H . /sys/class/hwmon/hwmon*/name` -> {chip: dir}. hwmonN numbers +// change across boots, so chips are found by name. +function hwmonMap(text) { + const map = {}; + for (const line of text.split("\n")) { + const m = /^(.*)\/name:(.+)$/.exec(line); + if (m && !(m[2] in map)) map[m[2]] = m[1]; + } + return map; +} + +// `ip -4 -o addr show ` -> the first address, without its prefix. +function lanAddr(text) { + const m = /\binet (\d+\.\d+\.\d+\.\d+)\//.exec(text); + return m ? m[1] : ""; +} + +// The first model name in /proc/cpuinfo, trimmed the way conky trimmed it. +function cpuModel(cpuinfo) { + const m = /^model name\s*:\s*(.+)$/m.exec(cpuinfo); + if (!m) return ""; + return m[1].replace(/\((R|TM|tm)\)/g, "") + .replace(/^\s*\d+th Gen\s+/, "") + .replace(/^\s*(AMD|Intel)\s+/, "") + .replace(/\s+\d+-Core Processor.*$/, "") + .replace(/\s+/g, " ").trim(); +} + +// "ok", "warn", "crit", or "" for a value that could not be read. +function band(v, warn, crit) { + if (v === null || v === undefined) return ""; + return v >= crit ? "crit" : v >= warn ? "warn" : "ok"; +} + +// The worst of a list of bands; unknowns never raise it. +function worst(bands) { + if (bands.indexOf("crit") >= 0) return "crit"; + if (bands.indexOf("warn") >= 0) return "warn"; + return "ok"; +} + +function isIPv4(s) { + return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split(".").every(o => Number(o) <= 255); +} + +// conky's cache format: "ip " and "fetched " lines. +function parsePubip(text) { + const ip = /^ip (\S+)$/m.exec(text), at = /^fetched (\d+)$/m.exec(text); + return ip && at && isIPv4(ip[1]) ? { ip: ip[1], fetched: Number(at[1]) } : null; +} + +function formatPubip(ip, fetched) { + return "ip " + ip + "\nfetched " + fetched + "\n"; +} + +// What the panel shows and whether the icon warns. Only a fresh value can +// mismatch: a failed fetch leaves the old one to go stale, never to alarm. +function ipCheck(pub, now, pin) { + const fresh = !!pub && now - pub.fetched < STALE; + return { ip: fresh ? pub.ip : "", mismatch: fresh && !!pin && pub.ip !== pin }; +} + +// Whether a fetch is due: no value yet, or one at least `every` seconds old. +function due(pub, now, every) { + return !pub || now - pub.fetched >= every; +} + +// A history with v appended, oldest dropped past n. +function push(list, v, n) { + const out = list.concat([v]); + return out.length > n ? out.slice(out.length - n) : out; +} + +function bytes(n) { + if (n === null || n === undefined) return "--"; + const u = ["B", "K", "M", "G", "T"]; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return (i ? n.toFixed(1) : String(Math.round(n))) + u[i]; +} + +function speed(n) { + return n === null || n === undefined ? "--" : bytes(n) + "/s"; +} diff --git a/desktop/modules/perf/Perf.qml b/desktop/modules/perf/Perf.qml new file mode 100644 index 0000000..53d8670 --- /dev/null +++ b/desktop/modules/perf/Perf.qml @@ -0,0 +1,303 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQml +import "../.." +import "Perf.js" as P + +// The numbers behind the performance panel and the status pill's icon. CPU, +// RAM, temperatures and disks are sampled always, so the icon can warn while +// the panel is closed; per-core load, the network rate, VRAM and the fan only +// while `detailed`, which is any PerfPanel existing (island or drawer). +// /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the +// fdinfo scan, ip and curl are Processes. +Singleton { + id: root + + // PerfPanel instances alive; each counts itself in and out. + property int viewers: 0 + readonly property bool detailed: viewers > 0 + + // ponytail: host constants, as conky had them. One mount per NFS server + // (Library and Slackware are one export, so are shared and backup); br0 + // is a bridge, so VM-to-host copies show in the graph; xe puts no VRAM + // capacity in sysfs. Edit here when the hardware changes. + readonly property var mounts: ["/", "/home", "/data", "/mnt/nfs/Library", "/mnt/nfs/shared"] + readonly property string iface: "br0" + readonly property real vramTotal: 12 * 1024 * 1024 * 1024 + readonly property string gpuName: "ARC B580" + + // Conky's table. Ceilings are per sensor: 70C is unremarkable on a CPU + // package and alarming on an NVMe. GPU and VRAM are the GPU card's; the + // rest are the CPU card's rows. + readonly property var sensors: [ + { chip: "k10temp", file: "temp1_input", label: "CPU", warn: 75, crit: 90 }, + { chip: "k10temp", file: "temp3_input", label: "CCD1", warn: 75, crit: 90 }, + { chip: "nvme", file: "temp1_input", label: "NVME", warn: 60, crit: 70 }, + { chip: "gigabyte_wmi", file: "temp2_input", label: "BOARD", warn: 60, crit: 70 }, + { chip: "gigabyte_wmi", file: "temp3_input", label: "BOARD", warn: 60, crit: 70 }, + { chip: "xe", file: "temp2_input", label: "GPU", warn: 75, crit: 85 }, + { chip: "xe", file: "temp3_input", label: "VRAM", warn: 80, crit: 90 }, + ] + + property var hwmon: ({}) // chip name -> /sys/class/hwmon/hwmonN + property string cpuModel: "" + property var load: null // aggregate percent + property var cores: [] // percent per core, while detailed + property var mem: null // {total, used} bytes + property var temps: [] // [{label, value, warn, crit}], value null if unread + property var disks: [] // Perf.js df rows + property var vram: null // bytes, while detailed + property var fan: null // rpm, while detailed + property var rx: [] // bytes/s, newest last, while detailed + property var tx: [] + property string lan: "" + property var pub: null // {ip, fetched} + property real now: Date.now() / 1000 + + readonly property var ip: P.ipCheck(root.pub, root.now, Prefs.pinnedIp) + + // The icon's dot. CPU load is left out: spikes would flicker it, and + // sustained load shows up as heat. + readonly property string severity: P.worst( + root.temps.map(t => P.band(t.value, t.warn, t.crit)) + .concat([P.band(root.mem ? 100 * root.mem.used / root.mem.total : null, 85, 95)]) + .concat(root.disks.map(d => P.band(d.pct, 90, 95))) + .concat([root.ip.mismatch ? "crit" : "ok"])) + + readonly property string pubipPath: + (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/udt/pubip.txt" + + property var prevCpu: [] + property var prevNet: null // {rx, tx, t} + + function num(view) { + const v = parseInt(view.text(), 10); + return isNaN(v) ? null : v; + } + + // Measured from the last attempt, not the last success, so a failing + // fetch (rate limited, offline) still waits 30 minutes; and never over + // one already running, which would kill it before it could answer. + property real pubAttempt: 0 + function fetchPubip() { + const now = Date.now() / 1000; + if (pubProc.running || !P.due(root.pub, now, 1800) || now - root.pubAttempt < 1800) return; + root.pubAttempt = now; + pubProc.running = true; + } + + onDetailedChanged: { + if (detailed) { + if (!root.lan) lanProc.running = true; + root.fetchPubip(); + } else { + root.cores = []; + root.rx = []; + root.tx = []; + root.prevNet = null; + root.vram = null; + root.fan = null; + } + } + + // --- /proc and /sys --------------------------------------------------- + + FileView { id: stat; path: "/proc/stat"; blockLoading: true } + FileView { id: meminfo; path: "/proc/meminfo"; blockLoading: true } + FileView { + path: "/proc/cpuinfo" + blockLoading: true + onLoaded: root.cpuModel = P.cpuModel(text()) + } + + Instantiator { + id: tempViews + model: root.sensors + delegate: FileView { + required property var modelData + path: root.hwmon[modelData.chip] ? root.hwmon[modelData.chip] + "/" + modelData.file : "" + blockLoading: true + printErrors: false + } + } + + FileView { + id: fanView + path: root.hwmon.xe ? root.hwmon.xe + "/fan1_input" : "" + blockLoading: true + printErrors: false + } + + FileView { + id: rxView + path: "/sys/class/net/" + root.iface + "/statistics/rx_bytes" + blockLoading: true + printErrors: false + } + + FileView { + id: txView + path: "/sys/class/net/" + root.iface + "/statistics/tx_bytes" + blockLoading: true + printErrors: false + } + + Process { + running: true + command: ["sh", "-c", "grep -H . /sys/class/hwmon/hwmon*/name"] + stdout: StdioCollector { onStreamFinished: root.hwmon = P.hwmonMap(text) } + } + + // CPU, RAM and temperatures always; the fan and VRAM while detailed. + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + root.now = Date.now() / 1000; + + stat.reload(); + const cur = P.cpuTimes(stat.text()); + root.load = P.load(root.prevCpu[0], cur[0]); + root.cores = root.detailed + ? cur.slice(1).map((c, i) => P.load(root.prevCpu[i + 1], c) ?? 0) + : []; + root.prevCpu = cur; + + meminfo.reload(); + root.mem = P.meminfo(meminfo.text()); + + const t = []; + for (let i = 0; i < root.sensors.length; i++) { + const s = root.sensors[i]; + const v = tempViews.objectAt(i); + let value = null; + if (v && v.path) { + v.reload(); + const n = root.num(v); + value = n === null ? null : n / 1000; + } + t.push({ label: s.label, value: value, warn: s.warn, crit: s.crit }); + } + root.temps = t; + + if (root.detailed) { + if (fanView.path) { fanView.reload(); root.fan = root.num(fanView); } + if (!vramProc.running) vramProc.running = true; + } + } + } + + // ponytail: a shell glob over every fd on the system, ~14ms; only while + // the panel is open. Only the user's own processes are readable, so a + // system service's GPU use is not counted, as in conky. + Process { + id: vramProc + command: ["sh", "-c", "grep -sH -e '^drm-driver' -e '^drm-client-id' " + + "-e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null"] + stdout: StdioCollector { onStreamFinished: root.vram = P.vram(text) } + } + + // --- network ------------------------------------------------------------ + + Timer { + interval: 1000 + running: root.detailed + repeat: true + triggeredOnStart: true + onTriggered: { + rxView.reload(); + txView.reload(); + const cur = { rx: root.num(rxView), tx: root.num(txView), t: Date.now() / 1000 }; + const p = root.prevNet; + if (p) { + const r = P.rate(p.rx, cur.rx, cur.t - p.t); + const s = P.rate(p.tx, cur.tx, cur.t - p.t); + if (r !== null) root.rx = P.push(root.rx, r, 60); + if (s !== null) root.tx = P.push(root.tx, s, 60); + } + root.prevNet = cur; + } + } + + Process { + id: lanProc + command: ["ip", "-4", "-o", "addr", "show", root.iface] + stdout: StdioCollector { onStreamFinished: root.lan = P.lanAddr(text) } + } + + // --- disks -------------------------------------------------------------- + + // df under timeout: an unreachable NFS server blocks statfs, and the + // whole call then yields nothing, so the last rows are kept. + Timer { + interval: 60000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { dfProc.running = false; dfProc.running = true; } + } + + Process { + id: dfProc + command: ["timeout", "-k", "2", "5", "/usr/bin/df", "-P", "-B1"].concat(root.mounts) + stdout: StdioCollector { + onStreamFinished: { + const rows = P.df(text); + if (rows.length) root.disks = rows; + } + } + } + + // --- public IP ---------------------------------------------------------- + + // conky's cache file, so the address survives a restart and the fetch + // keeps conky's 30 minute pace. Checked every minute, fetched when due. + FileView { + id: pubFile + path: root.pubipPath + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.pub = P.parsePubip(text()) + } + + Timer { + interval: 60000 + running: true + repeat: true + onTriggered: root.fetchPubip() + } + + // Anything but an IPv4 address (a failure, an error page) leaves the + // cache alone: the old value then goes stale rather than mismatching. + Process { + id: pubProc + command: ["curl", "-4fsS", "--max-time", "10", "https://ipinfo.io/ip"] + stdout: StdioCollector { + onStreamFinished: { + const ip = text.trim(); + if (!P.isIPv4(ip)) return; + const at = Math.floor(Date.now() / 1000); + root.pub = { ip: ip, fetched: at }; + pubFile.setText(P.formatPubip(ip, at)); + } + } + } +} diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml new file mode 100644 index 0000000..f149d82 --- /dev/null +++ b/desktop/modules/perf/PerfPanel.qml @@ -0,0 +1,336 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +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 { + id: panel + + columns: 3 + columnSpacing: 8 + rowSpacing: 8 + + readonly property real col: (width - 2 * columnSpacing) / 3 + readonly property var gpuTemp: sensor("GPU") + readonly property var vramTemp: sensor("VRAM") + + function tone(v, warn, crit) { + const b = P.band(v, warn, crit); + return b === "crit" ? Theme.red : b === "warn" ? Theme.yellow : b === "ok" ? Theme.green : Theme.subtext; + } + function deg(v) { return v === null ? "--" : Math.round(v) + "°"; } + 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. + Component.onCompleted: Perf.viewers++ + Component.onDestruction: Perf.viewers-- + + function pct(part, whole) { return part === null || !whole ? null : 100 * part / whole; } + function last(list) { return list.length ? list[list.length - 1] : null; } + // Conky's glyphs, re-checked in Inconsolata Nerd Font. + function glyph(d) { + if (d.host) return String.fromCodePoint(0xF06F3); // network share + return String.fromCodePoint({ "/": 0xF318, "/home": 0xF015 }[d.mount] ?? 0xF02CA); + } + + component Label: Text { + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + + // A label left, a value right, an optional glyph before the label. + component Pair: Item { + property string glyph: "" + property string label: "" + property string value: "" + property color labelColor: Theme.subtext + property color valueColor: Theme.text + width: parent ? parent.width : 0 + implicitHeight: valueText.implicitHeight + + Row { + spacing: 6 + Text { + visible: text !== "" + text: parent.parent.glyph + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + color: parent.parent.labelColor + } + Label { text: parent.parent.label; color: parent.parent.labelColor } + } + Label { + id: valueText + anchors.right: parent.right + text: parent.value + color: parent.valueColor + } + } + + component Bar: Rectangle { + property real frac: 0 + property color fill: Theme.green + width: parent ? parent.width : 0 + height: 5 + radius: 3 + color: Theme.surfaceAlt + Rectangle { + width: parent.width * Math.max(0, Math.min(1, parent.frac)) + height: parent.height + radius: 3 + color: parent.fill + } + } + + component Card: Rectangle { + default property alias content: body.data + property string title: "" + property string headline: "" + property color headlineColor: Theme.text + radius: 10 + color: Qt.alpha(Theme.surface, 0.6) + implicitHeight: body.implicitHeight + 20 + Layout.fillHeight: true + + Column { + id: body + x: 10 + y: 10 + width: parent.width - 20 + spacing: 5 + + Item { + width: parent.width + height: Math.max(titleText.implicitHeight, headText.implicitHeight) + Text { + id: titleText + anchors.verticalCenter: parent.verticalCenter + text: parent.parent.parent.title + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + Text { + id: headText + anchors.right: parent.right + text: parent.parent.parent.headline + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 4; bold: true } + color: parent.parent.parent.headlineColor + } + } + } + } + + // --- 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) + + Label { + width: parent.width + text: Perf.cpuModel + color: Theme.subtext + elide: Text.ElideRight + } + + // One bar per core, rising from a common baseline. + Row { + id: eq + width: parent.width + height: 40 + spacing: 2 + Repeater { + model: Perf.cores + delegate: Item { + required property var modelData + width: (eq.width - eq.spacing * (Perf.cores.length - 1)) / Perf.cores.length + height: eq.height + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: Math.max(1, parent.height * modelData / 100) + radius: 1 + color: panel.tone(modelData, 25, 75) + } + } + } + } + + Pair { + label: "RAM" + value: Perf.mem ? P.bytes(Perf.mem.used) + " / " + P.bytes(Perf.mem.total) : "--" + } + Bar { + 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) + } + } + } + + // --- GPU ---------------------------------------------------------------- + + // 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) + + Pair { + label: "VRAM" + value: Perf.vram === null ? "--" : P.bytes(Perf.vram) + " / " + P.bytes(Perf.vramTotal) + } + Bar { + frac: (Perf.vram ?? 0) / Perf.vramTotal + fill: panel.tone(panel.pct(Perf.vram, Perf.vramTotal), 25, 75) + } + Pair { + label: "VRAM temp" + value: panel.deg(panel.vramTemp.value) + valueColor: panel.tone(panel.vramTemp.value, panel.vramTemp.warn, panel.vramTemp.crit) + } + Pair { + label: "Fan" + value: Perf.fan === null ? "--" : Perf.fan + " rpm" + } + } + + // --- network ------------------------------------------------------------ + + Card { + Layout.row: 0 + Layout.column: 2 + Layout.preferredWidth: panel.col + title: "NET · " + Perf.iface + + Canvas { + id: graph + width: parent.width + height: 44 + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const max = Math.max(1, ...Perf.rx, ...Perf.tx); + const line = (data, color) => { + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + data.forEach((v, i) => { + const x = width * i / 59; + const y = height - 1 - (height - 2) * v / max; + if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); + }); + ctx.stroke(); + }; + line(Perf.rx, Theme.teal); + line(Perf.tx, Theme.peach); + } + Connections { + target: Perf + function onRxChanged() { graph.requestPaint(); } + function onTxChanged() { graph.requestPaint(); } + } + } + + Pair { + label: "↓ " + P.speed(panel.last(Perf.rx)) + labelColor: Theme.teal + value: "↑ " + P.speed(panel.last(Perf.tx)) + valueColor: Theme.peach + } + Pair { label: "LAN"; value: Perf.lan || "--" } + + // The public IP, red when it differs from the pinned one. The pin + // stores the address shown; it is lit while they are the same. + Item { + width: parent.width + height: pubText.implicitHeight + + Label { text: "Public"; color: Theme.subtext } + Label { + id: pubText + anchors { right: pin.left; rightMargin: 6 } + text: Perf.ip.ip || "--" + color: Perf.ip.mismatch ? Theme.red : Theme.text + } + Text { + id: pin + anchors { right: parent.right; verticalCenter: pubText.verticalCenter } + text: String.fromCodePoint(0xF0403) + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + color: Perf.ip.ip !== "" && Perf.ip.ip === Prefs.pinnedIp ? Theme.text : Theme.overlay + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + enabled: Perf.ip.ip !== "" + cursorShape: Qt.PointingHandCursor + onClicked: Prefs.setPinnedIp(Perf.ip.ip) + } + } + } + } + + // --- disks -------------------------------------------------------------- + + Card { + Layout.row: 1 + Layout.column: 1 + Layout.columnSpan: 2 + Layout.preferredWidth: 2 * panel.col + panel.columnSpacing + title: "DISKS" + + Repeater { + model: Perf.disks + delegate: Column { + required property var modelData + width: parent.width + spacing: 2 + Pair { + glyph: panel.glyph(modelData) + label: modelData.host || modelData.mount + value: modelData.pct + "% · " + P.bytes(modelData.used) + " / " + P.bytes(modelData.size) + valueColor: panel.tone(modelData.pct, 25, 75) + } + Bar { + frac: modelData.pct / 100 + fill: panel.tone(modelData.pct, 25, 75) + } + } + } + } +} diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js new file mode 100644 index 0000000..5f39f5d --- /dev/null +++ b/desktop/modules/perf/test_perf.js @@ -0,0 +1,153 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// Run: node desktop/island/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. + +const fs = require("fs"); +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, " + + "ipCheck, due, push, bytes, speed };")(); + +// cpuTimes: aggregate first, then cores; idle is idle + iowait. +const stat = [ + "cpu 100 0 50 800 50 0 0 0 0 0", + "cpu0 50 0 25 400 25 0 0 0 0 0", + "cpu1 50 0 25 400 25 0 0 0 0 0", + "intr 12345", + "ctxt 999", +].join("\n"); +const t = P.cpuTimes(stat); +assert.strictEqual(t.length, 3); +assert.deepStrictEqual(t[0], { total: 1000, idle: 850 }); +assert.deepStrictEqual(t[1], { total: 500, idle: 425 }); +assert.deepStrictEqual(P.cpuTimes(""), []); +// guest and guest_nice are already inside user and nice: not added twice. +assert.deepStrictEqual(P.cpuTimes("cpu 100 0 0 100 0 0 0 0 80 0"), [{ total: 200, idle: 100 }]); + +// load: busy share of the delta; no prev, or no time passed, is null. +assert.strictEqual(P.load({ total: 1000, idle: 850 }, { total: 1100, idle: 925 }), 25); +assert.strictEqual(P.load(undefined, { total: 1, idle: 1 }), null); +assert.strictEqual(P.load({ total: 5, idle: 5 }, { total: 5, idle: 5 }), null); +assert.strictEqual(P.load({ total: 0, idle: 0 }, { total: 100, idle: 0 }), 100); + +// meminfo: used is total - available, in bytes. +const mem = "MemTotal: 32000000 kB\nMemFree: 1000 kB\nMemAvailable: 24000000 kB\n"; +assert.deepStrictEqual(P.meminfo(mem), { total: 32000000 * 1024, used: 8000000 * 1024 }); +assert.strictEqual(P.meminfo("MemTotal: 5 kB\n"), null); + +// df: header skipped, NFS host from the device field, bytes as numbers. +const dfOut = [ + "Filesystem 1-blocks Used Available Capacity Mounted on", + "/dev/mapper/vg-root 263088050176 143921766400 105727733760 58% /", + "192.0.2.11:/Volume1/Library 3926310780928 3185272160256 541516365824 86% /mnt/nfs/Library", +].join("\n"); +const d = P.df(dfOut); +assert.strictEqual(d.length, 2); +assert.deepStrictEqual(d[0], { mount: "/", host: "", size: 263088050176, used: 143921766400, pct: 58 }); +assert.strictEqual(d[1].host, "192.0.2.11"); +assert.strictEqual(d[1].mount, "/mnt/nfs/Library"); +assert.deepStrictEqual(P.df(""), []); + +// vram: summed once per xe client id, units honoured, other drivers ignored. +const fd = [ + "/proc/1/fdinfo/7:drm-driver:\txe", + "/proc/1/fdinfo/7:drm-client-id:\t38", + "/proc/1/fdinfo/7:drm-resident-vram0:\t1024 KiB", + "/proc/1/fdinfo/10:drm-driver:\txe", + "/proc/1/fdinfo/10:drm-client-id:\t38", + "/proc/1/fdinfo/10:drm-resident-vram0:\t1024 KiB", + "/proc/2/fdinfo/5:drm-driver:\txe", + "/proc/2/fdinfo/5:drm-client-id:\t40", + "/proc/2/fdinfo/5:drm-resident-vram0:\t2 MiB", + "/proc/3/fdinfo/4:drm-driver:\tamdgpu", + "/proc/3/fdinfo/4:drm-client-id:\t1", + "/proc/3/fdinfo/4:drm-resident-vram0:\t9 GiB", +].join("\n"); +assert.strictEqual(P.vram(fd), 1024 * 1024 + 2 * 1024 * 1024); +assert.strictEqual(P.vram(""), null); + +// rate: bytes per second; a counter that went backwards is null. +assert.strictEqual(P.rate(1000, 3000, 2), 1000); +assert.strictEqual(P.rate(3000, 1000, 1), null); +assert.strictEqual(P.rate(null, 1000, 1), null); +assert.strictEqual(P.rate(1000, 2000, 0), null); + +// hwmonMap: chip name -> directory, first one wins. +const names = "/sys/class/hwmon/hwmon1/name:nvme\n/sys/class/hwmon/hwmon5/name:xe\n/sys/class/hwmon/hwmon7/name:nvme\n"; +assert.deepStrictEqual(P.hwmonMap(names), + { nvme: "/sys/class/hwmon/hwmon1", xe: "/sys/class/hwmon/hwmon5" }); + +// lanAddr: the address without its prefix length. +assert.strictEqual(P.lanAddr("4: br0 inet 192.0.2.5/24 brd 192.0.2.255 scope global br0\n"), "192.0.2.5"); +assert.strictEqual(P.lanAddr(""), ""); + +// cpuModel: vendor, trademarks and the core-count tail dropped. +assert.strictEqual(P.cpuModel("processor\t: 0\nmodel name\t: AMD Ryzen 9 7950X 16-Core Processor\n"), "Ryzen 9 7950X"); +assert.strictEqual(P.cpuModel("model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n"), "Core i7-1165G7 @ 2.80GHz"); +assert.strictEqual(P.cpuModel(""), ""); + +// band and worst: null is "" (unknown), which never raises the worst. +assert.strictEqual(P.band(10, 25, 75), "ok"); +assert.strictEqual(P.band(25, 25, 75), "warn"); +assert.strictEqual(P.band(75, 25, 75), "crit"); +assert.strictEqual(P.band(null, 25, 75), ""); +assert.strictEqual(P.worst(["ok", "", "warn"]), "warn"); +assert.strictEqual(P.worst(["warn", "crit", "ok"]), "crit"); +assert.strictEqual(P.worst(["", ""]), "ok"); +assert.strictEqual(P.worst([]), "ok"); + +// isIPv4 +assert.ok(P.isIPv4("203.0.113.5")); +assert.ok(!P.isIPv4("203.0.113.256")); +assert.ok(!P.isIPv4("")); +assert.ok(!P.isIPv4("")); + +// The pubip cache, in conky's format, both ways. +assert.deepStrictEqual(P.parsePubip("ip 203.0.113.5\nfetched 1700000000\n"), { ip: "203.0.113.5", fetched: 1700000000 }); +assert.strictEqual(P.parsePubip("ip nonsense\nfetched 1\n"), null); +assert.strictEqual(P.parsePubip(""), null); +assert.strictEqual(P.formatPubip("203.0.113.5", 1700000000), "ip 203.0.113.5\nfetched 1700000000\n"); +assert.deepStrictEqual(P.parsePubip(P.formatPubip("198.51.100.7", 42)), { ip: "198.51.100.7", fetched: 42 }); + +// ipCheck: no pin ok; match ok; mismatch only when fresh; stale is a dash. +const now = 1700010000; +const fresh = { ip: "203.0.113.5", fetched: now - 60 }; +const stale = { ip: "198.51.100.7", fetched: now - 5 * 3600 }; +assert.deepStrictEqual(P.ipCheck(fresh, now, ""), { ip: "203.0.113.5", mismatch: false }); +assert.deepStrictEqual(P.ipCheck(fresh, now, "203.0.113.5"), { ip: "203.0.113.5", mismatch: false }); +assert.deepStrictEqual(P.ipCheck(fresh, now, "198.51.100.7"), { ip: "203.0.113.5", mismatch: true }); +assert.deepStrictEqual(P.ipCheck(stale, now, "203.0.113.5"), { ip: "", mismatch: false }); +assert.deepStrictEqual(P.ipCheck(null, now, "203.0.113.5"), { ip: "", mismatch: false }); + +// due: no value, or one at least `every` seconds old. +assert.ok(P.due(null, now, 1800)); +assert.ok(P.due({ ip: "x", fetched: now - 1800 }, now, 1800)); +assert.ok(!P.due({ ip: "x", fetched: now - 1799 }, now, 1800)); + +// push: newest last, capped. +assert.deepStrictEqual(P.push([1, 2, 3], 4, 3), [2, 3, 4]); +assert.deepStrictEqual(P.push([], 1, 3), [1]); + +// bytes and speed +assert.strictEqual(P.bytes(512), "512B"); +assert.strictEqual(P.bytes(1536), "1.5K"); +assert.strictEqual(P.bytes(9.1 * 1024 ** 3), "9.1G"); +assert.strictEqual(P.bytes(null), "--"); +assert.strictEqual(P.speed(1024 * 1024 * 1.2), "1.2M/s"); +assert.strictEqual(P.speed(null), "--"); + +console.log("test_perf: ok"); -- cgit v1.2.3