summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorThanh Minh <112760114+tmih06@users.noreply.github.com>2026-02-19 18:53:22 +0700
committerGitHub <noreply@github.com>2026-02-19 22:53:22 +1100
commit46174d1934370b2f4a7da43a3dbc0289c14a5a2d (patch)
tree2f401649de42e204f9904ed7797a3600e4654b57 /services
parentfeat: add wallpaperEnabled option (#1187) (diff)
downloadcaelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.tar.gz
caelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.tar.bz2
caelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.zip
dashboard/performance: new design, configurable, controlcenter support (#975)
* feat(dashboard): add configurable performance resources - Add config options to show/hide Battery, GPU, CPU, Memory, Storage - Make dashboard responsive based on number of visible resources - Scale resource sizes and spacing dynamically for 3, 4, or 5 items - Battery shows charge status and time remaining/to full - Each resource can be individually toggled via config * fix(dashboard): add dynamic right margin for last visible resource Ensures the rightmost resource always has proper margin to prevent content from being cut off at the edge * fix(performance): comment out duplicated value2 properties for memory and storage resources * controlcenter: add settings for dashboard * feat: handle readonly properties and re-usable codes * Feature/performance tab rework (#5) * dashboard/performance: rework tab with card-based grid layout - Replace circular arc meters with card-based grid layout - CPU/GPU cards show hardware name, usage and temperature with horizontal bars - Memory card with 3/4 arc indicator and used/total at bottom - Storage card shows physical disks from lsblk with aggregated partition usage - Add cpuName, gpuName, cpuFreq, cpuMaxFreq, disks properties to SystemUsage - Clean hardware names (remove Intel/AMD/NVIDIA prefixes, TM/R symbols) * dashboard/performance: new hero card design * dashboard/performance: update storage indicators to be reponsive to the physical disks count * dashboard/performance: fix the overlay bounding issue * dashboard/perfromance: refactor code * dashboard/performance: add battery gauge * dashboard/performance: correct battery icon * dashboard/performance: configurable battery * dashboard/performance: update layout * dashboard/performance: move the "Usage" text on top and smaller the font size * dashboard/performance: add a lot of configurations * dashboard/performance: add network metrics * fix: issue with hot reload * chore: update default vaule for mainValueSpacing to 0 * chore: group settings into collapasible sections * chore: making GPU & Battery toggle not showing if not found * chore: fix network widget spacing & text * chore: remove old disk bars configs, add update interval * chore: remove old & unused value, functions * chore: network graph update smoothly when data points change * chore: refactor settings - de-flood settings, most of the font & size setting now follow the global Appearance config - Most of sliders are not needed anymore, only keep the update interval slider - clean up * chore: remove readonly properties from the controlcenter/dashboard. * chore: minor fix * fix: fix warning about onPercChange() * fix: network metrics negative number * fix: add minimal height & width, placeholder for none toggled * fix: network graph move smoothly (#6) * fix: network graph move smoothly * clean up * fix: graph animation even more smooth * fix: padding issue * chore: network icons short description * fix --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
Diffstat (limited to 'services')
-rw-r--r--services/NetworkUsage.qml233
-rw-r--r--services/SystemUsage.qml165
2 files changed, 374 insertions, 24 deletions
diff --git a/services/NetworkUsage.qml b/services/NetworkUsage.qml
new file mode 100644
index 0000000..502ec3a
--- /dev/null
+++ b/services/NetworkUsage.qml
@@ -0,0 +1,233 @@
+pragma Singleton
+
+import qs.config
+
+import Quickshell
+import Quickshell.Io
+
+import QtQuick
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+
+ // Current speeds in bytes per second
+ readonly property real downloadSpeed: _downloadSpeed
+ readonly property real uploadSpeed: _uploadSpeed
+
+ // Total bytes transferred since tracking started
+ readonly property real downloadTotal: _downloadTotal
+ readonly property real uploadTotal: _uploadTotal
+
+ // History of speeds for sparkline (most recent at end)
+ readonly property var downloadHistory: _downloadHistory
+ readonly property var uploadHistory: _uploadHistory
+ readonly property int historyLength: 30
+
+ // Private properties
+ property real _downloadSpeed: 0
+ property real _uploadSpeed: 0
+ property real _downloadTotal: 0
+ property real _uploadTotal: 0
+ property var _downloadHistory: []
+ property var _uploadHistory: []
+
+ // Previous readings for calculating speed
+ property real _prevRxBytes: 0
+ property real _prevTxBytes: 0
+ property real _prevTimestamp: 0
+
+ // Initial readings for calculating totals
+ property real _initialRxBytes: 0
+ property real _initialTxBytes: 0
+ property bool _initialized: false
+
+ function formatBytes(bytes: real): var {
+ // Handle negative or invalid values
+ if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
+ return {
+ value: 0,
+ unit: "B/s"
+ };
+ }
+
+ if (bytes < 1024) {
+ return {
+ value: bytes,
+ unit: "B/s"
+ };
+ } else if (bytes < 1024 * 1024) {
+ return {
+ value: bytes / 1024,
+ unit: "KB/s"
+ };
+ } else if (bytes < 1024 * 1024 * 1024) {
+ return {
+ value: bytes / (1024 * 1024),
+ unit: "MB/s"
+ };
+ } else {
+ return {
+ value: bytes / (1024 * 1024 * 1024),
+ unit: "GB/s"
+ };
+ }
+ }
+
+ function formatBytesTotal(bytes: real): var {
+ // Handle negative or invalid values
+ if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
+ return {
+ value: 0,
+ unit: "B"
+ };
+ }
+
+ if (bytes < 1024) {
+ return {
+ value: bytes,
+ unit: "B"
+ };
+ } else if (bytes < 1024 * 1024) {
+ return {
+ value: bytes / 1024,
+ unit: "KB"
+ };
+ } else if (bytes < 1024 * 1024 * 1024) {
+ return {
+ value: bytes / (1024 * 1024),
+ unit: "MB"
+ };
+ } else {
+ return {
+ value: bytes / (1024 * 1024 * 1024),
+ unit: "GB"
+ };
+ }
+ }
+
+ function parseNetDev(content: string): var {
+ const lines = content.split("\n");
+ let totalRx = 0;
+ let totalTx = 0;
+
+ for (let i = 2; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line)
+ continue;
+
+ const parts = line.split(/\s+/);
+ if (parts.length < 10)
+ continue;
+
+ const iface = parts[0].replace(":", "");
+ // Skip loopback interface
+ if (iface === "lo")
+ continue;
+
+ const rxBytes = parseFloat(parts[1]) || 0;
+ const txBytes = parseFloat(parts[9]) || 0;
+
+ totalRx += rxBytes;
+ totalTx += txBytes;
+ }
+
+ return {
+ rx: totalRx,
+ tx: totalTx
+ };
+ }
+
+ FileView {
+ id: netDevFile
+ path: "/proc/net/dev"
+ }
+
+ Timer {
+ interval: Config.dashboard.resourceUpdateInterval
+ running: root.refCount > 0
+ repeat: true
+ triggeredOnStart: true
+
+ onTriggered: {
+ netDevFile.reload();
+ const content = netDevFile.text();
+ if (!content)
+ return;
+
+ const data = root.parseNetDev(content);
+ const now = Date.now();
+
+ if (!root._initialized) {
+ root._initialRxBytes = data.rx;
+ root._initialTxBytes = data.tx;
+ root._prevRxBytes = data.rx;
+ root._prevTxBytes = data.tx;
+ root._prevTimestamp = now;
+ root._initialized = true;
+ return;
+ }
+
+ const timeDelta = (now - root._prevTimestamp) / 1000; // seconds
+ if (timeDelta > 0) {
+ // Calculate byte deltas
+ let rxDelta = data.rx - root._prevRxBytes;
+ let txDelta = data.tx - root._prevTxBytes;
+
+ // Handle counter overflow (when counters wrap around from max to 0)
+ // This happens when counters exceed 32-bit or 64-bit limits
+ if (rxDelta < 0) {
+ // Counter wrapped around - assume 64-bit counter
+ rxDelta += Math.pow(2, 64);
+ }
+ if (txDelta < 0) {
+ txDelta += Math.pow(2, 64);
+ }
+
+ // Calculate speeds
+ root._downloadSpeed = rxDelta / timeDelta;
+ root._uploadSpeed = txDelta / timeDelta;
+
+ const maxHistory = root.historyLength + 1;
+
+ if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) {
+ let newDownHist = root._downloadHistory.slice();
+ newDownHist.push(root._downloadSpeed);
+ if (newDownHist.length > maxHistory) {
+ newDownHist.shift();
+ }
+ root._downloadHistory = newDownHist;
+ }
+
+ if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) {
+ let newUpHist = root._uploadHistory.slice();
+ newUpHist.push(root._uploadSpeed);
+ if (newUpHist.length > maxHistory) {
+ newUpHist.shift();
+ }
+ root._uploadHistory = newUpHist;
+ }
+ }
+
+ // Calculate totals with overflow handling
+ let downTotal = data.rx - root._initialRxBytes;
+ let upTotal = data.tx - root._initialTxBytes;
+
+ // Handle counter overflow for totals
+ if (downTotal < 0) {
+ downTotal += Math.pow(2, 64);
+ }
+ if (upTotal < 0) {
+ upTotal += Math.pow(2, 64);
+ }
+
+ root._downloadTotal = downTotal;
+ root._uploadTotal = upTotal;
+
+ root._prevRxBytes = data.rx;
+ root._prevTxBytes = data.tx;
+ root._prevTimestamp = now;
+ }
+ }
+}
diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml
index bd02da3..1144932 100644
--- a/services/SystemUsage.qml
+++ b/services/SystemUsage.qml
@@ -8,24 +8,50 @@ 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
- property real storageUsed
- property real storageTotal
- property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 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\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim();
+ }
+
+ function cleanGpuName(name: string): string {
+ return name.replace(/NVIDIA GeForce /gi, "").replace(/NVIDIA /gi, "").replace(/AMD Radeon /gi, "").replace(/AMD /gi, "").replace(/Intel /gi, "").replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim();
+ }
+
function formatKib(kib: real): var {
const mib = 1024;
const gib = 1024 ** 2;
@@ -54,7 +80,7 @@ Singleton {
Timer {
running: root.refCount > 0
- interval: 3000
+ interval: Config.dashboard.resourceUpdateInterval
repeat: true
triggeredOnStart: true
onTriggered: {
@@ -66,6 +92,18 @@ Singleton {
}
}
+ // 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
@@ -101,41 +139,120 @@ Singleton {
Process {
id: storage
- command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"]
+ // Get physical disks with aggregated usage from their partitions
+ // lsblk outputs: NAME SIZE TYPE FSUSED FSSIZE in bytes
+ command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"]
stdout: StdioCollector {
onStreamFinished: {
- const deviceMap = new Map();
+ const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal }
+ const lines = text.trim().split("\n");
- for (const line of text.trim().split("\n")) {
+ for (const line of lines) {
if (line.trim() === "")
continue;
- const parts = line.trim().split(/\s+/);
- if (parts.length >= 3) {
- const device = parts[0];
- const used = parseInt(parts[1], 10) || 0;
- const avail = parseInt(parts[2], 10) || 0;
+ // Parse KEY="VALUE" format
+ const nameMatch = line.match(/NAME="([^"]+)"/);
+ const sizeMatch = line.match(/SIZE="([^"]+)"/);
+ const typeMatch = line.match(/TYPE="([^"]+)"/);
+ const fsusedMatch = line.match(/FSUSED="([^"]*)"/);
+ const fssizeMatch = line.match(/FSSIZE="([^"]*)"/);
+
+ if (!nameMatch || !typeMatch)
+ continue;
+
+ const name = nameMatch[1];
+ const type = typeMatch[1];
+ const size = parseInt(sizeMatch?.[1] || "0", 10);
+ const fsused = parseInt(fsusedMatch?.[1] || "0", 10);
+ const fssize = parseInt(fssizeMatch?.[1] || "0", 10);
+
+ if (type === "disk") {
+ // Skip zram (swap) devices
+ if (name.startsWith("zram"))
+ continue;
- // Only keep the entry with the largest total space for each device
- if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) {
- deviceMap.set(device, {
- used: used,
- avail: avail
- });
+ // Initialize disk entry
+ if (!diskMap[name]) {
+ diskMap[name] = {
+ name: name,
+ totalSize: size,
+ used: 0,
+ fsTotal: 0
+ };
+ }
+ } else if (type === "part") {
+ // Find parent disk (remove trailing numbers/p+numbers)
+ let parentDisk = name.replace(/p?\d+$/, "");
+ // For nvme devices like nvme0n1p1, parent is nvme0n1
+ if (name.match(/nvme\d+n\d+p\d+/))
+ parentDisk = name.replace(/p\d+$/, "");
+
+ // Aggregate partition usage to parent disk
+ if (diskMap[parentDisk]) {
+ diskMap[parentDisk].used += fsused;
+ diskMap[parentDisk].fsTotal += fssize;
}
}
}
+ // Convert map to sorted array
+ const diskList = [];
let totalUsed = 0;
- let totalAvail = 0;
+ let totalSize = 0;
+
+ for (const diskName of Object.keys(diskMap).sort()) {
+ const disk = diskMap[diskName];
+ // Use filesystem total if available, otherwise use disk size
+ const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize;
+ const used = disk.used;
+ const perc = total > 0 ? used / total : 0;
- for (const [device, stats] of deviceMap) {
- totalUsed += stats.used;
- totalAvail += stats.avail;
+ // Convert bytes to KiB for consistency with formatKib
+ diskList.push({
+ mount: disk.name // Using 'mount' property for compatibility
+ ,
+ used: used / 1024,
+ total: total / 1024,
+ free: (total - used) / 1024,
+ perc: perc
+ });
+
+ totalUsed += used;
+ totalSize += total;
}
- root.storageUsed = totalUsed;
- root.storageTotal = totalUsed + totalAvail;
+ root.disks = diskList;
+ }
+ }
+ }
+
+ // GPU name detection (one-time)
+ Process {
+ id: gpuNameDetect
+
+ running: true
+ command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || lspci 2>/dev/null | grep -i 'vga\\|3d\\|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 {
+ // Parse lspci output: extract name from brackets or after colon
+ 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]);
+ }
+ }
}
}
}