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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
This commit is contained in:
2026-08-22 20:50:16 +02:00
co-authored by Claude Sonnet 5
parent 60bef752cb
commit dc83e8c156
11 changed files with 978 additions and 0 deletions
+4
View File
@@ -7,6 +7,7 @@ import qs.widgets.notifications
import qs.widgets.osd import qs.widgets.osd
import qs.widgets.sidebar import qs.widgets.sidebar
import qs.widgets.systray import qs.widgets.systray
import qs.widgets.vitals
Scope { Scope {
// Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S. // Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S.
@@ -25,4 +26,7 @@ Scope {
Notifications {} Notifications {}
VolumeOsd {} VolumeOsd {}
// Host vitals HUD — toggle with SUPER CTRL V.
Vitals {}
} }
@@ -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 }
}
}
}
}
@@ -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 }
}
}
}
}
@@ -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;
}
}
}
+1
View File
@@ -13,6 +13,7 @@
../../services/containers.nix ../../services/containers.nix
../../services/network/caddy.nix ../../services/network/caddy.nix
../../services/vpn/tailscale.nix ../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/media/jellyfin.nix ../../services/media/jellyfin.nix
../../services/media/sabnzbd.nix ../../services/media/sabnzbd.nix
../../services/media/prowlarr.nix ../../services/media/prowlarr.nix
+1
View File
@@ -11,6 +11,7 @@
../../common.nix # shared base: user / ssh / nix / firewall ../../common.nix # shared base: user / ssh / nix / firewall
../../services/containers.nix ../../services/containers.nix
../../services/vpn/tailscale.nix ../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
]; ];
networking.hostName = "mars"; networking.hostName = "mars";
+1
View File
@@ -10,6 +10,7 @@
../../services/network/unbound.nix # local recursive resolver (127.0.0.1:5335) ../../services/network/unbound.nix # local recursive resolver (127.0.0.1:5335)
../../services/network/pihole.nix # DNS adblock + DHCP (declarative static leases) ../../services/network/pihole.nix # DNS adblock + DHCP (declarative static leases)
../../services/vpn/tailscale.nix # tailnet node (headscale on neptun) ../../services/vpn/tailscale.nix # tailnet node (headscale on neptun)
../../services/monitoring/node-exporter.nix
]; ];
networking.hostName = "mercury"; networking.hostName = "mercury";
+1
View File
@@ -9,6 +9,7 @@
../../common.nix # shared base: user / ssh / nix / firewall ../../common.nix # shared base: user / ssh / nix / firewall
../../services/network/caddy.nix ../../services/network/caddy.nix
../../services/vpn/tailscale.nix ../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/identity/authentik.nix ../../services/identity/authentik.nix
../../services/vpn/headscale.nix ../../services/vpn/headscale.nix
../../services/vpn/headplane.nix ../../services/vpn/headplane.nix
+1
View File
@@ -15,6 +15,7 @@ in
../../common.nix ../../common.nix
../../services/containers.nix ../../services/containers.nix
../../services/vpn/tailscale.nix ../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/desktop/desktop-hyprland.nix ../../services/desktop/desktop-hyprland.nix
../../services/desktop/desktop-apps.nix ../../services/desktop/desktop-apps.nix
../../services/desktop/librechat.nix ../../services/desktop/librechat.nix
+2
View File
@@ -204,6 +204,8 @@ in
(bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs"))
# toggle the Slant sidebar # toggle the Slant sidebar
(bind "SUPER + CTRL + S" (dsp.global "quickshell: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 + B" (dsp.exec "vivaldi"))
(bind "SUPER + E" (dsp.exec "cosmic-files")) (bind "SUPER + E" (dsp.exec "cosmic-files"))
+9
View File
@@ -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;
}