nosignal-shell/services/SystemUsage.qml
2026-03-22 06:02:19 +11:00

330 lines
11 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.config
Singleton {
id: root
// CPU properties
property string cpuName: ""
property real cpuPerc
property real cpuTemp
// GPU properties
readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType
property string autoGpuType: "NONE"
property string gpuName: ""
property real gpuPerc
property real gpuTemp
// Memory properties
property real memUsed
property real memTotal
readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
// Storage properties (aggregated)
readonly property real storagePerc: {
let totalUsed = 0;
let totalSize = 0;
for (const disk of disks) {
totalUsed += disk.used;
totalSize += disk.total;
}
return totalSize > 0 ? totalUsed / totalSize : 0;
}
// Individual disks: Array of { mount, used, total, free, perc }
property var disks: []
property real lastCpuIdle
property real lastCpuTotal
property int refCount
function cleanCpuName(name: string): string {
return name.replace(/\(R\)|\(TM\)|CPU|\d+(?:th|nd|rd|st) Gen |Core |Processor/gi, "").replace(/\s+/g, " ").trim();
}
function cleanGpuName(name: string): string {
return name.replace(/\(R\)|\(TM\)|Graphics/gi, "").replace(/\s+/g, " ").trim();
}
function formatKib(kib: real): var {
const mib = 1024;
const gib = 1024 ** 2;
const tib = 1024 ** 3;
if (kib >= tib)
return {
value: kib / tib,
unit: "TiB"
};
if (kib >= gib)
return {
value: kib / gib,
unit: "GiB"
};
if (kib >= mib)
return {
value: kib / mib,
unit: "MiB"
};
return {
value: kib,
unit: "KiB"
};
}
Timer {
running: root.refCount > 0
interval: Config.dashboard.resourceUpdateInterval
repeat: true
triggeredOnStart: true
onTriggered: {
stat.reload();
meminfo.reload();
storage.running = true;
gpuUsage.running = true;
sensors.running = true;
}
}
// One-time CPU info detection (name)
FileView {
id: cpuinfoInit
path: "/proc/cpuinfo"
onLoaded: {
const nameMatch = text().match(/model name\s*:\s*(.+)/);
if (nameMatch)
root.cpuName = root.cleanCpuName(nameMatch[1]);
}
}
FileView {
id: stat
path: "/proc/stat"
onLoaded: {
const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (data) {
const stats = data.slice(1).map(n => parseInt(n, 10));
const total = stats.reduce((a, b) => a + b, 0);
const idle = stats[3] + (stats[4] ?? 0);
const totalDiff = total - root.lastCpuTotal;
const idleDiff = idle - root.lastCpuIdle;
root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0;
root.lastCpuTotal = total;
root.lastCpuIdle = idle;
}
}
}
FileView {
id: meminfo
path: "/proc/meminfo"
onLoaded: {
const data = text();
root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1;
root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0;
}
}
Process {
id: storage
// Get physical disks with aggregated usage from their partitions
// -J triggers JSON output. -b triggers bytes.
command: ["lsblk", "-J", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE,MOUNTPOINT"]
stdout: StdioCollector {
onStreamFinished: {
const data = JSON.parse(text);
const diskList = [];
const seenDevices = new Set();
// Helper to recursively sum usage from children (partitions, crypt, lvm)
const aggregateUsage = dev => {
let used = 0;
let size = 0;
let isRoot = dev.mountpoint === "/" || (dev.mountpoints && dev.mountpoints.includes("/"));
if (!seenDevices.has(dev.name)) {
// lsblk returns null for empty/unformatted partitions, which parses to 0 here
used = parseInt(dev.fsused) || 0;
size = parseInt(dev.fssize) || 0;
seenDevices.add(dev.name);
}
if (dev.children) {
for (const child of dev.children) {
const stats = aggregateUsage(child);
used += stats.used;
size += stats.size;
if (stats.isRoot)
isRoot = true;
}
}
return {
used,
size,
isRoot
};
};
for (const dev of data.blockdevices) {
// Only process physical disks at the top level
if (dev.type === "disk" && !dev.name.startsWith("zram")) {
const stats = aggregateUsage(dev);
if (stats.size === 0) {
continue;
}
const total = stats.size;
const used = stats.used;
diskList.push({
mount: dev.name,
used: used / 1024 // KiB
,
total: total / 1024 // KiB
,
free: (total - used) / 1024,
perc: total > 0 ? used / total : 0,
hasRoot: stats.isRoot
});
}
}
// Sort by putting the disk with root first, then sort the rest alphabetically
root.disks = diskList.sort((a, b) => {
if (a.hasRoot && !b.hasRoot)
return -1;
if (!a.hasRoot && b.hasRoot)
return 1;
return a.mount.localeCompare(b.mount);
});
}
}
}
// GPU name detection (one-time)
Process {
id: gpuNameDetect
running: true
command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' -f1 || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const output = text.trim();
if (!output)
return;
// Check if it's from nvidia-smi (clean GPU name)
if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) {
root.gpuName = root.cleanGpuName(output);
} else if (output.toLowerCase().includes("rx")) {
root.gpuName = root.cleanGpuName(output);
} else {
// Parse lspci output: extract name from brackets or after colon
// Handles cases like [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT] (rev c0)
const bracketMatch = output.match(/\[([^\]]+)\][^\[]*$/);
if (bracketMatch) {
root.gpuName = root.cleanGpuName(bracketMatch[1]);
} else {
const colonMatch = output.match(/:\s*(.+)/);
if (colonMatch)
root.gpuName = root.cleanGpuName(colonMatch[1]);
}
}
}
}
}
Process {
id: gpuTypeCheck
running: !Config.services.gpuType
command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"]
stdout: StdioCollector {
onStreamFinished: root.autoGpuType = text.trim()
}
}
Process {
id: gpuUsage
command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"]
stdout: StdioCollector {
onStreamFinished: {
if (root.gpuType === "GENERIC") {
const percs = text.trim().split("\n");
const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0);
root.gpuPerc = sum / percs.length / 100;
} else if (root.gpuType === "NVIDIA") {
const [usage, temp] = text.trim().split(",");
root.gpuPerc = parseInt(usage, 10) / 100;
root.gpuTemp = parseInt(temp, 10);
} else {
root.gpuPerc = 0;
root.gpuTemp = 0;
}
}
}
}
Process {
id: sensors
command: ["sensors"]
environment: ({
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8"
})
stdout: StdioCollector {
onStreamFinished: {
let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/);
if (!cpuTemp)
// If AMD Tdie pattern failed, try fallback on Tctl
cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/);
if (cpuTemp)
root.cpuTemp = parseFloat(cpuTemp[1]);
if (root.gpuType !== "GENERIC")
return;
let eligible = false;
let sum = 0;
let count = 0;
for (const line of text.trim().split("\n")) {
if (line === "Adapter: PCI adapter")
eligible = true;
else if (line === "")
eligible = false;
else if (eligible) {
let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (!match)
// Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs)
match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (match) {
sum += parseFloat(match[2]);
count++;
}
}
}
root.gpuTemp = count > 0 ? sum / count : 0;
}
}
}
}