From 8c15dc1205d879591318cf365e15b014477329c1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 27 Oct 2026 13:56:24 +0100 Subject: network: traffic, its busiest processes and the IPs on the page The rx/tx graph, the LAN address and the public IP with its pin move from the perf page's NET card to the network page, where they belong; the NET card is gone. Perf still samples and owns all of them: the graph opens on the last minute of history, and a public IP that differs from the pin still lights the perf tile's dot. Below the graph, the five busiest processes. nethogs is installed but cannot open any interface unprivileged, so the rates come from `ss -tinpH`: per-socket byte counters diffed every 2s while the page is open, summed by process name. That counts TCP only, and only the user's own processes, so QUIC and system services are missing. The list is held at five rows tall so the IP lines under it never move. Perf.js gains sockets() and netProcs(), with tests in test_perf.js. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/network/NetworkPage.qml | 204 ++++++++++++++++++++++++++++++++ desktop/modules/network/README.md | 12 +- desktop/modules/perf/Perf.js | 43 +++++++ desktop/modules/perf/PerfPanel.qml | 76 +----------- desktop/modules/perf/README.md | 10 +- 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"); -- cgit v1.2.3