From d135331a30c719018b6e5bf2c05166eff28c678c Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 28 Jul 2026 15:40:28 +0100 Subject: [PATCH] quickshell: ssh server monitor widget + btop-style dropdown Co-Authored-By: Claude Opus 5 --- settings/quickshell.nix | 308 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 1f88d66..2de99d9 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -417,6 +417,10 @@ in readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" readonly property string systemctl: "${pkgs.systemd}/bin/systemctl" readonly property string weatherFetch: "${weatherFetchScript}" + readonly property string ssh: "${pkgs.openssh}/bin/ssh" + // /run/wrappers, not the store path: ping needs cap_net_raw + // and only the NixOS wrapper carries it. + readonly property string ping: "/run/wrappers/bin/ping" // Recoloured to the theme accent at draw time (ColorOverlay), // so the stock blue/grey snowflake artwork is fine here. readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg" @@ -1466,6 +1470,150 @@ in anchors.horizontalCenter: barBgRect.horizontalCenter spacing: 16 + // Server monitor: one persistent SSH streaming `top -b` + // from the mediaserver (2s ticks, parsed line by line — + // no per-poll reconnects) plus a streaming ping to the + // router. Glance = CPU/RAM mini meters + ping ms; click + // opens the btop-style dropdown. + Item { + id: srvWidget + width: Theme.barWidth + height: 42 + + property bool connected: false + property real cpu: 0 + property real memUsed: 0 // GiB + property real memTotal: 0 + property real pingMs: -1 + property string uptime: "" + property string load: "" + property var procs: [] + + property var _procsPending: [] + property bool _collecting: false + + function _parseLine(line) { + if (line.indexOf("top - ") === 0) { + if (_collecting) procs = _procsPending; + _collecting = false; + const up = line.match(/up\s+(.*?),\s*\d+\s+users?,/); + if (up) uptime = up[1].replace(/\s+/g, " "); + const la = line.match(/load average:\s*(.*)$/); + if (la) load = la[1]; + return; + } + if (line.indexOf("%Cpu(s)") === 0) { + const m = line.match(/([\d.]+)\s+id/); + if (m) { cpu = Math.max(0, 100 - parseFloat(m[1])); connected = true; } + return; + } + const mem = line.match(/^(\S+) Mem\s*:\s*([\d.]+)\s+total,\s*[\d.]+\s+free,\s*([\d.]+)\s+used/); + if (mem) { + const div = mem[1] === "KiB" ? 1048576 : mem[1] === "MiB" ? 1024 : 1; + memTotal = parseFloat(mem[2]) / div; + memUsed = parseFloat(mem[3]) / div; + return; + } + if (/^\s*PID\s/.test(line)) { _collecting = true; _procsPending = []; return; } + if (_collecting) { + const f = line.trim().split(/\s+/); + if (f.length >= 12) { + _procsPending.push({ pid: f[0], cpu: parseFloat(f[8]), mem: parseFloat(f[9]), name: f.slice(11).join(" ") }); + // top sorts by %CPU; first 10 rows are the story + if (_procsPending.length >= 10) { procs = _procsPending; _collecting = false; } + } else { + procs = _procsPending; _collecting = false; + } + } + } + + Process { + id: srvTop + command: [Commands.ssh, "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + "fred@10.0.0.1", + "env", "LC_ALL=C", "top", "-b", "-d", "2", "-w", "512"] + running: true + stdout: SplitParser { + onRead: data => srvWidget._parseLine(data) + } + onRunningChanged: if (!running) { srvWidget.connected = false; srvRestart.start(); } + } + Timer { id: srvRestart; interval: 10000; onTriggered: srvTop.running = true } + + // Ping is deliberately separate from the SSH stream: + // ping up + ssh down = box alive but wedged (seen it). + Process { + id: srvPing + command: [Commands.ping, "-n", "-i", "2", "10.0.0.1"] + running: true + stdout: SplitParser { + onRead: data => { + const m = data.match(/time=([\d.]+)\s*ms/); + if (m) { srvWidget.pingMs = parseFloat(m[1]); pingStale.restart(); } + } + } + onRunningChanged: if (!running) pingRestart.start() + } + Timer { id: pingRestart; interval: 10000; onTriggered: srvPing.running = true } + // No echo reply for 3 intervals -> show dashes, not a stale number. + Timer { id: pingStale; interval: 6000; onTriggered: srvWidget.pingMs = -1 } + + function openSrvDropdown() { + bar.toggleDropdown(srvDropdown, function() { + let pos = srvWidget.mapToItem(bar.contentItem, 0, srvWidget.height / 2); + srvDropdown.dropdownY = pos.y; + }); + } + + Column { + anchors.centerIn: parent + spacing: 4 + + Rectangle { + width: 24; height: 3; radius: 1.5 + color: Theme.base02 + Rectangle { + width: srvWidget.connected ? Math.max(2, parent.width * Math.min(1, srvWidget.cpu / 100)) : 0 + height: parent.height; radius: parent.radius + color: Theme.base0D + Behavior on width { NumberAnimation { duration: 300 } } + } + } + Rectangle { + width: 24; height: 3; radius: 1.5 + color: Theme.base02 + Rectangle { + width: srvWidget.connected && srvWidget.memTotal > 0 + ? Math.max(2, parent.width * Math.min(1, srvWidget.memUsed / srvWidget.memTotal)) : 0 + height: parent.height; radius: parent.radius + color: Theme.base0C + Behavior on width { NumberAnimation { duration: 300 } } + } + } + SText { + anchors.horizontalCenter: parent.horizontalCenter + text: srvWidget.pingMs < 0 ? "--" + : srvWidget.pingMs < 10 ? srvWidget.pingMs.toFixed(1) + : Math.round(srvWidget.pingMs).toString() + font.pixelSize: 9 + color: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base04 + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: srvWidget.openSrvDropdown() + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== srvDropdown) srvWidget.openSrvDropdown(); + } + } + } + } + // Volume Item { id: volWidget @@ -2340,6 +2488,166 @@ in } // Volume dropdown + // Server monitor dropdown — the btop-at-a-distance view. + BarDropdown { + id: srvDropdown + fullWidth: srvDropdownCol.width + 2 * Theme.panelGap + fullHeight: srvDropdownCol.height + 2 * Theme.panelGap + + Column { + id: srvDropdownCol + anchors.centerIn: parent + width: 300 + spacing: 8 + + Card { + width: parent.width + + Row { + spacing: 6 + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "dns" + color: srvWidget.connected ? Theme.base0B : Theme.base08 + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: "FredOS-Mediaserver" + font.weight: Font.Medium + } + } + + SText { + width: parent.width + text: srvWidget.connected + ? "up " + srvWidget.uptime + " • load " + srvWidget.load + : "unreachable" + font.pixelSize: 10 + color: srvWidget.connected ? Theme.base04 : Theme.base08 + elide: Text.ElideRight + } + + Row { + width: parent.width + spacing: 8 + SText { width: 32; text: "cpu"; font.pixelSize: 11; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } + PillSlider { + width: parent.width - 32 - 64 - 16 + height: 16; trackH: 4 + anchors.verticalCenter: parent.verticalCenter + value: srvWidget.cpu / 100 + fillColor: srvWidget.cpu >= 90 ? Theme.base08 : Theme.base0D + } + SText { + width: 64 + text: srvWidget.cpu.toFixed(1) + "%" + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + width: parent.width + spacing: 8 + SText { width: 32; text: "ram"; font.pixelSize: 11; color: Theme.base04; anchors.verticalCenter: parent.verticalCenter } + PillSlider { + width: parent.width - 32 - 64 - 16 + height: 16; trackH: 4 + anchors.verticalCenter: parent.verticalCenter + value: srvWidget.memTotal > 0 ? srvWidget.memUsed / srvWidget.memTotal : 0 + fillColor: Theme.base0C + } + SText { + width: 64 + text: srvWidget.memUsed.toFixed(1) + "/" + Math.round(srvWidget.memTotal) + "G" + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + width: parent.width + spacing: 8 + SText { width: 32; text: "ping"; font.pixelSize: 11; color: Theme.base04 } + SText { + text: srvWidget.pingMs < 0 ? "--" : srvWidget.pingMs.toFixed(1) + " ms" + font.pixelSize: 11 + color: srvWidget.pingMs < 0 ? Theme.base03 : Theme.base05 + } + } + } + + Card { + width: parent.width + + Row { + spacing: 6 + SIcon { anchors.verticalCenter: parent.verticalCenter; text: "monitor_heart" } + SText { + anchors.verticalCenter: parent.verticalCenter + text: "Processes" + font.weight: Font.Medium + } + } + + Row { + width: parent.width + SText { width: parent.width - 88; text: "name"; font.pixelSize: 10; color: Theme.base03 } + SText { width: 44; text: "cpu%"; font.pixelSize: 10; color: Theme.base03; horizontalAlignment: Text.AlignRight } + SText { width: 44; text: "mem%"; font.pixelSize: 10; color: Theme.base03; horizontalAlignment: Text.AlignRight } + } + + Column { + width: parent.width + spacing: 2 + + Repeater { + model: srvWidget.procs + + Row { + required property var modelData + width: parent.width + height: 17 + SText { + width: parent.width - 88 + text: modelData.name + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + SText { + width: 44 + text: modelData.cpu.toFixed(1) + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + color: modelData.cpu >= 100 ? Theme.base08 + : modelData.cpu >= 25 ? Theme.base0A : Theme.base05 + anchors.verticalCenter: parent.verticalCenter + } + SText { + width: 44 + text: modelData.mem.toFixed(1) + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + color: Theme.base04 + anchors.verticalCenter: parent.verticalCenter + } + } + } + + SText { + visible: srvWidget.procs.length === 0 + text: "no data" + font.pixelSize: 11 + color: Theme.base03 + } + } + } + } + } + BarDropdown { id: volDropdown fullWidth: volDropdownCol.width + 2 * Theme.panelGap