diff --git a/flake.nix b/flake.nix index ae06a40..b8163a6 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/services/SysStats.qml b/services/SysStats.qml index 147c821..bbc5966 100644 --- a/services/SysStats.qml +++ b/services/SysStats.qml @@ -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 ", 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"]; diff --git a/services/topnet.sh b/services/topnet.sh new file mode 100755 index 0000000..494a17c --- /dev/null +++ b/services/topnet.sh @@ -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 " 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 " " 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 diff --git a/widgets/Network.qml b/widgets/Network.qml index 7c9cb89..4579cdd 100644 --- a/widgets/Network.qml +++ b/widgets/Network.qml @@ -1,43 +1,237 @@ import QtQuick import QtQuick.Layouts +import Quickshell import "../config" import "../services" // Down/up throughput on the active interface, with a wifi/ethernet icon. -Pill { - Text { - text: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet - font.family: Theme.monoFont - font.pixelSize: Theme.fontSize + 1 - color: Theme.sky - Layout.alignment: Qt.AlignVCenter +// 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 + font.pixelSize: Theme.fontSize + 1 + color: Theme.sky + Layout.alignment: Qt.AlignVCenter + } + + // reserve the width of the widest rate string so values never shift + TextMetrics { + id: rateMetrics + font: rxText.font + text: Icons.down + " 99.9M" + } + + Text { + id: rxText + text: Icons.down + " " + SysStats.fmtRate(SysStats.rxRate) + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize - 1 + color: Theme.green + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: rateMetrics.advanceWidth + } + + Text { + text: Icons.up + " " + SysStats.fmtRate(SysStats.txRate) + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize - 1 + color: Theme.peach + horizontalAlignment: Text.AlignRight + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: rateMetrics.advanceWidth + } } - // reserve the width of the widest rate string so values never shift - TextMetrics { - id: rateMetrics - font: rxText.font - text: Icons.down + " 99.9M" + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: drawer.visible = !drawer.visible } - Text { - id: rxText - text: Icons.down + " " + SysStats.fmtRate(SysStats.rxRate) - font.family: Theme.monoFont - font.pixelSize: Theme.fontSize - 1 - color: Theme.green - horizontalAlignment: Text.AlignRight - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: rateMetrics.advanceWidth - } + // ---- top network-processes drawer ------------------------------------- - Text { - text: Icons.up + " " + SysStats.fmtRate(SysStats.txRate) - font.family: Theme.monoFont - font.pixelSize: Theme.fontSize - 1 - color: Theme.peach - horizontalAlignment: Text.AlignRight - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: rateMetrics.advanceWidth + 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 + } + } + } + } + } + } + } + } } }