quickshell: ssh server monitor widget + btop-style dropdown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c71cc3a1f
commit
d135331a30
1 changed files with 308 additions and 0 deletions
|
|
@ -417,6 +417,10 @@ in
|
||||||
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
|
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
|
||||||
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
|
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
|
||||||
readonly property string weatherFetch: "${weatherFetchScript}"
|
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),
|
// Recoloured to the theme accent at draw time (ColorOverlay),
|
||||||
// so the stock blue/grey snowflake artwork is fine here.
|
// 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"
|
readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"
|
||||||
|
|
@ -1466,6 +1470,150 @@ in
|
||||||
anchors.horizontalCenter: barBgRect.horizontalCenter
|
anchors.horizontalCenter: barBgRect.horizontalCenter
|
||||||
spacing: 16
|
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
|
// Volume
|
||||||
Item {
|
Item {
|
||||||
id: volWidget
|
id: volWidget
|
||||||
|
|
@ -2340,6 +2488,166 @@ in
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume dropdown
|
// 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 {
|
BarDropdown {
|
||||||
id: volDropdown
|
id: volDropdown
|
||||||
fullWidth: volDropdownCol.width + 2 * Theme.panelGap
|
fullWidth: volDropdownCol.width + 2 * Theme.panelGap
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue