From 3fb9a36f3bc57da15b59dbffe2f248614c937663 Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 7 May 2026 19:01:42 +0200 Subject: [PATCH 1/2] systemd applet: aggregate counts + lazy running list (step 2) --- plugin/src/systemd_service.rs | 185 ++++++++++++++---------- shell/applets/SystemdApplet.qml | 31 ++-- shell/applets/SystemdMachineSection.qml | 96 ++++++++++-- shell/services/SystemdService.qml | 21 +-- 4 files changed, 210 insertions(+), 123 deletions(-) diff --git a/plugin/src/systemd_service.rs b/plugin/src/systemd_service.rs index 53021d0..f58288d 100644 --- a/plugin/src/systemd_service.rs +++ b/plugin/src/systemd_service.rs @@ -1,11 +1,10 @@ // In-process systemd state for nova-shell. // -// Lists of units and machines are exposed to QML as JSON-encoded QString props -// (`failedUnitsJson`, `containersJson`) rather than QList. cxx-qt -// 0.8.1 does not implement QVariantValue for QVariantMap/QVariantList, and -// cxx-qt main on git regressed qt-build-utils to require QuickControls2.prl -// files that nixpkgs strips. Switch to QList when a release ships -// with both fixes. +// The machine tree is exposed to QML as a JSON-encoded QString (`machinesJson`) +// rather than QList. cxx-qt 0.8.1 does not implement QVariantValue +// for QVariantMap/QVariantList, and cxx-qt main on git regressed qt-build-utils +// to require QuickControls2.prl files that nixpkgs strips. Switch to +// QList when a release ships with both fixes. use core::pin::Pin; use cxx_qt_lib::QString; @@ -26,13 +25,13 @@ pub mod qobject { #[qml_element] #[qml_singleton] #[qproperty(QString, hostname)] - #[qproperty(QString, system_state, cxx_name = "systemState")] - #[qproperty(QString, user_state, cxx_name = "userState")] + // Local failed unit count (drives the bar module label). #[qproperty(i32, failed_count, cxx_name = "failedCount")] - // JSON array: [{ name, description, subState, scope: "system"|"user", machine: "" | name }] - #[qproperty(QString, failed_units_json, cxx_name = "failedUnitsJson")] - // JSON array: [{ name, class, service, systemState, failedUnits: [...] }] - #[qproperty(QString, containers_json, cxx_name = "containersJson")] + // JSON array, local first then nspawn containers. Each entry: + // { name, isLocal, marker, systemState, runningCount, totalCount, + // failedUnits: [{name, description, subState, scope, machine}], + // runningUnits: [...] } + #[qproperty(QString, machines_json, cxx_name = "machinesJson")] type SystemdService = super::SystemdServiceRust; #[qinvokable] @@ -46,7 +45,7 @@ pub mod qobject { impl cxx_qt::Initialize for SystemdService {} } -// systemd1.Manager.ListUnitsFiltered returns a(ssssssouso): name, description, +// systemd1.Manager.ListUnits returns a(ssssssouso): name, description, // load_state, active_state, sub_state, follower, unit_path, job_id, job_type, job_path. type UnitTuple = ( String, @@ -70,7 +69,7 @@ trait SystemdManager { #[zbus(property)] fn system_state(&self) -> zbus::Result; - fn list_units_filtered(&self, states: Vec<&str>) -> zbus::Result>; + fn list_units(&self) -> zbus::Result>; fn restart_unit(&self, name: &str, mode: &str) -> zbus::Result; @@ -88,44 +87,45 @@ trait Machined { } #[derive(Serialize)] -struct UnitJson<'a> { - name: &'a str, - description: &'a str, +struct UnitJson { + name: String, + description: String, #[serde(rename = "subState")] - sub_state: &'a str, - scope: &'a str, - machine: &'a str, + sub_state: String, + scope: String, + machine: String, } #[derive(Serialize)] -struct ContainerJson<'a> { - name: &'a str, - class: &'a str, - service: &'a str, +struct MachineJson { + name: String, + #[serde(rename = "isLocal")] + is_local: bool, + marker: String, #[serde(rename = "systemState")] - system_state: &'a str, + system_state: String, + #[serde(rename = "runningCount")] + running_count: i32, + #[serde(rename = "totalCount")] + total_count: i32, #[serde(rename = "failedUnits")] - failed_units: Vec>, + failed_units: Vec, + #[serde(rename = "runningUnits")] + running_units: Vec, } pub struct SystemdServiceRust { hostname: QString, - system_state: QString, - user_state: QString, failed_count: i32, - failed_units_json: QString, - containers_json: QString, + machines_json: QString, } impl Default for SystemdServiceRust { fn default() -> Self { Self { hostname: QString::from(read_hostname()), - system_state: QString::from("unknown"), - user_state: QString::from("unknown"), failed_count: 0, - failed_units_json: QString::from("[]"), - containers_json: QString::from("[]"), + machines_json: QString::from("[]"), } } } @@ -146,22 +146,47 @@ fn rt() -> &'static Runtime { }) } -async fn fetch_failed(bus: &Connection) -> (String, Vec) { +async fn fetch_units(bus: &Connection) -> (String, Vec) { let mut state = String::from("unknown"); let mut units = Vec::new(); if let Ok(mgr) = SystemdManagerProxy::new(bus).await { if let Ok(s) = mgr.system_state().await { state = s; } - if let Ok(u) = mgr.list_units_filtered(vec!["failed"]).await { + if let Ok(u) = mgr.list_units().await { units = u; } } (state, units) } +// Partition a unit list by active_state. (failed, running) +fn partition_units( + units: Vec, + scope: &str, + machine: &str, +) -> (Vec, Vec, i32) { + let total = units.len() as i32; + let mut failed = Vec::new(); + let mut running = Vec::new(); + for u in units { + let entry = UnitJson { + name: u.0, + description: u.1, + sub_state: u.4, + scope: scope.to_string(), + machine: machine.to_string(), + }; + match u.3.as_str() { + "failed" => failed.push(entry), + "active" => running.push(entry), + _ => {} + } + } + (failed, running, total) +} + async fn poll_async() -> ( - String, String, Vec, Vec, @@ -169,12 +194,11 @@ async fn poll_async() -> ( ) { let mut sys_state = String::from("unknown"); let mut sys_units = Vec::new(); - let mut user_state = String::from("unknown"); let mut user_units = Vec::new(); let mut machines = Vec::new(); if let Ok(c) = Connection::system().await { - let (s, u) = fetch_failed(&c).await; + let (s, u) = fetch_units(&c).await; sys_state = s; sys_units = u; if let Ok(m) = MachinedProxy::new(&c).await { @@ -188,25 +212,11 @@ async fn poll_async() -> ( } } if let Ok(c) = Connection::session().await { - let (s, u) = fetch_failed(&c).await; - user_state = s; + let (_, u) = fetch_units(&c).await; user_units = u; } - (sys_state, user_state, sys_units, user_units, machines) -} - -fn unit_jsons<'a>(units: &'a [UnitTuple], scope: &'a str, machine: &'a str) -> Vec> { - units - .iter() - .map(|u| UnitJson { - name: &u.0, - description: &u.1, - sub_state: &u.4, - scope, - machine, - }) - .collect() + (sys_state, sys_units, user_units, machines) } impl cxx_qt::Initialize for qobject::SystemdService { @@ -217,33 +227,54 @@ impl cxx_qt::Initialize for qobject::SystemdService { impl qobject::SystemdService { fn poll(mut self: Pin<&mut Self>) { - let (sys_state, user_state, sys_units, user_units, machines) = rt().block_on(poll_async()); + let (sys_state, sys_units, user_units, machines) = rt().block_on(poll_async()); - let mut all_failed: Vec = Vec::with_capacity(sys_units.len() + user_units.len()); - all_failed.extend(unit_jsons(&sys_units, "system", "")); - all_failed.extend(unit_jsons(&user_units, "user", "")); - let count = all_failed.len() as i32; - let failed_json = serde_json::to_string(&all_failed).unwrap_or_else(|_| "[]".into()); + let (sys_failed, sys_running, sys_total) = partition_units(sys_units, "system", ""); + let (user_failed, user_running, user_total) = partition_units(user_units, "user", ""); - let containers: Vec = machines - .iter() - .map(|(n, c, s)| ContainerJson { - name: n, - class: c, - service: s, - system_state: "unknown", + let mut failed: Vec = Vec::with_capacity(sys_failed.len() + user_failed.len()); + failed.extend(sys_failed); + failed.extend(user_failed); + let mut running: Vec = Vec::with_capacity(sys_running.len() + user_running.len()); + running.extend(sys_running); + running.extend(user_running); + + let local_failed_count = failed.len() as i32; + let local_running_count = running.len() as i32; + let local_total_count = sys_total + user_total; + + let local = MachineJson { + name: read_hostname(), + is_local: true, + marker: "this machine".into(), + system_state: sys_state, + running_count: local_running_count, + total_count: local_total_count, + failed_units: failed, + running_units: running, + }; + + // Containers: enumerate only; unit fetching for containers comes in step 5. + let mut all_machines = Vec::with_capacity(1 + machines.len()); + all_machines.push(local); + for (name, _class, _service) in &machines { + all_machines.push(MachineJson { + name: name.clone(), + is_local: false, + marker: String::new(), + system_state: "unknown".into(), + running_count: 0, + total_count: 0, failed_units: Vec::new(), - }) - .collect(); - let containers_json = serde_json::to_string(&containers).unwrap_or_else(|_| "[]".into()); + running_units: Vec::new(), + }); + } - self.as_mut().set_system_state(QString::from(sys_state)); - self.as_mut().set_user_state(QString::from(user_state)); + let machines_json = serde_json::to_string(&all_machines).unwrap_or_else(|_| "[]".into()); + + self.as_mut().set_failed_count(local_failed_count); self.as_mut() - .set_failed_units_json(QString::from(failed_json)); - self.as_mut().set_failed_count(count); - self.as_mut() - .set_containers_json(QString::from(containers_json)); + .set_machines_json(QString::from(machines_json)); } fn restart_unit(self: Pin<&mut Self>, name: QString, scope: QString, machine: QString) { diff --git a/shell/applets/SystemdApplet.qml b/shell/applets/SystemdApplet.qml index bd2f1aa..1f25f5d 100644 --- a/shell/applets/SystemdApplet.qml +++ b/shell/applets/SystemdApplet.qml @@ -13,38 +13,31 @@ Column { onHeightChanged: root.contentResized() - // Local machine header + units - SystemdMachineSection { - width: root.width - accentColor: root.accentColor - machineName: "" - title: S.SystemdService.hostname - marker: " this machine" - systemState: S.SystemdService.systemState - units: S.SystemdService.failedUnits - startExpanded: true - } - - // Containers Repeater { - model: S.SystemdService.containers + model: S.SystemdService.machines delegate: Column { id: _row required property var modelData + required property int index width: root.width - Separator {} + Separator { + visible: _row.index > 0 + } SystemdMachineSection { width: _row.width accentColor: root.accentColor - machineName: _row.modelData.name + machineName: _row.modelData.isLocal ? "" : _row.modelData.name title: _row.modelData.name - marker: "" + marker: _row.modelData.marker ?? "" systemState: _row.modelData.systemState ?? "unknown" - units: _row.modelData.failedUnits ?? [] - startExpanded: (_row.modelData.failedUnits ?? []).length > 0 + runningCount: _row.modelData.runningCount ?? 0 + totalCount: _row.modelData.totalCount ?? 0 + failedUnits: _row.modelData.failedUnits ?? [] + runningUnits: _row.modelData.runningUnits ?? [] + onContentResized: root.contentResized() } } } diff --git a/shell/applets/SystemdMachineSection.qml b/shell/applets/SystemdMachineSection.qml index 632c9db..3c7e244 100644 --- a/shell/applets/SystemdMachineSection.qml +++ b/shell/applets/SystemdMachineSection.qml @@ -4,8 +4,9 @@ import QtQuick import "../services" as S import NovaStats as NS -// One section of the systemd applet: a header with title + state chip, -// expandable list of failed units underneath. +// One section of the systemd applet: a header with title, aggregate counts, +// state chip; an auto-expanded list of failed units (hidden when empty); a +// lazy-loaded, collapsed-by-default list of running units. Column { id: root @@ -14,13 +15,21 @@ Column { required property string title required property string marker required property string systemState - required property var units - property bool startExpanded: false + required property int runningCount + required property int totalCount + required property var failedUnits + required property var runningUnits + + signal contentResized + onHeightChanged: root.contentResized() width: parent?.width ?? 0 - property bool _expanded: startExpanded + property bool _runningExpanded: false + readonly property int _failedCount: (failedUnits ?? []).length + + // Header Item { width: root.width height: 32 @@ -51,10 +60,29 @@ Column { elide: Text.ElideRight } + // Aggregate counts: "n running, m/total failed" or "n running" if no failures. + Text { + id: _counts + anchors.right: _stateChip.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: { + if (root.totalCount === 0) + return ""; + const r = root.runningCount + " running"; + if (root._failedCount > 0) + return r + ", " + root._failedCount + "/" + root.totalCount + " failed"; + return r; + } + color: NS.ThemeService.base04 + font.pixelSize: NS.ThemeService.fontSize - 3 + font.family: NS.ThemeService.fontFamily + } + Rectangle { id: _stateChip - anchors.right: _chevron.left - anchors.rightMargin: 8 + anchors.right: parent.right + anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter visible: root.systemState !== "unknown" color: { @@ -79,25 +107,71 @@ Column { font.family: NS.ThemeService.fontFamily } } + } + + // Failed units (auto-expanded; entire block hidden when there are none). + Repeater { + model: root._failedCount > 0 ? root.failedUnits : [] + delegate: SystemdUnitRow { + required property var modelData + unitName: modelData.name + description: modelData.description ?? "" + subState: modelData.subState ?? "" + scope: modelData.scope ?? "system" + machineName: root.machineName + accentColor: root.accentColor + } + } + + // Running units toggle row (only meaningful when there are running units to show). + Item { + visible: (root.runningUnits ?? []).length > 0 + width: root.width + height: 26 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: _runHdrHover.hovered ? NS.ThemeService.base02 : "transparent" + radius: NS.ThemeService.radius + z: -1 + } + + HoverHandler { + id: _runHdrHover + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: "running units (" + (root.runningUnits ?? []).length + ")" + color: NS.ThemeService.base04 + font.pixelSize: NS.ThemeService.fontSize - 2 + font.family: NS.ThemeService.fontFamily + font.letterSpacing: 1 + } Text { - id: _chevron anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: root._expanded ? "" : "" + text: root._runningExpanded ? "" : "" color: NS.ThemeService.base04 font.pixelSize: NS.ThemeService.fontSize - 2 font.family: NS.ThemeService.iconFontFamily } TapHandler { - onTapped: root._expanded = !root._expanded + onTapped: root._runningExpanded = !root._runningExpanded } } + // Lazy-loaded running units list. Repeater materializes rows only when the + // model is non-empty, so feeding `[]` while collapsed avoids per-row cost. Repeater { - model: root._expanded ? root.units : [] + model: root._runningExpanded ? root.runningUnits : [] delegate: SystemdUnitRow { required property var modelData unitName: modelData.name diff --git a/shell/services/SystemdService.qml b/shell/services/SystemdService.qml index 4ba5f38..3a82048 100644 --- a/shell/services/SystemdService.qml +++ b/shell/services/SystemdService.qml @@ -4,29 +4,18 @@ import QtQuick import NovaStats as NS // Thin wrapper around NS.SystemdService: drives the poll Timer and parses the -// JSON-encoded list properties into JS arrays for QML consumers. Restart helper -// proxies through to the Rust singleton. +// JSON-encoded machine tree into a JS array for QML consumers. QtObject { id: root readonly property string hostname: NS.SystemdService.hostname - readonly property string systemState: NS.SystemdService.systemState - readonly property string userState: NS.SystemdService.userState readonly property int failedCount: NS.SystemdService.failedCount - // Parsed [{ name, description, subState, scope, machine }, ...] - readonly property var failedUnits: { + // [{ name, isLocal, marker, systemState, runningCount, totalCount, + // failedUnits: [...], runningUnits: [...] }, ...] + readonly property var machines: { try { - return JSON.parse(NS.SystemdService.failedUnitsJson); - } catch (e) { - return []; - } - } - - // Parsed [{ name, class, service, systemState, failedUnits: [...] }, ...] - readonly property var containers: { - try { - return JSON.parse(NS.SystemdService.containersJson); + return JSON.parse(NS.SystemdService.machinesJson); } catch (e) { return []; } From 6224d8696518d086126c49ba2a40e0a4c6e928b4 Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 7 May 2026 20:00:39 +0200 Subject: [PATCH 2/2] systemd: parse static machines list, render placeholders (step 3) --- plugin/src/modules_service.rs | 45 +++++++++++++++++++++++-- plugin/src/systemd_service.rs | 23 +++++++++++++ shell/applets/SystemdMachineSection.qml | 2 ++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/plugin/src/modules_service.rs b/plugin/src/modules_service.rs index 05e33ad..a08b119 100644 --- a/plugin/src/modules_service.rs +++ b/plugin/src/modules_service.rs @@ -101,9 +101,10 @@ pub mod qobject { #[qproperty(bool, dock_applet_mpris, cxx_name = "dockAppletMpris")] #[qproperty(bool, dock_applet_notifications, cxx_name = "dockAppletNotifications")] #[qproperty(bool, dock_applet_power, cxx_name = "dockAppletPower")] - // Unified systemd bar module (covers local + nspawn containers, and later remotes). + // Unified systemd bar module (covers local + nspawn containers + remotes). #[qproperty(bool, systemd_enable, cxx_name = "systemdEnable")] #[qproperty(i32, systemd_interval, cxx_name = "systemdInterval")] + #[qproperty(QList_QString, systemd_machines, cxx_name = "systemdMachines")] type ModulesService = super::ModulesServiceRust; } @@ -443,6 +444,34 @@ mod data { } } + // Systemd-bar group: enable + poll interval + remote SSH targets. Each + // `machines` entry is a string like "host" or "user@host"; the local + // machine is deduped by hostname even if listed. + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct Systemd { + #[serde(default = "t")] + pub enable: bool, + #[serde(default = "Systemd::d_interval")] + pub interval: i32, + #[serde(default)] + pub machines: Vec, + } + impl Systemd { + fn d_interval() -> i32 { + 15_000 + } + } + impl Default for Systemd { + fn default() -> Self { + Self { + enable: true, + interval: Self::d_interval(), + machines: Vec::new(), + } + } + } + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct StatsDaemon { @@ -518,7 +547,7 @@ mod data { #[serde(default)] pub dock: Dock, #[serde(default)] - pub systemd: WithInterval, + pub systemd: Systemd, #[serde(default)] pub stats_daemon: StatsDaemon, } @@ -595,6 +624,7 @@ pub struct ModulesServiceRust { dock_applet_power: bool, systemd_enable: bool, systemd_interval: i32, + systemd_machines: QList, } impl Default for ModulesServiceRust { @@ -678,6 +708,13 @@ impl ModulesServiceRust { dock_applet_power: d.dock.applets.power, systemd_enable: d.systemd.enable, systemd_interval: d.systemd.interval, + systemd_machines: { + let mut list = QList::::default(); + for s in &d.systemd.machines { + list.append(QString::from(s.as_str())); + } + list + }, } } } @@ -691,6 +728,10 @@ pub(crate) fn config_path(file: &str) -> PathBuf { base.join("nova-shell").join(file) } +pub(crate) fn load_systemd_machines() -> Vec { + load_modules_data().systemd.machines +} + fn load_modules_data() -> ModulesData { let path = config_path("modules.json"); let raw = match std::fs::read_to_string(&path) { diff --git a/plugin/src/systemd_service.rs b/plugin/src/systemd_service.rs index f58288d..fcee982 100644 --- a/plugin/src/systemd_service.rs +++ b/plugin/src/systemd_service.rs @@ -6,6 +6,7 @@ // to require QuickControls2.prl files that nixpkgs strips. Switch to // QList when a release ships with both fixes. +use crate::modules_service; use core::pin::Pin; use cxx_qt_lib::QString; use serde::Serialize; @@ -270,6 +271,28 @@ impl qobject::SystemdService { }); } + // Configured remote machines (placeholders; transport lands in step 4). + // Dedup local: drop entries matching the local hostname or `localhost`, + // with or without a `user@` prefix. + let host = read_hostname(); + let cfg_machines = modules_service::load_systemd_machines(); + for target in cfg_machines { + let host_part = target.rsplit_once('@').map_or(target.as_str(), |(_, h)| h); + if host_part == host || host_part == "localhost" { + continue; + } + all_machines.push(MachineJson { + name: target.clone(), + is_local: false, + marker: String::new(), + system_state: "pending".into(), + running_count: 0, + total_count: 0, + failed_units: Vec::new(), + running_units: Vec::new(), + }); + } + let machines_json = serde_json::to_string(&all_machines).unwrap_or_else(|_| "[]".into()); self.as_mut().set_failed_count(local_failed_count); diff --git a/shell/applets/SystemdMachineSection.qml b/shell/applets/SystemdMachineSection.qml index 3c7e244..b5329f2 100644 --- a/shell/applets/SystemdMachineSection.qml +++ b/shell/applets/SystemdMachineSection.qml @@ -91,6 +91,8 @@ Column { return NS.ThemeService.base0B; if (st === "degraded") return NS.ThemeService.base0A; + if (st === "pending") + return NS.ThemeService.base04; return NS.ThemeService.base08; } opacity: 0.85