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

@@ -24,12 +24,14 @@
qsPkg = quickshell.packages.${pkgs.system}.default;
# Runtime tools the bar relies on: discover.sh (one-shot hardware
# probe) and the periodic `df` for disk usage. Everything else is
# read from /proc and /sys directly in QML.
# probe), the periodic `df` for disk usage, and `ss` (iproute2) for
# the per-process network drawer (topnet.sh). Everything else is read
# from /proc and /sys directly in QML.
runtimeInputs = [
qsPkg
pkgs.bash
pkgs.coreutils # df, cat
pkgs.iproute2 # ss, for topnet.sh per-process network usage
];
# Self-contained font set so Nerd Font glyphs always render,
@@ -69,6 +71,7 @@
quickshell.packages.${pkgs.system}.default
pkgs.bash
pkgs.coreutils
pkgs.iproute2 # ss, for topnet.sh
pkgs.nerd-fonts.jetbrains-mono
pkgs.inter
pkgs.qt6.qtdeclarative # qmlls / qmlformat for editing

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"];

97
services/topnet.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Print one frame of the top network-consuming processes, then exit.
#
# Single-shot by design, exactly like topproc.sh: SysStats re-runs it every
# netProcInterval while the Network drawer is open, and exiting per frame
# guarantees stdout is flushed (a long-running loop would block-buffer its
# frame and the QML side would never see it). topproc.sh is the reference for
# the bash-script + single-shot + SplitParser-parse pattern.
#
# How per-process bandwidth is derived: `ss` reports, per TCP socket, the
# cumulative bytes_sent / bytes_received plus the owning pid (users:) and a
# stable socket inode (ino:). We take two snapshots netProcInterval apart, diff
# each socket's byte counters by inode, sum the positive deltas per process,
# and divide by the interval to get bytes/sec. Only TCP carries these counters,
# so UDP/QUIC traffic is not attributed — that is a kernel/ss limitation, not a
# bug here.
#
# IMPORTANT: needs `ss` (iproute2) on PATH — added to runtimeInputs in flake.nix
# alongside bash + coreutils. No awk/getconf (absent at runtime); bash builtins
# and coreutils (sort, head, sleep) only.
#
# Output: one "N <rx_bytes_per_sec> <tx_bytes_per_sec> <name>" line per process,
# highest combined throughput first.
intv="${1:-2}"
n="${2:-6}"
# Snapshot every TCP socket's cumulative rx/tx bytes into the rx/tx assoc
# arrays keyed by socket inode, and remember each inode's owning process in
# pidOf/nameOf. `ss -O` prints one socket per line so a single read loop works.
# -t TCP -i info(byte counters) -n numeric -H no header
# -O oneline -p process -e extended(ino:)
snapshot() {
local -n _rx="$1" _tx="$2" _pid="$3" _name="$4"
local line w ino bs br pid name tmp
set -f # no globbing when word-splitting $line below
while IFS= read -r line; do
ino=""; bs=0; br=0
# ino: / bytes_*: tokens never contain spaces, so word-splitting is safe
# even when a process name does (e.g. "Web Content").
for w in $line; do
case "$w" in
ino:*) ino="${w#ino:}" ;;
bytes_sent:*) bs="${w#bytes_sent:}" ;;
bytes_received:*) br="${w#bytes_received:}" ;;
esac
done
[ -n "$ino" ] || continue
_rx["$ino"]="$br"
_tx["$ino"]="$bs"
# name + pid from the users:(("name",pid=N,fd=M)) token; parse off the
# whole line so a space inside the quoted name can't break it.
tmp="${line#*users:((\"}"; name="${tmp%%\"*}"
tmp="${line#*pid=}"; pid="${tmp%%,*}"
_pid["$ino"]="$pid"
_name["$ino"]="$name"
done < <(ss -tinHOpe 2>/dev/null)
set +f
}
declare -A rx1 tx1 rx2 tx2 pidOf nameOf
snapshot rx1 tx1 pidOf nameOf
sleep "$intv"
snapshot rx2 tx2 pidOf nameOf
# Per-process byte deltas over the interval. A socket present in both snapshots
# contributes its counter delta; a socket new in the second snapshot contributes
# its full byte count (all of it landed within the window). Aggregate by pid.
declare -A prx ptx pname
for ino in "${!rx2[@]}"; do
pid="${pidOf[$ino]}"
[ -n "$pid" ] || continue
if [ -n "${rx1[$ino]:-}" ]; then
drx=$(( ${rx2[$ino]} - ${rx1[$ino]} ))
dtx=$(( ${tx2[$ino]} - ${tx1[$ino]} ))
else
drx="${rx2[$ino]}"
dtx="${tx2[$ino]}"
fi
[ "$drx" -gt 0 ] || drx=0
[ "$dtx" -gt 0 ] || dtx=0
prx["$pid"]=$(( ${prx[$pid]:-0} + drx ))
ptx["$pid"]=$(( ${ptx[$pid]:-0} + dtx ))
pname["$pid"]="${nameOf[$ino]}"
done
# Emit "<total/s> <rx/s> <tx/s> <name>" per process, rank by total throughput,
# take the top n, then reformat to the N-line protocol the QML side parses.
for pid in "${!prx[@]}"; do
r=$(( ${prx[$pid]} / intv ))
t=$(( ${ptx[$pid]} / intv ))
tot=$(( r + t ))
[ "$tot" -gt 0 ] || continue
printf '%d %d %d %s\n' "$tot" "$r" "$t" "${pname[$pid]}"
done | sort -rn | head -n "$n" | while read -r tot r t name; do
printf 'N %s %s %s\n' "$r" "$t" "$name"
done

