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

@@ -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
}
}
}
}
}
}
}
}
}
}