diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/modules/network/NetworkPage.qml | 204 | ||||
| -rw-r--r-- | desktop/modules/network/README.md | 12 | ||||
| -rw-r--r-- | desktop/modules/perf/Perf.js | 43 | ||||
| -rw-r--r-- | desktop/modules/perf/PerfPanel.qml | 76 | ||||
| -rw-r--r-- | desktop/modules/perf/README.md | 10 | ||||
| -rw-r--r-- | desktop/modules/perf/test_perf.js | 28 |
6 files changed, 291 insertions, 82 deletions
diff --git a/desktop/modules/network/NetworkPage.qml b/desktop/modules/network/NetworkPage.qml index b789d17..e0f32e8 100644 --- a/desktop/modules/network/NetworkPage.qml +++ b/desktop/modules/network/NetworkPage.qml @@ -9,9 +9,12 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell.Io import Quickshell.Networking import QtQuick import "../.." +import "../perf" +import "../perf/Perf.js" as P Column { id: page @@ -27,6 +30,207 @@ Column { : s === ConnectionState.Disconnecting ? "disconnecting" : "disconnected"; } + function last(list) { return list.length ? list[list.length - 1] : null; } + + // --- Traffic --- + + // The rates are Perf's, sampled always, so the graph opens on the last + // minute rather than empty. + Row { + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: String.fromCodePoint(0xF012A) + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Traffic · " + Perf.iface + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + } + + Canvas { + id: graph + width: page.width + height: 80 + 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(); } + } + } + + Item { + width: page.width + implicitHeight: rxText.implicitHeight + Text { + id: rxText + text: "↓ " + P.speed(page.last(Perf.rx)) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.teal + } + Text { + anchors.right: parent.right + text: "↑ " + P.speed(page.last(Perf.tx)) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.peach + } + } + + // The five busiest processes, from `ss -tinp` byte counters diffed every + // 2s while the page is open; it stops with the page. TCP only, and only + // the user's own processes: UDP (QUIC, so much of a browser's HTTP/3) + // and system services are not counted. nethogs would see both but needs + // capabilities it does not have here. + property var socks: null // {list, t} from the last sample + property var netProcs: [] + + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { ssProc.running = false; ssProc.running = true; } + } + + Process { + id: ssProc + command: ["/sbin/ss", "-tinpH"] + stdout: StdioCollector { + onStreamFinished: { + const cur = { list: P.sockets(text), t: Date.now() / 1000 }; + if (page.socks) page.netProcs = P.netProcs(page.socks.list, cur.list, cur.t - page.socks.t).slice(0, 5); + page.socks = cur; + } + } + } + + // Five rows tall whatever the count, so what sits below never moves. + Column { + width: page.width + height: 5 * rowMetrics.height + 4 * spacing + spacing: 8 + + FontMetrics { + id: rowMetrics + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + + Repeater { + model: page.netProcs + delegate: Item { + required property var modelData + width: page.width + implicitHeight: procName.implicitHeight + Text { + id: procName + anchors { left: parent.left; right: procRx.left; rightMargin: 8 } + text: modelData.name + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + Text { + id: procRx + anchors.right: procTx.left + width: Theme.fontSize * 6 + horizontalAlignment: Text.AlignRight + text: "↓ " + P.speed(modelData.rx) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + font.features: { "tnum": 1 } + color: Theme.teal + } + Text { + id: procTx + anchors.right: parent.right + width: Theme.fontSize * 6 + horizontalAlignment: Text.AlignRight + text: "↑ " + P.speed(modelData.tx) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + font.features: { "tnum": 1 } + color: Theme.peach + } + } + } + } + + // The LAN address and the public IP, red when it differs from the + // pinned one; Perf keeps both, since the mismatch also lights the perf + // tile's dot. The pin stores the address shown and is lit while they + // are the same. + Item { + width: page.width + implicitHeight: lanText.implicitHeight + Text { + text: "LAN" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + Text { + id: lanText + anchors.right: parent.right + text: Perf.lan || "--" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + } + + Item { + width: page.width + implicitHeight: pubText.implicitHeight + Text { + text: "Public" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + Text { + id: pubText + anchors { right: pin.left; rightMargin: 6 } + text: Perf.ip.ip || "--" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + 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 } + 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) + } + } + } + + Rectangle { width: page.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } // --- Wired --- diff --git a/desktop/modules/network/README.md b/desktop/modules/network/README.md index a806488..551e23c 100644 --- a/desktop/modules/network/README.md +++ b/desktop/modules/network/README.md @@ -10,7 +10,17 @@ slow timer while a link is up. When both wired and wifi are up the tile shows both: the API exposes no route metric, so it does not guess which link carries traffic. -The page lists managed wired devices, then a wifi radio switch and list. A +The page opens on traffic: `br0`'s download and upload over the last minute, +sampled always by `perf/Perf.qml`, the five busiest processes below it, then +the LAN address and the public IP with its pin (see the perf README; Perf +still owns both, since a mismatch lights the perf tile's dot). +Those come from `ss -tinp`, its per-socket byte counters diffed every 2s +while the page is open, summed by process name. That is TCP only and the +user's own processes only: QUIC and system services are missing. nethogs +would see both, but unprivileged it cannot open the interfaces. The parsing +is in `perf/Perf.js`, tested by `perf/test_perf.js`. + +Then the page lists managed wired devices, then a wifi radio switch and list. A row click connects; a secured network that is not yet known reveals an inline password field. Known networks offer Forget, the connected one Disconnect. diff --git a/desktop/modules/perf/Perf.js b/desktop/modules/perf/Perf.js index 8294e94..03fd484 100644 --- a/desktop/modules/perf/Perf.js +++ b/desktop/modules/perf/Perf.js @@ -207,6 +207,49 @@ function procs(text) { return out; } +// One {key, name, rx, tx} per TCP socket in `ss -tinpH` output: a line +// naming the owner in users:((...)), then the -i line under it with the +// byte counters. Sockets ss names no owner for (other users', without root) +// are skipped. The key is both endpoints and the pid, so a reused port in +// another process is a new socket. +function sockets(text) { + const out = []; + let cur = null; + for (const line of text.split("\n")) { + const u = /^\S+\s+\d+\s+\d+\s+(\S+)\s+(\S+)\s+users:\(\("([^"]*)",pid=(\d+)/.exec(line); + if (u) { + cur = { key: u[1] + " " + u[2] + " " + u[4], name: u[3], rx: 0, tx: 0 }; + out.push(cur); + } else if (cur && /^\s/.test(line)) { + cur.rx = Number((/bytes_received:(\d+)/.exec(line) || [0, 0])[1]); + cur.tx = Number((/bytes_sent:(\d+)/.exec(line) || [0, 0])[1]); + cur = null; + } else { + cur = null; + } + } + return out; +} + +// Bytes/s per process name between two sockets() samples, busiest first, +// idle ones dropped. A socket new since the last sample counts from zero, +// since it opened inside the interval; one that closed takes its last +// bytes with it, which undercounts short transfers. +function netProcs(prev, cur, secs) { + if (!secs || secs <= 0) return []; + const before = {}; + for (const s of prev) before[s.key] = s; + const by = {}; + for (const s of cur) { + const p = before[s.key] || { rx: 0, tx: 0 }; + const e = by[s.name] || (by[s.name] = { name: s.name, rx: 0, tx: 0 }); + e.rx += Math.max(0, s.rx - p.rx) / secs; + e.tx += Math.max(0, s.tx - p.tx) / secs; + } + return Object.keys(by).map(k => by[k]).filter(e => e.rx + e.tx > 0) + .sort((a, b) => (b.rx + b.tx) - (a.rx + a.tx)); +} + function bytes(n) { if (n === null || n === undefined) return "--"; const u = ["B", "K", "M", "G", "T"]; diff --git a/desktop/modules/perf/PerfPanel.qml b/desktop/modules/perf/PerfPanel.qml index ff9f74b..8c9773d 100644 --- a/desktop/modules/perf/PerfPanel.qml +++ b/desktop/modules/perf/PerfPanel.qml @@ -15,7 +15,7 @@ import QtQuick import "../.." import "Perf.js" as P -// CPU, temperatures, GPU, network, disks and ~/.cache as cards, one per row: +// CPU, temperatures, GPU, disks and ~/.cache as cards, one per row: // the drawer page scrolls, so nothing has to share a line. Numbers come from // Perf; colours follow conky's bands, so they read as they did there. Column { @@ -36,7 +36,6 @@ Column { } 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 @@ -329,79 +328,6 @@ Column { } } - // --- network ------------------------------------------------------------ - - Card { - title: "NET · " + Perf.iface - - Canvas { - id: graph - width: parent.width - height: 80 - 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: panel.fs + 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 { diff --git a/desktop/modules/perf/README.md b/desktop/modules/perf/README.md index 8282a50..3d11be4 100644 --- a/desktop/modules/perf/README.md +++ b/desktop/modules/perf/README.md @@ -3,8 +3,7 @@ The figures the conky dashboard used to show, as a drawer page of cards, one per row; the page scrolls. CPU load with a bar per core, the CPU model and RAM; every temperature as a tile; the Arc B580's VRAM and fan (no -utilisation, xe exposes none); download and upload over the last minute, the -LAN address and the public IP; the five mounts with their usage; and +utilisation, xe exposes none); the five mounts with their usage; and `~/.cache` with its five largest children. `Perf.qml` reads them; `Perf.js` holds the parsing, tested by `node desktop/modules/perf/test_perf.js`. @@ -19,7 +18,7 @@ The tile shows CPU and RAM, and a dot for the worst of these, yellow or red: CPU load is left out, since spikes would flicker it. There is no hysteresis, so a sensor sitting on its threshold can toggle the dot. The pin beside the -public IP stores the address shown as `perf.pinnedIp` in +public IP, on the network page, stores the address shown as `perf.pinnedIp` in `$XDG_STATE_HOME/keystone/desktop.json`; the check exists because the VPN into the LAN depends on the ISP's fixed address. The fetch is `curl -4 https://ipinfo.io/ip` every 30 minutes from the last attempt, into @@ -27,8 +26,9 @@ into the LAN depends on the ISP's fixed address. The fetch is never a mismatch: the old value goes stale after 4 hours and shows as a dash. Everything is sampled always, including per-core load, the network rate, -VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan, so the page opens -on the last minute of network history instead of an empty graph. The mount list, `br0`, the GPU name and the 12 GiB VRAM +VRAM (a scan of every fdinfo, ~14ms every 2s) and the fan. The network rate +is drawn by the network page, which reads it from here, so its graph opens +on the last minute of history instead of empty. The mount list, `br0`, the GPU name and the 12 GiB VRAM size are constants in `Perf.qml`. The CPU card ends with the five heaviest processes, by CPU or by RAM (a diff --git a/desktop/modules/perf/test_perf.js b/desktop/modules/perf/test_perf.js index f2caa94..b7d9dbb 100644 --- a/desktop/modules/perf/test_perf.js +++ b/desktop/modules/perf/test_perf.js @@ -20,7 +20,7 @@ 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, du, band, worst, isIPv4, parsePubip, formatPubip, " + - "ipCheck, due, push, bytes, speed, procs };")(); + "ipCheck, due, push, bytes, speed, procs, sockets, netProcs };")(); // cpuTimes: aggregate first, then cores; idle is idle + iowait. const stat = [ @@ -171,4 +171,30 @@ assert.deepStrictEqual(P.procs(frame), [ ]); assert.deepStrictEqual(P.procs(""), []); +// sockets: owned sockets with their -i counters; ownerless ones skipped. +const ss = [ + "ESTAB 0 0 10.0.0.5:35806 192.0.2.1:443 users:((\"firefox\",pid=12,fd=53))", + "\t cubic rto:340 bytes_sent:100 bytes_acked:101 bytes_received:5000 segs_out:2", + "ESTAB 0 0 10.0.0.5:22 192.0.2.9:5000", + "\t cubic bytes_sent:999 bytes_received:999", + "SYN-SENT 0 1 10.0.0.5:49916 192.0.2.2:65081 users:((\"ktorrent\",pid=7,fd=67))", + "\t cubic rto:1000 segs_out:3", +].join("\n"); +const socks = P.sockets(ss); +assert.deepStrictEqual(socks, [ + { key: "10.0.0.5:35806 192.0.2.1:443 12", name: "firefox", rx: 5000, tx: 100 }, + { key: "10.0.0.5:49916 192.0.2.2:65081 7", name: "ktorrent", rx: 0, tx: 0 }, +]); + +// netProcs: deltas over the interval summed by name; a new socket counts +// from zero, idle processes drop out. +const later = [ + { key: "a", name: "firefox", rx: 5000 + 4000, tx: 100 }, + { key: "b", name: "firefox", rx: 2000, tx: 0 }, + { key: "c", name: "ktorrent", rx: 0, tx: 0 }, +]; +assert.deepStrictEqual(P.netProcs([{ key: "a", name: "firefox", rx: 5000, tx: 100 }], later, 2), + [{ name: "firefox", rx: 3000, tx: 0 }]); +assert.deepStrictEqual(P.netProcs([], later, 0), []); + console.log("test_perf: ok"); |