View File

@@ -1,10 +1,24 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import "../config"
import "../services"
// Down/up throughput on the active interface, with a wifi/ethernet icon.
// Clicking the pill toggles a drawer listing the top network-consuming
// processes, refreshed live (SysStats only scans sockets while the drawer is
// open). CpuGraph.qml is the reference for the drawer pattern.
Item {
id: root
implicitWidth: pill.implicitWidth
implicitHeight: pill.implicitHeight
Pill {
id: pill
anchors.fill: parent
spacing: Theme.spacing
Text {
text: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet
font.family: Theme.monoFont
@@ -41,3 +55,183 @@ Pill {
Layout.preferredWidth: rateMetrics.advanceWidth
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: drawer.visible = !drawer.visible
}
// ---- top network-processes drawer -------------------------------------
PopupWindow {
id: drawer
visible: false
color: "transparent"
implicitWidth: panel.implicitWidth
implicitHeight: panel.implicitHeight
// Hang below the pill, top-right corner aligned to the pill's
// bottom-right (same anchoring rationale as CpuGraph's drawer): anchor
// to the item so the rect is in the pill's own coordinates and tracks
// it wherever the RowLayout places it.
anchor {
item: root
rect.x: 0
rect.y: root.height + Theme.gap
rect.width: root.width
rect.height: 1
edges: Edges.Bottom | Edges.Right
gravity: Edges.Bottom | Edges.Left
}
// Only pay for the socket scan while the drawer is actually showing.
onVisibleChanged: SysStats.netProcPollEnabled = visible
Rectangle {
id: panel
readonly property int rowH: 22
implicitWidth: 340
// Fixed height (header + netProcCount rows): the panel must NOT
// resize when rows replace "sampling…", or the popup's layer surface
// shows a stale/blank buffer under Sway on first open (same class of
// bug as Bar.qml's geomKey remap). A constant size sidesteps it.
implicitHeight: col.implicitHeight + Theme.padding * 2
radius: Theme.radius
color: Theme.barColor
border.color: Theme.surface1
border.width: 1
// Largest combined rate in the current frame, used to scale the
// per-row background bar (network has no fixed ceiling like CPU%).
readonly property real maxRate: {
let m = 1;
const ps = SysStats.topNetProcs;
for (let i = 0; i < ps.length; i++)
m = Math.max(m, ps[i].rx + ps[i].tx);
return m;
}
ColumnLayout {
id: col
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.padding
spacing: 3
// header: icon + title + total throughput
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing
Text {
text: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize + 1
color: Theme.sky
}
Text {
text: "Network by process"
font.family: Theme.font
font.pixelSize: Theme.fontSize
color: Theme.text
Layout.fillWidth: true
}
Text {
text: Icons.down + SysStats.fmtRate(SysStats.rxRate)
+ " " + Icons.up + SysStats.fmtRate(SysStats.txRate)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 3
color: Theme.subtext
}
}
Rectangle { // divider
Layout.fillWidth: true
Layout.topMargin: 2
Layout.bottomMargin: 2
implicitHeight: 1
color: Theme.surface1
}
// Fixed-height body so the panel stays one constant size whether
// it shows the placeholder or the rows (see panel.implicitHeight).
Item {
Layout.fillWidth: true
Layout.topMargin: 2
implicitHeight: SysStats.netProcCount * panel.rowH
Text {
anchors.centerIn: parent
visible: SysStats.topNetProcs.length === 0
text: "sampling…"
font.family: Theme.font
font.pixelSize: Theme.fontSize - 2
color: Theme.subtext
}
Column {
anchors.fill: parent
Repeater {
model: SysStats.topNetProcs
delegate: Item {
id: rowItem
required property var modelData
width: parent.width
height: panel.rowH
// throughput bar behind the text, scaled to the
// busiest process in the frame.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
height: parent.height - 4
radius: 3
width: parent.width *
Math.min(1, (rowItem.modelData.rx + rowItem.modelData.tx) / panel.maxRate)
color: Qt.rgba(Theme.sky.r, Theme.sky.g, Theme.sky.b, 0.16)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: Theme.spacing
Text {
text: rowItem.modelData.name
font.family: Theme.font
font.pixelSize: Theme.fontSize - 1
color: Theme.text
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: Icons.down + " " + SysStats.fmtRate(rowItem.modelData.rx)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 2
color: Theme.green
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 62
}
Text {
text: Icons.up + " " + SysStats.fmtRate(rowItem.modelData.tx)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 2
color: Theme.peach
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 62
}
}
}
}
}
}
}
}
}
}