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>
This commit is contained in:
Thanh Minh 2026-02-19 18:53:22 +07:00 committed by GitHub
parent 69adb92087
commit 46174d1934
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1683 additions and 210 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
/.qmlls.ini /.qmlls.ini
build/ build/
.cache/ .cache/
logs

View file

@ -258,8 +258,16 @@ Singleton {
return { return {
enabled: dashboard.enabled, enabled: dashboard.enabled,
showOnHover: dashboard.showOnHover, showOnHover: dashboard.showOnHover,
mediaUpdateInterval: dashboard.mediaUpdateInterval, updateInterval: dashboard.updateInterval,
dragThreshold: dashboard.dragThreshold, dragThreshold: dashboard.dragThreshold,
performance: {
showBattery: dashboard.performance.showBattery,
showGpu: dashboard.performance.showGpu,
showCpu: dashboard.performance.showCpu,
showMemory: dashboard.performance.showMemory,
showStorage: dashboard.performance.showStorage,
showNetwork: dashboard.performance.showNetwork
},
sizes: { sizes: {
tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight, tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight,
tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing, tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing,

View file

@ -4,8 +4,19 @@ JsonObject {
property bool enabled: true property bool enabled: true
property bool showOnHover: true property bool showOnHover: true
property int mediaUpdateInterval: 500 property int mediaUpdateInterval: 500
property int resourceUpdateInterval: 1000
property int dragThreshold: 50 property int dragThreshold: 50
property Sizes sizes: Sizes {} property Sizes sizes: Sizes {}
property Performance performance: Performance {}
component Performance: JsonObject {
property bool showBattery: true
property bool showGpu: true
property bool showCpu: true
property bool showMemory: true
property bool showStorage: true
property bool showNetwork: true
}
component Sizes: JsonObject { component Sizes: JsonObject {
readonly property int tabIndicatorHeight: 3 readonly property int tabIndicatorHeight: 3

View file

@ -41,6 +41,12 @@ QtObject {
readonly property string label: "launcher" readonly property string label: "launcher"
readonly property string icon: "apps" readonly property string icon: "apps"
readonly property string component: "launcher/LauncherPane.qml" readonly property string component: "launcher/LauncherPane.qml"
},
QtObject {
readonly property string id: "dashboard"
readonly property string label: "dashboard"
readonly property string icon: "dashboard"
readonly property string component: "dashboard/DashboardPane.qml"
} }
] ]

View file

@ -6,6 +6,7 @@ import "audio"
import "appearance" import "appearance"
import "taskbar" import "taskbar"
import "launcher" import "launcher"
import "dashboard"
import qs.components import qs.components
import qs.services import qs.services
import qs.config import qs.config

View file

@ -0,0 +1,67 @@
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
ColumnLayout {
id: root
property string label: ""
property real value: 0
property real from: 0
property real to: 100
property string suffix: ""
property bool readonly: false
spacing: Appearance.spacing.small
RowLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
StyledText {
visible: root.label !== ""
text: root.label
font.pointSize: Appearance.font.size.normal
color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
}
Item {
Layout.fillWidth: true
}
MaterialIcon {
visible: root.readonly
text: "lock"
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
}
StyledText {
text: Math.round(root.value) + (root.suffix !== "" ? " " + root.suffix : "")
font.pointSize: Appearance.font.size.normal
color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
}
}
StyledRect {
Layout.fillWidth: true
implicitHeight: Appearance.padding.normal
radius: Appearance.rounding.full
color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 1)
opacity: root.readonly ? 0.5 : 1.0
StyledRect {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * ((root.value - root.from) / (root.to - root.from))
radius: parent.radius
color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3primary
}
}
}

View file

@ -0,0 +1,123 @@
pragma ComponentBehavior: Bound
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.components.effects
import qs.components.containers
import qs.services
import qs.config
import qs.utils
import Quickshell
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
Item {
id: root
required property Session session
// General Settings
property bool enabled: Config.dashboard.enabled ?? true
property bool showOnHover: Config.dashboard.showOnHover ?? true
property int updateInterval: Config.dashboard.updateInterval ?? 1000
property int dragThreshold: Config.dashboard.dragThreshold ?? 50
// Performance Resources
property bool showBattery: Config.dashboard.performance.showBattery ?? false
property bool showGpu: Config.dashboard.performance.showGpu ?? true
property bool showCpu: Config.dashboard.performance.showCpu ?? true
property bool showMemory: Config.dashboard.performance.showMemory ?? true
property bool showStorage: Config.dashboard.performance.showStorage ?? true
property bool showNetwork: Config.dashboard.performance.showNetwork ?? true
anchors.fill: parent
function saveConfig() {
Config.dashboard.enabled = root.enabled;
Config.dashboard.showOnHover = root.showOnHover;
Config.dashboard.updateInterval = root.updateInterval;
Config.dashboard.dragThreshold = root.dragThreshold;
Config.dashboard.performance.showBattery = root.showBattery;
Config.dashboard.performance.showGpu = root.showGpu;
Config.dashboard.performance.showCpu = root.showCpu;
Config.dashboard.performance.showMemory = root.showMemory;
Config.dashboard.performance.showStorage = root.showStorage;
Config.dashboard.performance.showNetwork = root.showNetwork;
// Note: sizes properties are readonly and cannot be modified
Config.save();
}
ClippingRectangle {
id: dashboardClippingRect
anchors.fill: parent
anchors.margins: Appearance.padding.normal
anchors.leftMargin: 0
anchors.rightMargin: Appearance.padding.normal
radius: dashboardBorder.innerRadius
color: "transparent"
Loader {
id: dashboardLoader
anchors.fill: parent
anchors.margins: Appearance.padding.large + Appearance.padding.normal
anchors.leftMargin: Appearance.padding.large
anchors.rightMargin: Appearance.padding.large
sourceComponent: dashboardContentComponent
}
}
InnerBorder {
id: dashboardBorder
leftThickness: 0
rightThickness: Appearance.padding.normal
}
Component {
id: dashboardContentComponent
StyledFlickable {
id: dashboardFlickable
flickableDirection: Flickable.VerticalFlick
contentHeight: dashboardLayout.height
StyledScrollBar.vertical: StyledScrollBar {
flickable: dashboardFlickable
}
ColumnLayout {
id: dashboardLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Appearance.spacing.normal
RowLayout {
spacing: Appearance.spacing.smaller
StyledText {
text: qsTr("Dashboard")
font.pointSize: Appearance.font.size.large
font.weight: 500
}
}
// General Settings Section
GeneralSection {
rootItem: root
}
// Performance Resources Section
PerformanceSection {
rootItem: root
}
}
}
}
}

View file

@ -0,0 +1,81 @@
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.services
import qs.config
import QtQuick
import QtQuick.Layouts
SectionContainer {
id: root
required property var rootItem
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("General Settings")
font.pointSize: Appearance.font.size.normal
}
SwitchRow {
label: qsTr("Enabled")
checked: root.rootItem.enabled
onToggled: checked => {
root.rootItem.enabled = checked;
root.rootItem.saveConfig();
}
}
SwitchRow {
label: qsTr("Show on hover")
checked: root.rootItem.showOnHover
onToggled: checked => {
root.rootItem.showOnHover = checked;
root.rootItem.saveConfig();
}
}
SectionContainer {
contentSpacing: Appearance.spacing.normal
SliderInput {
Layout.fillWidth: true
label: qsTr("Update interval")
value: root.rootItem.updateInterval
from: 100
to: 10000
stepSize: 100
suffix: "ms"
validator: IntValidator { bottom: 100; top: 10000 }
formatValueFunction: (val) => Math.round(val).toString()
parseValueFunction: (text) => parseInt(text)
onValueModified: (newValue) => {
root.rootItem.updateInterval = Math.round(newValue);
root.rootItem.saveConfig();
}
}
SliderInput {
Layout.fillWidth: true
label: qsTr("Drag threshold")
value: root.rootItem.dragThreshold
from: 0
to: 100
suffix: "px"
validator: IntValidator { bottom: 0; top: 100 }
formatValueFunction: (val) => Math.round(val).toString()
parseValueFunction: (text) => parseInt(text)
onValueModified: (newValue) => {
root.rootItem.dragThreshold = Math.round(newValue);
root.rootItem.saveConfig();
}
}
}
}

View file

@ -0,0 +1,85 @@
import ".."
import "../components"
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.UPower
import qs.components
import qs.components.controls
import qs.config
import qs.services
SectionContainer {
id: root
required property var rootItem
// GPU toggle is hidden when gpuType is "NONE" (no GPU data available)
readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE"
// Battery toggle is hidden when no laptop battery is present
readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("Performance Resources")
font.pointSize: Appearance.font.size.normal
}
ConnectedButtonGroup {
rootItem: root.rootItem
options: {
let opts = [];
if (root.batteryAvailable)
opts.push({
"label": qsTr("Battery"),
"propertyName": "showBattery",
"onToggled": function(checked) {
root.rootItem.showBattery = checked;
root.rootItem.saveConfig();
}
});
if (root.gpuAvailable)
opts.push({
"label": qsTr("GPU"),
"propertyName": "showGpu",
"onToggled": function(checked) {
root.rootItem.showGpu = checked;
root.rootItem.saveConfig();
}
});
opts.push({
"label": qsTr("CPU"),
"propertyName": "showCpu",
"onToggled": function(checked) {
root.rootItem.showCpu = checked;
root.rootItem.saveConfig();
}
}, {
"label": qsTr("Memory"),
"propertyName": "showMemory",
"onToggled": function(checked) {
root.rootItem.showMemory = checked;
root.rootItem.saveConfig();
}
}, {
"label": qsTr("Storage"),
"propertyName": "showStorage",
"onToggled": function(checked) {
root.rootItem.showStorage = checked;
root.rootItem.saveConfig();
}
}, {
"label": qsTr("Network"),
"propertyName": "showNetwork",
"onToggled": function(checked) {
root.rootItem.showNetwork = checked;
root.rootItem.saveConfig();
}
});
return opts;
}
}
}

View file

@ -1,14 +1,12 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs.components import qs.components
import qs.components.effects
import qs.components.controls import qs.components.controls
import qs.services import qs.services
import qs.utils import qs.utils
import qs.config import qs.config
import Caelestia.Services import Caelestia.Services
import Quickshell import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
@ -142,7 +140,7 @@ Item {
anchors.fill: parent anchors.fill: parent
source: Players.active?.trackArtUrl ?? "" source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
asynchronous: true asynchronous: true
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
sourceSize.width: width sourceSize.width: width
@ -324,7 +322,7 @@ Item {
disabled: !Players.list.length disabled: !Players.list.length
active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null
menu.onItemSelected: item => Players.manualActive = item.modelData menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData
menuItems: playerList.instances menuItems: playerList.instances
fallbackIcon: "music_off" fallbackIcon: "music_off"
@ -341,13 +339,7 @@ Item {
model: Players.list model: Players.list
MenuItem { PlayerItem {}
required property MprisPlayer modelData
icon: modelData === Players.active ? "check" : ""
text: Players.getIdentity(modelData)
activeIcon: "animated_images"
}
} }
} }
@ -380,13 +372,21 @@ Item {
height: visualiser.height * 0.75 height: visualiser.height * 0.75
playing: Players.active?.isPlaying ?? false playing: Players.active?.isPlaying ?? false
speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type
source: Paths.absolutePath(Config.paths.mediaGif) source: Paths.absolutePath(Config.paths.mediaGif)
asynchronous: true asynchronous: true
fillMode: AnimatedImage.PreserveAspectFit fillMode: AnimatedImage.PreserveAspectFit
} }
} }
component PlayerItem: MenuItem {
required property MprisPlayer modelData
icon: modelData === Players.active ? "check" : ""
text: Players.getIdentity(modelData)
activeIcon: "animated_images"
}
component PlayerControl: IconButton { component PlayerControl: IconButton {
Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0)
radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2

File diff suppressed because it is too large Load diff

View file

@ -60,10 +60,10 @@ Item {
id: indicator id: indicator
anchors.top: bar.bottom anchors.top: bar.bottom
anchors.topMargin: Config.dashboard.sizes.tabIndicatorSpacing anchors.topMargin: 5
implicitWidth: bar.currentItem.implicitWidth implicitWidth: bar.currentItem.implicitWidth
implicitHeight: Config.dashboard.sizes.tabIndicatorHeight implicitHeight: 3
x: { x: {
const tab = bar.currentItem; const tab = bar.currentItem;

View file

@ -106,7 +106,7 @@ Item {
anchors.fill: parent anchors.fill: parent
source: Players.active?.trackArtUrl ?? "" source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
asynchronous: true asynchronous: true
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
sourceSize.width: width sourceSize.width: width

233
services/NetworkUsage.qml Normal file
View file

@ -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;
}
}
}

View file

@ -8,24 +8,50 @@ import QtQuick
Singleton { Singleton {
id: root id: root
// CPU properties
property string cpuName: ""
property real cpuPerc property real cpuPerc
property real cpuTemp property real cpuTemp
// GPU properties
readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType
property string autoGpuType: "NONE" property string autoGpuType: "NONE"
property string gpuName: ""
property real gpuPerc property real gpuPerc
property real gpuTemp property real gpuTemp
// Memory properties
property real memUsed property real memUsed
property real memTotal property real memTotal
readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
property real storageUsed
property real storageTotal // Storage properties (aggregated)
property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 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 lastCpuIdle
property real lastCpuTotal property real lastCpuTotal
property int refCount 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 { function formatKib(kib: real): var {
const mib = 1024; const mib = 1024;
const gib = 1024 ** 2; const gib = 1024 ** 2;
@ -54,7 +80,7 @@ Singleton {
Timer { Timer {
running: root.refCount > 0 running: root.refCount > 0
interval: 3000 interval: Config.dashboard.resourceUpdateInterval
repeat: true repeat: true
triggeredOnStart: true triggeredOnStart: true
onTriggered: { 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 { FileView {
id: stat id: stat
@ -101,41 +139,120 @@ Singleton {
Process { Process {
id: storage 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 { stdout: StdioCollector {
onStreamFinished: { 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() === "") if (line.trim() === "")
continue; continue;
const parts = line.trim().split(/\s+/); // Parse KEY="VALUE" format
if (parts.length >= 3) { const nameMatch = line.match(/NAME="([^"]+)"/);
const device = parts[0]; const sizeMatch = line.match(/SIZE="([^"]+)"/);
const used = parseInt(parts[1], 10) || 0; const typeMatch = line.match(/TYPE="([^"]+)"/);
const avail = parseInt(parts[2], 10) || 0; const fsusedMatch = line.match(/FSUSED="([^"]*)"/);
const fssizeMatch = line.match(/FSSIZE="([^"]*)"/);
// Only keep the entry with the largest total space for each device if (!nameMatch || !typeMatch)
if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { continue;
deviceMap.set(device, {
used: used, const name = nameMatch[1];
avail: avail 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;
// 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 totalUsed = 0;
let totalAvail = 0; let totalSize = 0;
for (const [device, stats] of deviceMap) { for (const diskName of Object.keys(diskMap).sort()) {
totalUsed += stats.used; const disk = diskMap[diskName];
totalAvail += stats.avail; // 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;
// 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.disks = diskList;
root.storageTotal = totalUsed + totalAvail; }
}
}
// 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]);
}
}
} }
} }
} }