Add per-process network drawer to the Network widget

Clicking the Network pill now toggles a drawer listing processes ranked
by network throughput, refreshed live only while open — mirroring the
CPU widget's top-processes drawer.

services/topnet.sh (single-shot, like topproc.sh) takes two `ss` snapshots
procInterval apart, diffs each TCP socket's cumulative rx/tx bytes by inode,
sums positive deltas per owning PID, and prints ranked "N <rx/s> <tx/s>
<name>" frames. SysStats re-runs it while netProcPollEnabled (drawer open)
using the same SplitParser-accumulate / parse-on-exit / re-arm plumbing as
procScan. Network.qml wraps its Pill in an Item and hangs a fixed-size
PopupWindow drawer off it.

ss (iproute2) is added to runtimeInputs since per-process byte accounting
has no virtual-file equivalent. Caveat: ss only exposes byte counters for
TCP sockets, so UDP/QUIC traffic is not attributed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:40:26 +02:00
parent eecf1a1b6a
commit dfb16cce4e
4 changed files with 383 additions and 32 deletions

View File

@@ -37,6 +37,16 @@ Singleton {
property bool procPollEnabled: false
readonly property int procInterval: 2 // seconds between frames
// --- top network processes (same opt-in streaming model as topProcs) ---
// Each entry: { name, rx (bytes/sec down), tx (bytes/sec up) }, highest
// combined throughput first. Set netProcPollEnabled true (Network drawer
// open) to start the streamer; only TCP traffic is attributable (see
// topnet.sh), so UDP/QUIC-heavy apps may underreport.
property var topNetProcs: []
readonly property int netProcCount: 6
property bool netProcPollEnabled: false
readonly property int netProcInterval: 2 // seconds between frames
readonly property bool hasBattery: battery >= 0
// poll interval in seconds
@@ -242,6 +252,24 @@ Singleton {
root.topProcs = out;
}
// Build topNetProcs from one run of topnet.sh: lines of
// "N <rx_bytes/s> <tx_bytes/s> <name>", already ranked by the script.
function _parseNetProcFrame(text) {
if (!text) return;
const lines = text.split("\n");
const out = [];
for (let i = 0; i < lines.length; i++) {
const p = lines[i].split(" ");
if (p[0] !== "N" || p.length < 4) continue;
out.push({
rx: Number(p[1]),
tx: Number(p[2]),
name: p.slice(3).join(" ")
});
}
root.topNetProcs = out;
}
function _tickOnce() {
statView.reload();
cpuInfoView.reload();
@@ -343,6 +371,35 @@ Singleton {
else root.topProcs = [];
}
// ---- top network processes (opt-in, drawer-driven) --------------------
// Mirror of procScan: services/topnet.sh prints one ranked frame and exits;
// we re-run it every ~netProcInterval while netProcPollEnabled (the Network
// drawer is open). Each run sleeps netProcInterval between its two ss
// snapshots, so the bar pays nothing at rest.
property var _netFrame: [] // stdout lines accumulated for the current run
Process {
id: netScan
command: ["bash", Quickshell.shellPath("services/topnet.sh"),
String(root.netProcInterval), String(root.netProcCount)]
onStarted: root._netFrame = [];
onExited: {
root._parseNetProcFrame(root._netFrame.join("\n"));
if (root.netProcPollEnabled) // re-arm for the next frame
Qt.callLater(function () { netScan.running = true; });
}
stdout: SplitParser {
splitMarker: "\n"
onRead: line => { if (line) root._netFrame.push(line); }
}
}
onNetProcPollEnabledChanged: {
if (netProcPollEnabled) netScan.running = true;
else root.topNetProcs = [];
}
// Human-readable byte rate with kB as the smallest unit, e.g. "1.2M", "8K".
function fmtRate(bytes) {
const u = ["K", "M", "G"];