diff options
| author | Thanh Minh <112760114+tmih06@users.noreply.github.com> | 2026-02-19 18:53:22 +0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-19 22:53:22 +1100 |
| commit | 46174d1934370b2f4a7da43a3dbc0289c14a5a2d (patch) | |
| tree | 2f401649de42e204f9904ed7797a3600e4654b57 /services | |
| parent | feat: add wallpaperEnabled option (#1187) (diff) | |
| download | caelestia-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.qml | 233 | ||||
| -rw-r--r-- | services/SystemUsage.qml | 165 |
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]); + } + } } } } |