From dc83e8c156f186edb46b5ae93e8c155b0250228d Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 22 Aug 2026 20:50:16 +0200 Subject: [PATCH] add node_exporter host vitals + quickshell HUD Prometheus node_exporter enabled on every host, plus a quickshell widget (SUPER+CTRL+V on terra) to view live CPU/mem/disk/net/uptime without a separate dashboard. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ --- dotfiles/quickshell/shell.qml | 4 + .../quickshell/widgets/vitals/VitalBar.qml | 94 +++ dotfiles/quickshell/widgets/vitals/Vitals.qml | 543 ++++++++++++++++++ .../quickshell/widgets/vitals/VitalsData.qml | 321 +++++++++++ hosts/jupiter/configuration.nix | 1 + hosts/mars/configuration.nix | 1 + hosts/mercury/configuration.nix | 1 + hosts/neptun/configuration.nix | 1 + hosts/terra/configuration.nix | 1 + hosts/terra/home/hyprland.nix | 2 + services/monitoring/node-exporter.nix | 9 + 11 files changed, 978 insertions(+) create mode 100644 dotfiles/quickshell/widgets/vitals/VitalBar.qml create mode 100644 dotfiles/quickshell/widgets/vitals/Vitals.qml create mode 100644 dotfiles/quickshell/widgets/vitals/VitalsData.qml create mode 100644 services/monitoring/node-exporter.nix diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 5a225a0..6d5fd22 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -7,6 +7,7 @@ import qs.widgets.notifications import qs.widgets.osd import qs.widgets.sidebar import qs.widgets.systray +import qs.widgets.vitals Scope { // Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S. @@ -25,4 +26,7 @@ Scope { Notifications {} VolumeOsd {} + + // Host vitals HUD — toggle with SUPER CTRL V. + Vitals {} } diff --git a/dotfiles/quickshell/widgets/vitals/VitalBar.qml b/dotfiles/quickshell/widgets/vitals/VitalBar.qml new file mode 100644 index 0000000..32f378f --- /dev/null +++ b/dotfiles/quickshell/widgets/vitals/VitalBar.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Shapes + +// Horizontal meter in the Slant language: a chamfered track whose fill is a +// clipped copy of the SAME hexagon, so empty and full always share one +// silhouette (the trick the sidebar's volume meter uses vertically). +Item { + id: bar + + property real value: 0 // 0..1 + property real warn: 0.85 // fraction at which the fill goes hot + property bool unknown: false // no reading — draw an empty, dimmed track + + readonly property real frac: bar.unknown ? 0 : Math.max(0, Math.min(1, bar.value)) + readonly property bool hot: !bar.unknown && bar.frac >= bar.warn + + implicitWidth: 200 + implicitHeight: 13 + + readonly property int chamfer: 5 + + // Track. + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 1 + strokeColor: bar.unknown ? "#3A3D42" : "#7A7B7D" + fillColor: "#292C30" + + startX: bar.chamfer + startY: 0 + PathLine { x: bar.width; y: 0 } + PathLine { x: bar.width; y: bar.height - bar.chamfer } + PathLine { x: bar.width - bar.chamfer; y: bar.height } + PathLine { x: 0; y: bar.height } + PathLine { x: 0; y: bar.chamfer } + PathLine { x: bar.chamfer; y: 0 } + } + } + + // Fill — revealed from the left. + Item { + id: fillClip + + anchors.left: parent.left + anchors.leftMargin: 2 + anchors.top: parent.top + anchors.topMargin: 2 + anchors.bottom: parent.bottom + anchors.bottomMargin: 2 + clip: true + + width: bar.frac * (bar.width - 4) + + Behavior on width { + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + + Shape { + id: fill + + width: bar.width - 4 + height: bar.height - 4 + preferredRendererType: Shape.CurveRenderer + + readonly property int chamfer: bar.chamfer - 2 + + ShapePath { + strokeWidth: 0 + fillColor: bar.hot ? "#FF6B4A" : "#FFD063" + + Behavior on fillColor { + ColorAnimation { + duration: 200 + } + } + + startX: fill.chamfer + startY: 0 + PathLine { x: fill.width; y: 0 } + PathLine { x: fill.width; y: fill.height - fill.chamfer } + PathLine { x: fill.width - fill.chamfer; y: fill.height } + PathLine { x: 0; y: fill.height } + PathLine { x: 0; y: fill.chamfer } + PathLine { x: fill.chamfer; y: 0 } + } + } + } +} diff --git a/dotfiles/quickshell/widgets/vitals/Vitals.qml b/dotfiles/quickshell/widgets/vitals/Vitals.qml new file mode 100644 index 0000000..76ca292 --- /dev/null +++ b/dotfiles/quickshell/widgets/vitals/Vitals.qml @@ -0,0 +1,543 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes + +// Host vitals HUD — the "Slant" language (chamfered panel, trapezoid bevels, +// outward corner chunks, floating cap, slanted dividers) carried over from the +// sidebar and launcher V6, wrapped around this box's own node_exporter feed. +// Toggled with SUPER CTRL V. +Scope { + id: root + + property bool active: false + + function toggle() { + root.active = !root.active; + } + + GlobalShortcut { + name: "vitals" + description: "Toggle the host vitals panel" + onPressed: root.toggle() + } + + VitalsData { + id: vitals + active: root.active + } + + PanelWindow { + id: win + + visible: root.active + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + // Ignore the bars' exclusive zones so the backdrop covers the screen. + exclusionMode: ExclusionMode.Ignore + + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + onVisibleChanged: { + if (visible) + keys.forceActiveFocus(); + } + + // Dim backdrop — click anywhere to dismiss. + Rectangle { + anchors.fill: parent + color: "#0A0A0C" + opacity: 0.5 + + MouseArea { + anchors.fill: parent + onClicked: root.active = false + } + } + + Item { + id: keys + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + root.active = false; + event.accepted = true; + } + } + } + + Item { + id: frame + + anchors.centerIn: parent + width: 620 + height: content.implicitHeight + 2 * 26 + + readonly property int chamfer: 18 + readonly property int bevel: 4 // inward thickness of the bevel borders + readonly property int chunkThick: 8 // outward thickness of the heavy chunks + readonly property int chunkSlant: 12 // slant of their end pieces + readonly property int capSize: 10 // floating triangle cap + readonly property int smallChamfer: 6 + + // Chamfered panel — top-left / bottom-right cut, accent edge. + Shape { + id: panelShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: "#0F1012" + strokeColor: "#FFD063" + strokeWidth: 2 + + startX: frame.chamfer + startY: 0 + PathLine { x: panelShape.width - frame.smallChamfer; y: 0 } + PathLine { x: panelShape.width; y: frame.smallChamfer } + PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer } + PathLine { x: panelShape.width - frame.chamfer; y: panelShape.height } + PathLine { x: frame.smallChamfer; y: panelShape.height } + PathLine { x: 0; y: panelShape.height - frame.smallChamfer } + PathLine { x: 0; y: frame.chamfer } + PathLine { x: frame.chamfer; y: 0 } + } + + // Thick top-left bevel accent. + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + + startX: 0 + startY: frame.chamfer + PathLine { x: frame.chamfer; y: 0 } + PathLine { x: frame.chamfer + 2 * frame.bevel; y: 0 } + PathLine { x: 0; y: frame.chamfer + 2 * frame.bevel } + PathLine { x: 0; y: frame.chamfer } + } + + // Thick bottom-right bevel accent. + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + + startX: panelShape.width + startY: panelShape.height - frame.chamfer + PathLine { x: panelShape.width - frame.chamfer; y: panelShape.height } + PathLine { x: panelShape.width - frame.chamfer - 2 * frame.bevel; y: panelShape.height } + PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer - 2 * frame.bevel } + PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer } + } + + // Floating triangle cap in the bottom-right notch. + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + + startX: panelShape.width + startY: panelShape.height + PathLine { x: panelShape.width; y: panelShape.height - frame.capSize } + PathLine { x: panelShape.width - frame.capSize; y: panelShape.height } + PathLine { x: panelShape.width; y: panelShape.height } + } + + // Heavy outward chunk wrapping the bottom-left corner. + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + + startX: panelShape.width / 3 + startY: panelShape.height + PathLine { x: panelShape.width / 3 - frame.chunkSlant; y: panelShape.height + frame.chunkThick } + PathLine { x: frame.smallChamfer; y: panelShape.height + frame.chunkThick } + PathLine { x: -frame.chunkThick; y: panelShape.height - frame.smallChamfer } + PathLine { x: -frame.chunkThick; y: panelShape.height - panelShape.height / 7 + frame.chunkSlant } + PathLine { x: 0; y: panelShape.height - panelShape.height / 7 } + PathLine { x: 0; y: panelShape.height - frame.smallChamfer } + PathLine { x: frame.smallChamfer; y: panelShape.height } + PathLine { x: panelShape.width / 3; y: panelShape.height } + } + + // Heavy outward chunk wrapping the top-right corner. + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + + startX: panelShape.width + startY: panelShape.height / 3 + PathLine { x: panelShape.width + frame.chunkThick; y: panelShape.height / 3 - frame.chunkSlant } + PathLine { x: panelShape.width + frame.chunkThick; y: frame.smallChamfer } + PathLine { x: panelShape.width - frame.smallChamfer; y: -frame.chunkThick } + PathLine { x: panelShape.width - panelShape.width / 5 + frame.chunkSlant; y: -frame.chunkThick } + PathLine { x: panelShape.width - panelShape.width / 5; y: 0 } + PathLine { x: panelShape.width - frame.smallChamfer; y: 0 } + PathLine { x: panelShape.width; y: frame.smallChamfer } + PathLine { x: panelShape.width; y: panelShape.height / 3 } + } + } + + // ── Content ──────────────────────────────────────────────────── + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 26 + anchors.leftMargin: 30 + anchors.rightMargin: 26 + spacing: 12 + + // Header — slash trio, host, uptime. + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Shape { + id: slashes + implicitWidth: 26 + implicitHeight: 22 + Layout.alignment: Qt.AlignVCenter + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + startX: 6 + startY: 0 + PathLine { x: 10; y: 0 } + PathLine { x: 4; y: slashes.height } + PathLine { x: 0; y: slashes.height } + PathLine { x: 6; y: 0 } + } + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + startX: 15 + startY: 0 + PathLine { x: 19; y: 0 } + PathLine { x: 13; y: slashes.height } + PathLine { x: 9; y: slashes.height } + PathLine { x: 15; y: 0 } + } + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + startX: 24 + startY: 0 + PathLine { x: 28; y: 0 } + PathLine { x: 22; y: slashes.height } + PathLine { x: 18; y: slashes.height } + PathLine { x: 24; y: 0 } + } + } + + Text { + text: (vitals.host || "vitals").toUpperCase() + color: "#EEEEEE" + font.family: "Digital-7 Mono" + font.pointSize: 20 + font.letterSpacing: 2 + } + + Item { + Layout.fillWidth: true + } + + Text { + text: "UP" + color: "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + + Text { + text: vitals.fmtUptime(vitals.uptime) + color: "#FFD063" + font.family: "Digital-7 Mono" + font.pointSize: 16 + } + } + + Slant {} + + // Unreachable exporter — say so rather than drawing zeroes. + Text { + Layout.fillWidth: true + visible: vitals.failed + text: "NODE_EXPORTER UNREACHABLE ON :" + vitals.port + color: "#FF6B4A" + font.family: "Digital-7 Mono" + font.pointSize: 13 + } + + ColumnLayout { + Layout.fillWidth: true + visible: !vitals.failed + spacing: 12 + + Metric { + label: "CPU" + value: vitals.cpu + unknown: !vitals.ratesReady + warn: 0.9 + readout: vitals.ratesReady ? Math.round(vitals.cpu * 100) + "%" : "--" + detail: "LOAD " + vitals.load1.toFixed(2) + " / " + vitals.load5.toFixed(2) + " / " + vitals.load15.toFixed(2) + aside: vitals.cpuThreads + "T " + vitals.fmtTemp(vitals.cpuTemp) + asideHot: vitals.cpuTemp >= 85 + } + + Metric { + label: "MEM" + value: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0 + unknown: !vitals.ready + readout: vitals.memTotal > 0 ? Math.round(vitals.memUsed / vitals.memTotal * 100) + "%" : "--" + detail: vitals.fmtBytes(vitals.memUsed) + " / " + vitals.fmtBytes(vitals.memTotal) + aside: "" + } + + // No GPU-busy counter exists in node_exporter, so the bar + // tracks power draw against the card's cap — a real + // reading, labelled for what it is rather than faked as + // utilisation. + Metric { + label: "GPU" + visible: isFinite(vitals.gpuTemp) + value: isFinite(vitals.gpuPower) && vitals.gpuPowerCap > 0 ? vitals.gpuPower / vitals.gpuPowerCap : 0 + unknown: !isFinite(vitals.gpuPower) + warn: 0.9 + readout: isFinite(vitals.gpuPower) ? Math.round(vitals.gpuPower) + "W" : "--" + detail: (vitals.gpuPowerCap > 0 ? "CAP " + Math.round(vitals.gpuPowerCap) + "W" : "") + (isFinite(vitals.gpuClock) ? " SCLK " + Math.round(vitals.gpuClock) + " MHZ" : "") + aside: vitals.fmtTemp(vitals.gpuTemp) + (isFinite(vitals.gpuHotspot) ? " / " + vitals.fmtTemp(vitals.gpuHotspot) : "") + asideHot: vitals.gpuHotspot >= 95 + } + } + + Slant { + visible: !vitals.failed + } + + // Disks. + ColumnLayout { + Layout.fillWidth: true + visible: !vitals.failed + spacing: 6 + + Repeater { + model: vitals.disks + + delegate: RowLayout { + id: diskRow + required property var modelData + + readonly property real frac: diskRow.modelData.size > 0 ? diskRow.modelData.used / diskRow.modelData.size : 0 + + Layout.fillWidth: true + spacing: 12 + + Text { + Layout.preferredWidth: 96 + text: diskRow.modelData.mount + color: "#7A7B7D" + font.pointSize: 9 + elide: Text.ElideMiddle + } + + VitalBar { + Layout.fillWidth: true + implicitHeight: 11 + value: diskRow.frac + warn: 0.9 + } + + Text { + Layout.preferredWidth: 48 + horizontalAlignment: Text.AlignRight + text: Math.round(diskRow.frac * 100) + "%" + color: diskRow.frac >= 0.9 ? "#FF6B4A" : "#EEEEEE" + font.family: "Digital-7 Mono" + font.pointSize: 13 + } + + Text { + Layout.preferredWidth: 104 + horizontalAlignment: Text.AlignRight + text: vitals.fmtBytes(diskRow.modelData.size - diskRow.modelData.used) + " FREE" + color: "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + } + } + } + + Slant { + visible: !vitals.failed + } + + // Network. + RowLayout { + Layout.fillWidth: true + visible: !vitals.failed + spacing: 12 + + Text { + Layout.preferredWidth: 96 + text: vitals.netIface || "NET" + color: "#7A7B7D" + font.pointSize: 9 + } + + Text { + text: "RX" + color: "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + + Text { + text: vitals.fmtRate(vitals.netRx) + color: "#FFD063" + font.family: "Digital-7 Mono" + font.pointSize: 14 + } + + Item { + Layout.fillWidth: true + } + + Text { + text: "TX" + color: "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + + Text { + text: vitals.fmtRate(vitals.netTx) + color: "#FFD063" + font.family: "Digital-7 Mono" + font.pointSize: 14 + + // Right-align the TX readout against the panel edge the + // disk rows' "FREE" column already lines up with. + Layout.preferredWidth: 104 + horizontalAlignment: Text.AlignRight + } + } + } + } + } + + // ── Local pieces ─────────────────────────────────────────────────────── + + // A labelled bar with a readout, a sub-line and a right-hand aside. + component Metric: ColumnLayout { + id: metric + + property string label: "" + property string readout: "" + property string detail: "" + property string aside: "" + property bool asideHot: false + property real value: 0 + property real warn: 0.85 + property bool unknown: false + + Layout.fillWidth: true + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + Layout.preferredWidth: 46 + text: metric.label + color: "#FFD063" + font.family: "Digital-7 Mono" + font.pointSize: 15 + font.letterSpacing: 1 + } + + VitalBar { + Layout.fillWidth: true + value: metric.value + warn: metric.warn + unknown: metric.unknown + } + + Text { + Layout.preferredWidth: 54 + horizontalAlignment: Text.AlignRight + text: metric.readout + color: "#EEEEEE" + font.family: "Digital-7 Mono" + font.pointSize: 16 + } + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 58 + spacing: 12 + + Text { + text: metric.detail + color: "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + + Item { + Layout.fillWidth: true + } + + Text { + visible: metric.aside.length > 0 + text: metric.aside + color: metric.asideHot ? "#FF6B4A" : "#7A7B7D" + font.family: "Digital-7 Mono" + font.pointSize: 11 + } + } + } + + // Slanted divider — the launcher/sidebar motif. + component Slant: Item { + Layout.fillWidth: true + implicitHeight: 3 + + Shape { + id: divider + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeWidth: 0 + fillColor: "#FFD063" + startX: 6 + startY: 0 + PathLine { x: divider.width; y: 0 } + PathLine { x: divider.width - 6; y: divider.height } + PathLine { x: 0; y: divider.height } + PathLine { x: 6; y: 0 } + } + } + } +} diff --git a/dotfiles/quickshell/widgets/vitals/VitalsData.qml b/dotfiles/quickshell/widgets/vitals/VitalsData.qml new file mode 100644 index 0000000..5d9ca59 --- /dev/null +++ b/dotfiles/quickshell/widgets/vitals/VitalsData.qml @@ -0,0 +1,321 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick + +// Vitals source: scrapes this host's own node_exporter over loopback. +// +// The exporter comes from services/monitoring/node-exporter.nix — the same +// collection layer the homelab dashboard scrapes over the tailnet — so what +// this panel shows and what the dashboard graphs can never drift apart. +// :9100 is firewalled to tailscale0 for everyone else, but loopback is always +// reachable, so no extra hole is opened for this. +// +// CPU busy and network throughput are counter DELTAS: the first sample after +// `active` flips on only primes the counters, and `ratesReady` stays false +// until a second one gives them an interval to divide by. +Scope { + id: root + + // Poll only while the panel is on screen — no cost when hidden. + property bool active: false + property int interval: 2000 + property int port: 9100 + + // ── Readings ─────────────────────────────────────────────────────────── + property bool ready: false // one successful scrape happened + property bool ratesReady: false // two — so deltas are meaningful + property bool failed: false // exporter unreachable / no metrics + + property string host: "" + property real uptime: 0 // seconds + + property real cpu: 0 // 0..1 busy + property int cpuThreads: 0 + property real load1: 0 + property real load5: 0 + property real load15: 0 + property real cpuTemp: NaN // °C + + property real memUsed: 0 // bytes + property real memTotal: 0 + + property real gpuTemp: NaN // edge + property real gpuHotspot: NaN // junction + property real gpuPower: NaN // W + property real gpuPowerCap: NaN + property real gpuClock: NaN // MHz, shader clock + property real nvmeTemp: NaN + + property var disks: [] // [{ mount, used, size }] + + property string netIface: "" + property real netRx: 0 // bytes/s + property real netTx: 0 + + // ── Delta state ──────────────────────────────────────────────────────── + property double _prevTime: 0 + property double _prevIdle: 0 + property double _prevTotal: 0 + property double _prevRx: 0 + property double _prevTx: 0 + + onActiveChanged: { + if (!root.active) { + // Drop the counters so reopening the panel doesn't average a rate + // across however long it sat hidden. + root._prevTime = 0; + root.ratesReady = false; + } + } + + // Local filesystems worth showing; everything else (tmpfs, ramfs, and the + // cifs mount of jupiter, which is another host's disk, not terra's) is out. + readonly property var _fsTypes: ["ext4", "btrfs", "xfs", "vfat", "f2fs"] + + // Virtual/overlay interfaces that would drown out the real NIC. + readonly property var _skipIface: ["lo", "docker", "podman", "veth", "br-", "virbr", "cni"] + + function _label(s, key) { + const m = s.match(new RegExp(key + '="([^"]*)"')); + return m ? m[1] : ""; + } + + function _parse(text) { + const lines = text.split("\n"); + + let idle = 0, total = 0; + const seenCpu = {}; + let memTotal = 0, memAvail = 0, bootTime = 0; + let l1 = 0, l5 = 0, l15 = 0; + let host = ""; + + // hwmon is keyed by an opaque chip id; node_hwmon_chip_names maps it to + // the driver (amdgpu/k10temp/nvme) but is NOT guaranteed to be emitted + // before the readings, so collect raw and resolve after the loop. + const chipName = {}; + const tempRaw = {}, powerRaw = {}, freqRaw = {}; + + const fsSize = {}, fsAvail = {}; + const rxByIface = {}, txByIface = {}; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.length === 0 || line.charCodeAt(0) === 35 /* '#' */) + continue; + + const sp = line.lastIndexOf(" "); + if (sp < 0) + continue; + + const key = line.substring(0, sp); + const val = parseFloat(line.substring(sp + 1)); + if (!isFinite(val)) + continue; + + if (key.startsWith("node_cpu_seconds_total{")) { + total += val; + const mode = root._label(key, "mode"); + if (mode === "idle") + idle += val; + seenCpu[root._label(key, "cpu")] = true; + } else if (key === "node_memory_MemTotal_bytes") { + memTotal = val; + } else if (key === "node_memory_MemAvailable_bytes") { + memAvail = val; + } else if (key === "node_load1") { + l1 = val; + } else if (key === "node_load5") { + l5 = val; + } else if (key === "node_load15") { + l15 = val; + } else if (key === "node_boot_time_seconds") { + bootTime = val; + } else if (key.startsWith("node_uname_info{")) { + host = root._label(key, "nodename"); + } else if (key.startsWith("node_hwmon_chip_names{")) { + chipName[root._label(key, "chip")] = root._label(key, "chip_name"); + } else if (key.startsWith("node_hwmon_temp_celsius{")) { + const c = root._label(key, "chip"); + (tempRaw[c] = tempRaw[c] || {})[root._label(key, "sensor")] = val; + } else if (key.startsWith("node_hwmon_power_average_watt{")) { + powerRaw[root._label(key, "chip")] = val; + } else if (key.startsWith("node_hwmon_power_cap_watt{")) { + const c = root._label(key, "chip"); + (freqRaw[c] = freqRaw[c] || {})["cap"] = val; + } else if (key.startsWith("node_hwmon_freq_freq_mhz{")) { + const c = root._label(key, "chip"); + (freqRaw[c] = freqRaw[c] || {})[root._label(key, "sensor")] = val; + } else if (key.startsWith("node_filesystem_size_bytes{")) { + const mp = root._label(key, "mountpoint"); + if (root._fsTypes.indexOf(root._label(key, "fstype")) >= 0) + fsSize[mp] = val; + } else if (key.startsWith("node_filesystem_avail_bytes{")) { + fsAvail[root._label(key, "mountpoint")] = val; + } else if (key.startsWith("node_network_receive_bytes_total{")) { + rxByIface[root._label(key, "device")] = val; + } else if (key.startsWith("node_network_transmit_bytes_total{")) { + txByIface[root._label(key, "device")] = val; + } + } + + if (memTotal <= 0) { + // Reachable but not serving node metrics — treat as a failure + // rather than rendering a panel full of zeroes. + root.failed = true; + return; + } + + // Resolve hwmon chips by driver name. + const byDriver = {}; + for (const chip in tempRaw) + byDriver[chipName[chip] || chip] = { temp: tempRaw[chip], chip: chip }; + + const cpuChip = byDriver["k10temp"] || byDriver["coretemp"] || byDriver["zenpower"]; + root.cpuTemp = cpuChip ? (cpuChip.temp["temp1"] ?? NaN) : NaN; + + const gpu = byDriver["amdgpu"]; + if (gpu) { + root.gpuTemp = gpu.temp["temp1"] ?? NaN; // edge + root.gpuHotspot = gpu.temp["temp2"] ?? NaN; // junction + root.gpuPower = powerRaw[gpu.chip] ?? NaN; + root.gpuPowerCap = freqRaw[gpu.chip] ? (freqRaw[gpu.chip]["cap"] ?? NaN) : NaN; + root.gpuClock = freqRaw[gpu.chip] ? (freqRaw[gpu.chip]["sclk"] ?? NaN) : NaN; + } else { + root.gpuTemp = NaN; + root.gpuHotspot = NaN; + root.gpuPower = NaN; + root.gpuPowerCap = NaN; + root.gpuClock = NaN; + } + + const nvme = byDriver["nvme"]; + root.nvmeTemp = nvme ? (nvme.temp["temp1"] ?? NaN) : NaN; + + // Filesystems. /nix/store is the same device as / on every host here, + // so listing it twice would just be noise. + const mounts = []; + for (const mp in fsSize) { + if (mp === "/nix/store") + continue; + const size = fsSize[mp]; + const avail = fsAvail[mp]; + if (!(size > 0) || avail === undefined) + continue; + mounts.push({ mount: mp, used: size - avail, size: size }); + } + mounts.sort((a, b) => a.mount === "/" ? -1 : b.mount === "/" ? 1 : a.mount.localeCompare(b.mount)); + root.disks = mounts; + + // Busiest real interface. + let iface = "", best = -1; + for (const dev in rxByIface) { + let skip = false; + for (let s = 0; s < root._skipIface.length; s++) { + if (dev === root._skipIface[s] || dev.indexOf(root._skipIface[s]) === 0) { + skip = true; + break; + } + } + if (skip) + continue; + if (rxByIface[dev] > best) { + best = rxByIface[dev]; + iface = dev; + } + } + root.netIface = iface; + + const rx = iface ? (rxByIface[iface] ?? 0) : 0; + const tx = iface ? (txByIface[iface] ?? 0) : 0; + + // Rates. + const now = Date.now() / 1000; + const dt = now - root._prevTime; + if (root._prevTime > 0 && dt > 0) { + const dTotal = total - root._prevTotal; + if (dTotal > 0) + root.cpu = Math.max(0, Math.min(1, 1 - (idle - root._prevIdle) / dTotal)); + root.netRx = Math.max(0, (rx - root._prevRx) / dt); + root.netTx = Math.max(0, (tx - root._prevTx) / dt); + root.ratesReady = true; + } + root._prevTime = now; + root._prevIdle = idle; + root._prevTotal = total; + root._prevRx = rx; + root._prevTx = tx; + + root.cpuThreads = Object.keys(seenCpu).length; + root.memTotal = memTotal; + root.memUsed = memTotal - memAvail; + root.load1 = l1; + root.load5 = l5; + root.load15 = l15; + root.host = host; + root.uptime = bootTime > 0 ? (Date.now() / 1000 - bootTime) : 0; + + root.failed = false; + root.ready = true; + } + + // ── Formatting helpers, shared with the panel ────────────────────────── + function fmtBytes(b) { + if (!isFinite(b)) + return "--"; + const u = ["B", "K", "M", "G", "T"]; + let i = 0; + while (b >= 1024 && i < u.length - 1) { + b /= 1024; + i++; + } + return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1)) + u[i]; + } + + function fmtRate(b) { + return root.ratesReady ? root.fmtBytes(b) + "/S" : "--"; + } + + function fmtTemp(c) { + return isFinite(c) ? Math.round(c) + "°" : "--"; + } + + function fmtUptime(s) { + if (!(s > 0)) + return "--"; + const d = Math.floor(s / 86400); + const h = Math.floor(s % 86400 / 3600); + const m = Math.floor(s % 3600 / 60); + return d > 0 ? d + "D " + h + "H" : h > 0 ? h + "H " + m + "M" : m + "M"; + } + + // ── Polling ──────────────────────────────────────────────────────────── + Process { + id: scrape + + // Filtered at the source: the full endpoint is ~1400 lines and only + // these families are drawn. + command: ["sh", "-c", "curl -s --max-time 2 http://127.0.0.1:" + root.port + "/metrics | grep -E '^node_(cpu_seconds_total|memory_MemTotal_bytes|memory_MemAvailable_bytes|load1|load5|load15|boot_time_seconds|uname_info|hwmon_chip_names|hwmon_temp_celsius|hwmon_power_average_watt|hwmon_power_cap_watt|hwmon_freq_freq_mhz|filesystem_avail_bytes|filesystem_size_bytes|network_receive_bytes_total|network_transmit_bytes_total)[ {]'"] + + stdout: StdioCollector { + onStreamFinished: { + if (this.text.length === 0) + root.failed = true; + else + root._parse(this.text); + } + } + } + + Timer { + interval: root.interval + running: root.active + repeat: true + triggeredOnStart: true + onTriggered: { + if (!scrape.running) + scrape.running = true; + } + } +} diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 4d0c0b8..ba947f7 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -13,6 +13,7 @@ ../../services/containers.nix ../../services/network/caddy.nix ../../services/vpn/tailscale.nix + ../../services/monitoring/node-exporter.nix ../../services/media/jellyfin.nix ../../services/media/sabnzbd.nix ../../services/media/prowlarr.nix diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 40f001e..13a0d3f 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -11,6 +11,7 @@ ../../common.nix # shared base: user / ssh / nix / firewall ../../services/containers.nix ../../services/vpn/tailscale.nix + ../../services/monitoring/node-exporter.nix ]; networking.hostName = "mars"; diff --git a/hosts/mercury/configuration.nix b/hosts/mercury/configuration.nix index 0ec3955..8062ae3 100644 --- a/hosts/mercury/configuration.nix +++ b/hosts/mercury/configuration.nix @@ -10,6 +10,7 @@ ../../services/network/unbound.nix # local recursive resolver (127.0.0.1:5335) ../../services/network/pihole.nix # DNS adblock + DHCP (declarative static leases) ../../services/vpn/tailscale.nix # tailnet node (headscale on neptun) + ../../services/monitoring/node-exporter.nix ]; networking.hostName = "mercury"; diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index c15d6f4..15da275 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -9,6 +9,7 @@ ../../common.nix # shared base: user / ssh / nix / firewall ../../services/network/caddy.nix ../../services/vpn/tailscale.nix + ../../services/monitoring/node-exporter.nix ../../services/identity/authentik.nix ../../services/vpn/headscale.nix ../../services/vpn/headplane.nix diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index deaff91..03dd041 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -15,6 +15,7 @@ in ../../common.nix ../../services/containers.nix ../../services/vpn/tailscale.nix + ../../services/monitoring/node-exporter.nix ../../services/desktop/desktop-hyprland.nix ../../services/desktop/desktop-apps.nix ../../services/desktop/librechat.nix diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index 4c157f0..5dd1d16 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -204,6 +204,8 @@ in (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) # toggle the Slant sidebar (bind "SUPER + CTRL + S" (dsp.global "quickshell:sidebar")) + # toggle the host vitals HUD + (bind "SUPER + CTRL + V" (dsp.global "quickshell:vitals")) (bind "SUPER + B" (dsp.exec "vivaldi")) (bind "SUPER + E" (dsp.exec "cosmic-files")) diff --git a/services/monitoring/node-exporter.nix b/services/monitoring/node-exporter.nix new file mode 100644 index 0000000..4b6fd6e --- /dev/null +++ b/services/monitoring/node-exporter.nix @@ -0,0 +1,9 @@ +{ ... }: + +# Prometheus node_exporter — host vitals (CPU/mem/disk/net/uptime) for the +# homelab dashboard. No openFirewall needed: tailscale.nix already trusts +# tailscale0, so :9100 is reachable over the tailnet and blocked on every +# other interface (LAN, public) without any extra rule here. +{ + services.prometheus.exporters.node.enable = true; +}