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
build/
.cache/
logs

View file

@ -258,8 +258,16 @@ Singleton {
return {
enabled: dashboard.enabled,
showOnHover: dashboard.showOnHover,
mediaUpdateInterval: dashboard.mediaUpdateInterval,
updateInterval: dashboard.updateInterval,
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: {
tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight,
tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing,

View file

@ -4,8 +4,19 @@ JsonObject {
property bool enabled: true
property bool showOnHover: true
property int mediaUpdateInterval: 500
property int resourceUpdateInterval: 1000
property int dragThreshold: 50
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 {
readonly property int tabIndicatorHeight: 3

View file

@ -41,6 +41,12 @@ QtObject {
readonly property string label: "launcher"
readonly property string icon: "apps"
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 "taskbar"
import "launcher"
import "dashboard"
import qs.components
import qs.services
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
import qs.components
import qs.components.effects
import qs.components.controls
import qs.services
import qs.utils
import qs.config
import Caelestia.Services
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Mpris
import QtQuick
import QtQuick.Layouts
@ -142,7 +140,7 @@ Item {
anchors.fill: parent
source: Players.active?.trackArtUrl ?? ""
source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
asynchronous: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: width
@ -324,7 +322,7 @@ Item {
disabled: !Players.list.length
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
fallbackIcon: "music_off"
@ -341,13 +339,7 @@ Item {
model: Players.list
MenuItem {
required property MprisPlayer modelData
icon: modelData === Players.active ? "check" : ""
text: Players.getIdentity(modelData)
activeIcon: "animated_images"
}
PlayerItem {}
}
}
@ -380,13 +372,21 @@ Item {
height: visualiser.height * 0.75
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)
asynchronous: true
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 {
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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -106,7 +106,7 @@ Item {
anchors.fill: parent
source: Players.active?.trackArtUrl ?? ""
source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type
asynchronous: true
fillMode: Image.PreserveAspectCrop
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 {
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="([^"]*)"/);
// 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
});
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;
// 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 [device, stats] of deviceMap) {
totalUsed += stats.used;
totalAvail += stats.avail;
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;
// 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]);
}
}
}
}
}