aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/modules/network/NetworkPage.qml204
-rw-r--r--desktop/modules/network/README.md12
-rw-r--r--desktop/modules/perf/Perf.js43
-rw-r--r--desktop/modules/perf/PerfPanel.qml76
-rw-r--r--desktop/modules/perf/README.md10
-rw-r--r--desktop/modules/perf/test_perf.js28
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");