From 8ab3fc5f6b81a7226817c01b974e4b50fd4e9e74 Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 1 May 2026 18:43:35 +0200 Subject: [PATCH] add systemd and machinectl bar modules with applets --- shell/applets/MachinectlApplet.qml | 351 +++++++++++++++++++++++++++ shell/applets/SystemdApplet.qml | 164 +++++++++++++ shell/applets/SystemdUnitRow.qml | 239 ++++++++++++++++++ shell/applets/qmldir | 3 + shell/modules/Bar.qml | 2 + shell/modules/MachinectlModule.qml | 44 ++++ shell/modules/SystemdModule.qml | 55 +++++ shell/modules/qmldir | 2 + shell/services/MachinectlService.qml | 149 ++++++++++++ shell/services/Modules.qml | 10 +- shell/services/SystemdService.qml | 97 ++++++++ shell/services/qmldir | 2 + 12 files changed, 1117 insertions(+), 1 deletion(-) create mode 100644 shell/applets/MachinectlApplet.qml create mode 100644 shell/applets/SystemdApplet.qml create mode 100644 shell/applets/SystemdUnitRow.qml create mode 100644 shell/modules/MachinectlModule.qml create mode 100644 shell/modules/SystemdModule.qml create mode 100644 shell/services/MachinectlService.qml create mode 100644 shell/services/SystemdService.qml diff --git a/shell/applets/MachinectlApplet.qml b/shell/applets/MachinectlApplet.qml new file mode 100644 index 0000000..d3407b4 --- /dev/null +++ b/shell/applets/MachinectlApplet.qml @@ -0,0 +1,351 @@ +import QtQuick +import "../services" as S + +Column { + id: root + + required property color accentColor + property bool active: true + + property bool _localExpanded: true + + // Localhost section header + Item { + width: root.width + height: 32 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: _localHdrHover.hovered ? S.Theme.base02 : "transparent" + radius: S.Theme.radius + z: -1 + } + + HoverHandler { + id: _localHdrHover + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: " localhost" + color: S.Theme.base05 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.fontFamily + } + + Rectangle { + id: _localStateChip + anchors.right: _localChevron.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + visible: S.SystemdService.systemState !== "unknown" + color: { + const st = S.SystemdService.systemState; + if (st === "running") + return S.Theme.base0B; + if (st === "degraded") + return S.Theme.base0A; + return S.Theme.base08; + } + opacity: 0.85 + radius: 3 + width: _localStateLbl.width + 8 + height: 14 + + Text { + id: _localStateLbl + anchors.centerIn: parent + text: S.SystemdService.systemState + color: S.Theme.base00 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + + Text { + id: _localChevron + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: root._localExpanded ? "" : "" + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.iconFontFamily + } + + TapHandler { + onTapped: root._localExpanded = !root._localExpanded + } + } + + // Localhost expanded content + Column { + visible: root._localExpanded + width: root.width + + // System sub-section + Item { + width: root.width + height: 22 + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: "SYSTEM" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + } + + Repeater { + model: S.SystemdService.systemUnits + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description + subState: modelData.subState + isUser: false + machineName: "" + accentColor: root.accentColor + } + } + + Item { + visible: S.SystemdService.systemUnits.length === 0 + width: root.width + height: 22 + Text { + anchors.centerIn: parent + text: "no failures" + color: S.Theme.base0B + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + // User sub-section + Item { + width: root.width + height: 22 + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: "USER" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + } + + Repeater { + model: S.SystemdService.userUnits + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description + subState: modelData.subState + isUser: true + machineName: "" + accentColor: root.accentColor + } + } + + Item { + visible: S.SystemdService.userUnits.length === 0 + width: root.width + height: 22 + Text { + anchors.centerIn: parent + text: "no failures" + color: S.Theme.base0B + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + } + + // Containers + Repeater { + model: S.MachinectlService.machines + + delegate: Column { + id: _machineSection + required property var modelData + required property int index + + property bool _expanded: false + property bool _loading: false + + width: root.width + + Connections { + target: S.MachinectlService + function onMachineReady(machineName) { + if (machineName === _machineSection.modelData.name) + _machineSection._loading = false; + } + } + + Separator {} + + // Machine header + Item { + width: _machineSection.width + height: 32 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: _mHdrHover.hovered ? S.Theme.base02 : "transparent" + radius: S.Theme.radius + z: -1 + } + + HoverHandler { + id: _mHdrHover + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: " " + _machineSection.modelData.name + color: S.Theme.base05 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.fontFamily + elide: Text.ElideRight + width: parent.width - 100 + } + + Rectangle { + id: _mStateChip + anchors.right: _mChevron.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + visible: _machineSection._expanded && !_machineSection._loading + color: { + const st = S.MachinectlService.machineState(_machineSection.modelData.name); + if (st === "running") + return S.Theme.base0B; + if (st === "degraded") + return S.Theme.base0A; + return st === "unknown" ? "transparent" : S.Theme.base08; + } + opacity: 0.85 + radius: 3 + width: _mStateLbl.width + 8 + height: 14 + + Text { + id: _mStateLbl + anchors.centerIn: parent + text: S.MachinectlService.machineState(_machineSection.modelData.name) + color: S.Theme.base00 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + + Text { + id: _mChevron + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: _machineSection._expanded ? "" : "" + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.iconFontFamily + } + + TapHandler { + onTapped: { + _machineSection._expanded = !_machineSection._expanded; + if (_machineSection._expanded) { + _machineSection._loading = true; + S.MachinectlService.fetchMachine(_machineSection.modelData.name); + } + } + } + } + + // Machine expanded content + Column { + visible: _machineSection._expanded + width: _machineSection.width + + Item { + visible: _machineSection._loading + width: _machineSection.width + height: 28 + Text { + anchors.centerIn: parent + text: "loading..." + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + // System units inside the container + Item { + visible: !_machineSection._loading + width: _machineSection.width + height: 22 + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: "SYSTEM" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + } + + Repeater { + model: !_machineSection._loading ? S.MachinectlService.machineUnits(_machineSection.modelData.name) : [] + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description + subState: modelData.subState + isUser: false + machineName: _machineSection.modelData.name + accentColor: root.accentColor + } + } + + Item { + visible: !_machineSection._loading && S.MachinectlService.machineUnits(_machineSection.modelData.name).length === 0 + width: _machineSection.width + height: 22 + Text { + anchors.centerIn: parent + text: "no failures" + color: S.Theme.base0B + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + } + } + } + + Item { + width: 1 + height: 4 + } +} diff --git a/shell/applets/SystemdApplet.qml b/shell/applets/SystemdApplet.qml new file mode 100644 index 0000000..a3868e7 --- /dev/null +++ b/shell/applets/SystemdApplet.qml @@ -0,0 +1,164 @@ +import QtQuick +import "../services" as S + +Column { + id: root + + required property color accentColor + property bool active: true + + // Section header: state label + unit count + Item { + width: root.width + height: 28 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "SYSTEM" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + + Rectangle { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + visible: S.SystemdService.systemState !== "unknown" + color: { + const st = S.SystemdService.systemState; + if (st === "running") + return S.Theme.base0B; + if (st === "degraded") + return S.Theme.base0A; + return S.Theme.base08; + } + opacity: 0.85 + radius: 3 + width: _sysStateLbl.width + 8 + height: 14 + + Text { + id: _sysStateLbl + anchors.centerIn: parent + text: S.SystemdService.systemState + color: S.Theme.base00 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + } + + Repeater { + model: S.SystemdService.systemUnits + + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description + subState: modelData.subState + isUser: false + machineName: "" + accentColor: root.accentColor + onHeightChanged: root.keepPanelOpen?.(300) + } + } + + Item { + visible: S.SystemdService.systemUnits.length === 0 + width: root.width + height: 24 + + Text { + anchors.centerIn: parent + text: "no failed system units" + color: S.Theme.base0B + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + Separator {} + + // User section + Item { + width: root.width + height: 28 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "USER" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + + Rectangle { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + visible: S.SystemdService.userState !== "unknown" + color: { + const st = S.SystemdService.userState; + if (st === "running") + return S.Theme.base0B; + if (st === "degraded") + return S.Theme.base0A; + return S.Theme.base08; + } + opacity: 0.85 + radius: 3 + width: _userStateLbl.width + 8 + height: 14 + + Text { + id: _userStateLbl + anchors.centerIn: parent + text: S.SystemdService.userState + color: S.Theme.base00 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + } + + Repeater { + model: S.SystemdService.userUnits + + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description + subState: modelData.subState + isUser: true + machineName: "" + accentColor: root.accentColor + onHeightChanged: root.keepPanelOpen?.(300) + } + } + + Item { + visible: S.SystemdService.userUnits.length === 0 + width: root.width + height: 24 + + Text { + anchors.centerIn: parent + text: "no failed user units" + color: S.Theme.base0B + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + Item { + width: 1 + height: 4 + } +} diff --git a/shell/applets/SystemdUnitRow.qml b/shell/applets/SystemdUnitRow.qml new file mode 100644 index 0000000..a64d34e --- /dev/null +++ b/shell/applets/SystemdUnitRow.qml @@ -0,0 +1,239 @@ +import QtQuick +import "../services" as S + +Item { + id: root + + required property string unitName + required property string description + required property string subState + required property bool isUser + property string machineName: "" + required property color accentColor + + property string _jText: "" + property bool _expanded: false + property bool _loading: false + + width: parent?.width ?? 0 + height: _row.height + (_expanded ? _journalArea.height : 0) + + Behavior on height { + NumberAnimation { + duration: 150 + } + } + + Connections { + target: S.SystemdService + enabled: root.machineName === "" + function onJournalReady(unitName, isUser, text) { + if (unitName === root.unitName && isUser === root.isUser) { + root._jText = text; + root._loading = false; + } + } + } + + Connections { + target: S.MachinectlService + enabled: root.machineName !== "" + function onMachineJournalReady(mname, unitName, text) { + if (mname === root.machineName && unitName === root.unitName) { + root._jText = text; + root._loading = false; + } + } + } + + function _fetchJournal() { + root._loading = true; + root._jText = ""; + if (root.machineName === "") + S.SystemdService.fetchJournal(root.unitName, root.isUser); + else + S.MachinectlService.fetchMachineJournal(root.machineName, root.unitName); + } + + function _doRestart() { + if (root.machineName === "") + S.SystemdService.restartUnit(root.unitName, root.isUser); + else + S.MachinectlService.restartMachineUnit(root.machineName, root.unitName); + } + + // Row + Item { + id: _row + width: parent.width + height: 32 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: _rowHover.hovered ? S.Theme.base02 : "transparent" + radius: S.Theme.radius + z: -1 + } + + HoverHandler { + id: _rowHover + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: _subStateTag.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.unitName + color: S.Theme.base05 + font.pixelSize: S.Theme.fontSize - 1 + font.family: S.Theme.fontFamily + elide: Text.ElideRight + } + + Rectangle { + id: _subStateTag + anchors.right: _restartBtn.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + color: S.Theme.base08 + opacity: 0.85 + radius: 3 + width: _subStateLbl.width + 8 + height: 14 + + Text { + id: _subStateLbl + anchors.centerIn: parent + text: root.subState + color: S.Theme.base00 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + + // Restart button -  = fa-refresh + Item { + id: _restartBtn + anchors.right: _expandBtn.left + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + + Text { + anchors.centerIn: parent + text: "" + color: _rHover.hovered ? root.accentColor : S.Theme.base04 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.iconFontFamily + Behavior on color { + ColorAnimation { + duration: 80 + } + } + } + + HoverHandler { + id: _rHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + onTapped: { + root._doRestart(); + if (root._expanded) { + root._jText = ""; + Qt.callLater(root._fetchJournal); + } + } + } + } + + // Expand chevron -  = fa-chevron-down,  = fa-chevron-up + Item { + id: _expandBtn + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + + Text { + anchors.centerIn: parent + text: root._expanded ? "" : "" + color: _expHover.hovered ? root.accentColor : S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 1 + font.family: S.Theme.iconFontFamily + Behavior on color { + ColorAnimation { + duration: 80 + } + } + } + + HoverHandler { + id: _expHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + onTapped: { + root._expanded = !root._expanded; + if (root._expanded && root._jText === "") + root._fetchJournal(); + } + } + } + } + + // Journal area + Item { + id: _journalArea + anchors.top: _row.bottom + width: parent.width + height: 120 + visible: root._expanded + clip: true + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + anchors.bottomMargin: 4 + color: S.Theme.base01 + radius: S.Theme.radius + + Text { + anchors.centerIn: parent + visible: root._loading + text: "loading..." + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + + Flickable { + id: _flick + anchors.fill: parent + anchors.margins: 6 + visible: !root._loading + contentHeight: _jContent.height + clip: true + + Text { + id: _jContent + width: _flick.width + text: root._jText + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + wrapMode: Text.WrapAnywhere + } + + onContentHeightChanged: contentY = Math.max(0, contentHeight - height) + } + } + } +} diff --git a/shell/applets/qmldir b/shell/applets/qmldir index 35bd132..355dd6a 100644 --- a/shell/applets/qmldir +++ b/shell/applets/qmldir @@ -10,6 +10,7 @@ GpuApplet 1.0 GpuApplet.qml HexWaveBackground 1.0 HexWaveBackground.qml HoverableListItem 1.0 HoverableListItem.qml InfoRow 1.0 InfoRow.qml +MachinectlApplet 1.0 MachinectlApplet.qml MemoryApplet 1.0 MemoryApplet.qml MprisApplet 1.0 MprisApplet.qml NetworkApplet 1.0 NetworkApplet.qml @@ -17,6 +18,8 @@ NotifApplet 1.0 NotifApplet.qml PowerApplet 1.0 PowerApplet.qml Separator 1.0 Separator.qml SparklineCanvas 1.0 SparklineCanvas.qml +SystemdApplet 1.0 SystemdApplet.qml +SystemdUnitRow 1.0 SystemdUnitRow.qml TemperatureApplet 1.0 TemperatureApplet.qml VolumeApplet 1.0 VolumeApplet.qml WeatherApplet 1.0 WeatherApplet.qml diff --git a/shell/modules/Bar.qml b/shell/modules/Bar.qml index d7605fc..005a4dc 100644 --- a/shell/modules/Bar.qml +++ b/shell/modules/Bar.qml @@ -185,6 +185,8 @@ PanelWindow { M.TemperatureModule {} M.WeatherModule {} M.DiskModule {} + M.SystemdModule {} + M.MachinectlModule {} } // Power + Dock diff --git a/shell/modules/MachinectlModule.qml b/shell/modules/MachinectlModule.qml new file mode 100644 index 0000000..a9026b7 --- /dev/null +++ b/shell/modules/MachinectlModule.qml @@ -0,0 +1,44 @@ +import QtQuick +import Quickshell +import "." as M +import "../services" as S +import "../applets" as C + +M.BarModule { + id: root + active: S.Modules.machinectl.enable + tooltip: { + const n = S.MachinectlService.machines.length; + return n === 0 ? "no containers" : n + " container" + (n === 1 ? "" : "s"); + } + panelNamespace: "nova-machinectl" + panelContentWidth: 320 + panelComponent: Component { + C.MachinectlApplet { + width: parent.width + accentColor: root.accentColor + active: root._showPanel + } + } + + Connections { + target: S.MachinectlService + function onMachineReady() { + root.keepPanelOpen(300); + } + } + + readonly property color _stateColor: S.MachinectlService.anyUnhealthy ? S.Theme.base0A : root.accentColor + + M.BarIcon { + icon: "" + color: root._stateColor + anchors.verticalCenter: parent.verticalCenter + } + M.BarLabel { + label: S.MachinectlService.machines.length.toString() + minText: "9" + color: root._stateColor + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/shell/modules/SystemdModule.qml b/shell/modules/SystemdModule.qml new file mode 100644 index 0000000..517b399 --- /dev/null +++ b/shell/modules/SystemdModule.qml @@ -0,0 +1,55 @@ +import QtQuick +import Quickshell +import "." as M +import "../services" as S +import "../applets" as C + +M.BarModule { + id: root + active: S.Modules.systemd.enable + tooltip: { + const sys = S.SystemdService.systemState; + const fc = S.SystemdService.totalFailedCount; + return "systemd: " + sys + (fc > 0 ? " (" + fc + " failed)" : ""); + } + panelNamespace: "nova-systemd" + panelContentWidth: 300 + panelComponent: Component { + C.SystemdApplet { + width: parent.width + accentColor: root.accentColor + active: root._showPanel + } + } + + Connections { + target: S.SystemdService + function onSystemUnitsChanged() { + root.keepPanelOpen(300); + } + function onUserUnitsChanged() { + root.keepPanelOpen(300); + } + } + + readonly property color _stateColor: { + const st = S.SystemdService.systemState; + if (st === "running") + return root.accentColor; + if (st === "degraded") + return S.Theme.base0A; + return S.Theme.base08; + } + + M.BarIcon { + icon: "" + color: root._stateColor + anchors.verticalCenter: parent.verticalCenter + } + M.BarLabel { + label: S.SystemdService.totalFailedCount > 0 ? S.SystemdService.totalFailedCount + " failed" : S.SystemdService.systemState + minText: "degraded" + color: root._stateColor + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/shell/modules/qmldir b/shell/modules/qmldir index 09884ca..fbdec44 100644 --- a/shell/modules/qmldir +++ b/shell/modules/qmldir @@ -16,6 +16,7 @@ DockModule 1.0 DockModule.qml GpuModule 1.0 GpuModule.qml HoverPanel 1.0 HoverPanel.qml IdleInhibitorModule 1.0 IdleInhibitorModule.qml +MachinectlModule 1.0 MachinectlModule.qml MemoryModule 1.0 MemoryModule.qml MprisModule 1.0 MprisModule.qml NetworkModule 1.0 NetworkModule.qml @@ -31,6 +32,7 @@ ProcessList 1.0 ProcessList.qml PulseAnimation 1.0 PulseAnimation.qml ScreenCapture 1.0 ScreenCapture.qml ScreenCorners 1.0 ScreenCorners.qml +SystemdModule 1.0 SystemdModule.qml TemperatureModule 1.0 TemperatureModule.qml ThemedIcon 1.0 ThemedIcon.qml Tooltip 1.0 Tooltip.qml diff --git a/shell/services/MachinectlService.qml b/shell/services/MachinectlService.qml new file mode 100644 index 0000000..37becc9 --- /dev/null +++ b/shell/services/MachinectlService.qml @@ -0,0 +1,149 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io +import "." as S + +QtObject { + id: root + + property var machines: [] + + // cache: machineName -> {state, units, loading} + property var _cache: ({}) + + readonly property bool anyUnhealthy: { + for (const k of Object.keys(_cache)) { + if ((_cache[k]?.units?.length ?? 0) > 0) + return true; + } + return false; + } + + function machineState(name) { + return _cache[name]?.state ?? "unknown"; + } + function machineUnits(name) { + return _cache[name]?.units ?? []; + } + function machineLoading(name) { + return _cache[name]?.loading ?? false; + } + + signal machineReady(string machineName, string state, var units) + signal machineJournalReady(string machineName, string unitName, string text) + + function fetchMachine(name) { + const c = Object.assign({}, _cache); + c[name] = Object.assign({}, c[name] ?? {}, { + loading: true + }); + _cache = c; + + _machineProc._name = name; + _machineProc.command = ["sh", "-c", "busctl get-property --json=short --machine=" + name + " org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager SystemState 2>/dev/null || echo '{}'; " + "busctl call --json=short --machine=" + name + " org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager ListUnitsFiltered as 1 failed 2>/dev/null || echo '{}'"]; + if (_machineProc.running) + _machineProc.running = false; + _machineProc.running = true; + } + + function fetchMachineJournal(machineName, unitName) { + _machineJournalProc._machine = machineName; + _machineJournalProc._unit = unitName; + _machineJournalProc.command = ["journalctl", "-M", machineName, "-u", unitName, "-n", "80", "--no-pager", "--output=short-precise"]; + if (_machineJournalProc.running) + _machineJournalProc.running = false; + _machineJournalProc.running = true; + } + + function restartMachineUnit(machineName, unitName) { + _machineRestartProc._machine = machineName; + _machineRestartProc.command = ["pkexec", "systemctl", "-M", machineName, "restart", unitName]; + if (_machineRestartProc.running) + _machineRestartProc.running = false; + _machineRestartProc.running = true; + } + + property Timer _poll: Timer { + interval: S.Modules.machinectl.interval ?? 15000 + running: S.Modules.machinectl.enable + repeat: true + triggeredOnStart: true + onTriggered: if (!_listProc.running) + _listProc.running = true + } + + property Process _listProc: Process { + command: ["busctl", "call", "--json=short", "org.freedesktop.machine1", "/org/freedesktop/machine1", "org.freedesktop.machine1.Manager", "ListMachines"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(text.trim()); + const newMachines = (parsed.data || []).map(m => ({ + name: m[0], + class: m[1], + service: m[2] + })); + root.machines = newMachines; + // drop stale cache entries + const names = new Set(newMachines.map(m => m.name)); + const c = {}; + for (const k of Object.keys(root._cache)) { + if (names.has(k)) + c[k] = root._cache[k]; + } + root._cache = c; + } catch (e) { + root.machines = []; + } + } + } + } + + // 2 lines of output: state, failed-units + property Process _machineProc: Process { + property string _name: "" + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + let state = "unknown"; + let units = []; + try { + state = JSON.parse(lines[0] ?? "").data || "unknown"; + } catch (e) {} + try { + const parsed = JSON.parse(lines[1] ?? ""); + units = (parsed.data || []).map(u => ({ + name: u[0], + description: u[1], + loadState: u[2], + activeState: u[3], + subState: u[4] + })); + } catch (e) {} + const c = Object.assign({}, root._cache); + c[root._machineProc._name] = { + state: state, + units: units, + loading: false + }; + root._cache = c; + root.machineReady(root._machineProc._name, state, units); + } + } + } + + property Process _machineJournalProc: Process { + property string _machine: "" + property string _unit: "" + stdout: StdioCollector { + onStreamFinished: root.machineJournalReady(root._machineJournalProc._machine, root._machineJournalProc._unit, text) + } + } + + property Process _machineRestartProc: Process { + property string _machine: "" + onRunningChanged: if (!running && _machine !== "") + root.fetchMachine(_machine) + } +} diff --git a/shell/services/Modules.qml b/shell/services/Modules.qml index 114ae0c..be8b2bf 100644 --- a/shell/services/Modules.qml +++ b/shell/services/Modules.qml @@ -125,13 +125,21 @@ QtObject { power: true } }) + property var systemd: ({ + enable: true, + interval: 15000 + }) + property var machinectl: ({ + enable: true, + interval: 15000 + }) property var statsDaemon: ({ interval: -1 }) // All module keys that have an enable flag — used to default-enable anything // not explicitly mentioned in modules.json - readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock", "dock"] + readonly property var _moduleKeys: ["workspaces", "tray", "windowTitle", "clock", "notifications", "mpris", "volume", "bluetooth", "backlight", "network", "powerProfile", "idleInhibitor", "weather", "temperature", "gpu", "cpu", "memory", "disk", "battery", "privacy", "screenCorners", "power", "backgroundOverlay", "overviewBackdrop", "lock", "dock", "systemd", "machinectl"] // Fallback: if modules.json doesn't exist, enable everything Component.onCompleted: _apply("{}") diff --git a/shell/services/SystemdService.qml b/shell/services/SystemdService.qml new file mode 100644 index 0000000..66dcb93 --- /dev/null +++ b/shell/services/SystemdService.qml @@ -0,0 +1,97 @@ +pragma Singleton + +import QtQuick +import Quickshell.Io +import "." as S + +QtObject { + id: root + + property string systemState: "unknown" + property string userState: "unknown" + property var systemUnits: [] + property var userUnits: [] + readonly property int totalFailedCount: systemUnits.length + userUnits.length + + signal journalReady(string unitName, bool isUser, string text) + + function refresh() { + if (!_pollProc.running) + _pollProc.running = true; + } + + function fetchJournal(unitName, isUser) { + _journalProc.command = isUser ? ["journalctl", "--user", "-u", unitName, "-n", "80", "--no-pager", "--output=short-precise"] : ["journalctl", "-u", unitName, "-n", "80", "--no-pager", "--output=short-precise"]; + _journalProc._unitName = unitName; + _journalProc._isUser = isUser; + if (_journalProc.running) + _journalProc.running = false; + _journalProc.running = true; + } + + function restartUnit(unitName, isUser) { + _restartProc.command = isUser ? ["systemctl", "--user", "restart", unitName] : ["pkexec", "systemctl", "restart", unitName]; + if (_restartProc.running) + _restartProc.running = false; + _restartProc.running = true; + } + + function _parseState(json) { + try { + return JSON.parse(json).data || "unknown"; + } catch (e) { + return "unknown"; + } + } + + function _parseUnits(json) { + try { + const parsed = JSON.parse(json); + return (parsed.data || []).map(u => ({ + name: u[0], + description: u[1], + loadState: u[2], + activeState: u[3], + subState: u[4] + })); + } catch (e) { + return []; + } + } + + property Timer _poll: Timer { + interval: S.Modules.systemd.interval ?? 15000 + running: S.Modules.systemd.enable + repeat: true + triggeredOnStart: true + onTriggered: if (!_pollProc.running) + _pollProc.running = true + } + + // 4 lines of output: systemState, systemUnits, userState, userUnits + property Process _pollProc: Process { + command: ["sh", "-c", "busctl get-property --json=short org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager SystemState 2>/dev/null || echo '{}'; " + "busctl call --json=short org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager ListUnitsFiltered as 1 failed 2>/dev/null || echo '{}'; " + "busctl --user get-property --json=short org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager SystemState 2>/dev/null || echo '{}'; " + "busctl --user call --json=short org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager ListUnitsFiltered as 1 failed 2>/dev/null || echo '{}'"] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + root.systemState = root._parseState(lines[0] ?? ""); + root.systemUnits = root._parseUnits(lines[1] ?? ""); + root.userState = root._parseState(lines[2] ?? ""); + root.userUnits = root._parseUnits(lines[3] ?? ""); + } + } + } + + property Process _journalProc: Process { + property string _unitName: "" + property bool _isUser: false + stdout: StdioCollector { + onStreamFinished: root.journalReady(root._journalProc._unitName, root._journalProc._isUser, text) + } + } + + property Process _restartProc: Process { + onRunningChanged: if (!running) + root.refresh() + } +} diff --git a/shell/services/qmldir b/shell/services/qmldir index 42a29ec..e435fe4 100644 --- a/shell/services/qmldir +++ b/shell/services/qmldir @@ -8,6 +8,7 @@ singleton CpuService 1.0 CpuService.qml singleton DockState 1.0 DockState.qml singleton IdleInhibitService 1.0 IdleInhibitService.qml singleton LockService 1.0 LockService.qml +singleton MachinectlService 1.0 MachinectlService.qml singleton Modules 1.0 Modules.qml singleton MprisService 1.0 MprisService.qml singleton NetworkService 1.0 NetworkService.qml @@ -18,6 +19,7 @@ singleton PowerProfileService 1.0 PowerProfileService.qml singleton ScreenshotService 1.0 ScreenshotService.qml singleton SleepService 1.0 SleepService.qml singleton SystemStats 1.0 SystemStats.qml +singleton SystemdService 1.0 SystemdService.qml singleton Theme 1.0 Theme.qml singleton WeatherService 1.0 WeatherService.qml # keep-sorted end