* refactor: Optimized storage aggregation + The storage aggregation logic doesn't account more complex storage setups and relied too much on risky string parsing to guess where partitions are. For example, in my case, I had a LUKS-encrypted drive which lives inside a "crypt," because it couldn't match the type (it only matched "disk" and "part"), it did not include my entire drive at all. Also, Linux devices names aren't always predictable (take mapper devices or complex NVMe paths), so if the RegEx doesn't match the name of those devices, the data just dissapears. I decided to go for a JSON approach making the code shorter and safer. Everything should work about the same. * systemusage: More intuitive filtering for storage devices + Removes "useless" drives from being show on the storage dashboard + Prioritizes the root disk to be shown first * refactor: formatted code properly
329 lines
11 KiB
QML
329 lines
11 KiB
QML
pragma Singleton
|
|
|
|
import qs.config
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|