systemd applet: aggregate counts + lazy running list (step 2)

This commit is contained in:
Damocles 2026-05-07 19:01:42 +02:00
parent 6fc7e8bc8a
commit 3fb9a36f3b
4 changed files with 210 additions and 123 deletions

View file

@ -1,11 +1,10 @@
// In-process systemd state for nova-shell. // In-process systemd state for nova-shell.
// //
// Lists of units and machines are exposed to QML as JSON-encoded QString props // The machine tree is exposed to QML as a JSON-encoded QString (`machinesJson`)
// (`failedUnitsJson`, `containersJson`) rather than QList<QVariantMap>. cxx-qt // rather than QList<QVariantMap>. cxx-qt 0.8.1 does not implement QVariantValue
// 0.8.1 does not implement QVariantValue for QVariantMap/QVariantList, and // for QVariantMap/QVariantList, and cxx-qt main on git regressed qt-build-utils
// cxx-qt main on git regressed qt-build-utils to require QuickControls2.prl // to require QuickControls2.prl files that nixpkgs strips. Switch to
// files that nixpkgs strips. Switch to QList<QVariantMap> when a release ships // QList<QVariantMap> when a release ships with both fixes.
// with both fixes.
use core::pin::Pin; use core::pin::Pin;
use cxx_qt_lib::QString; use cxx_qt_lib::QString;
@ -26,13 +25,13 @@ pub mod qobject {
#[qml_element] #[qml_element]
#[qml_singleton] #[qml_singleton]
#[qproperty(QString, hostname)] #[qproperty(QString, hostname)]
#[qproperty(QString, system_state, cxx_name = "systemState")] // Local failed unit count (drives the bar module label).
#[qproperty(QString, user_state, cxx_name = "userState")]
#[qproperty(i32, failed_count, cxx_name = "failedCount")] #[qproperty(i32, failed_count, cxx_name = "failedCount")]
// JSON array: [{ name, description, subState, scope: "system"|"user", machine: "" | name }] // JSON array, local first then nspawn containers. Each entry:
#[qproperty(QString, failed_units_json, cxx_name = "failedUnitsJson")] // { name, isLocal, marker, systemState, runningCount, totalCount,
// JSON array: [{ name, class, service, systemState, failedUnits: [...] }] // failedUnits: [{name, description, subState, scope, machine}],
#[qproperty(QString, containers_json, cxx_name = "containersJson")] // runningUnits: [...] }
#[qproperty(QString, machines_json, cxx_name = "machinesJson")]
type SystemdService = super::SystemdServiceRust; type SystemdService = super::SystemdServiceRust;
#[qinvokable] #[qinvokable]
@ -46,7 +45,7 @@ pub mod qobject {
impl cxx_qt::Initialize for SystemdService {} 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. // load_state, active_state, sub_state, follower, unit_path, job_id, job_type, job_path.
type UnitTuple = ( type UnitTuple = (
String, String,
@ -70,7 +69,7 @@ trait SystemdManager {
#[zbus(property)] #[zbus(property)]
fn system_state(&self) -> zbus::Result<String>; fn system_state(&self) -> zbus::Result<String>;
fn list_units_filtered(&self, states: Vec<&str>) -> zbus::Result<Vec<UnitTuple>>; fn list_units(&self) -> zbus::Result<Vec<UnitTuple>>;
fn restart_unit(&self, name: &str, mode: &str) fn restart_unit(&self, name: &str, mode: &str)
-> zbus::Result<zbus::zvariant::OwnedObjectPath>; -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
@ -88,44 +87,45 @@ trait Machined {
} }
#[derive(Serialize)] #[derive(Serialize)]
struct UnitJson<'a> { struct UnitJson {
name: &'a str, name: String,
description: &'a str, description: String,
#[serde(rename = "subState")] #[serde(rename = "subState")]
sub_state: &'a str, sub_state: String,
scope: &'a str, scope: String,
machine: &'a str, machine: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct ContainerJson<'a> { struct MachineJson {
name: &'a str, name: String,
class: &'a str, #[serde(rename = "isLocal")]
service: &'a str, is_local: bool,
marker: String,
#[serde(rename = "systemState")] #[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")] #[serde(rename = "failedUnits")]
failed_units: Vec<UnitJson<'a>>, failed_units: Vec<UnitJson>,
#[serde(rename = "runningUnits")]
running_units: Vec<UnitJson>,
} }
pub struct SystemdServiceRust { pub struct SystemdServiceRust {
hostname: QString, hostname: QString,
system_state: QString,
user_state: QString,
failed_count: i32, failed_count: i32,
failed_units_json: QString, machines_json: QString,
containers_json: QString,
} }
impl Default for SystemdServiceRust { impl Default for SystemdServiceRust {
fn default() -> Self { fn default() -> Self {
Self { Self {
hostname: QString::from(read_hostname()), hostname: QString::from(read_hostname()),
system_state: QString::from("unknown"),
user_state: QString::from("unknown"),
failed_count: 0, failed_count: 0,
failed_units_json: QString::from("[]"), machines_json: QString::from("[]"),
containers_json: QString::from("[]"),
} }
} }
} }
@ -146,22 +146,47 @@ fn rt() -> &'static Runtime {
}) })
} }
async fn fetch_failed(bus: &Connection) -> (String, Vec<UnitTuple>) { async fn fetch_units(bus: &Connection) -> (String, Vec<UnitTuple>) {
let mut state = String::from("unknown"); let mut state = String::from("unknown");
let mut units = Vec::new(); let mut units = Vec::new();
if let Ok(mgr) = SystemdManagerProxy::new(bus).await { if let Ok(mgr) = SystemdManagerProxy::new(bus).await {
if let Ok(s) = mgr.system_state().await { if let Ok(s) = mgr.system_state().await {
state = s; state = s;
} }
if let Ok(u) = mgr.list_units_filtered(vec!["failed"]).await { if let Ok(u) = mgr.list_units().await {
units = u; units = u;
} }
} }
(state, units) (state, units)
} }
// Partition a unit list by active_state. (failed, running)
fn partition_units(
units: Vec<UnitTuple>,
scope: &str,
machine: &str,
) -> (Vec<UnitJson>, Vec<UnitJson>, 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() -> ( async fn poll_async() -> (
String,
String, String,
Vec<UnitTuple>, Vec<UnitTuple>,
Vec<UnitTuple>, Vec<UnitTuple>,
@ -169,12 +194,11 @@ async fn poll_async() -> (
) { ) {
let mut sys_state = String::from("unknown"); let mut sys_state = String::from("unknown");
let mut sys_units = Vec::new(); let mut sys_units = Vec::new();
let mut user_state = String::from("unknown");
let mut user_units = Vec::new(); let mut user_units = Vec::new();
let mut machines = Vec::new(); let mut machines = Vec::new();
if let Ok(c) = Connection::system().await { 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_state = s;
sys_units = u; sys_units = u;
if let Ok(m) = MachinedProxy::new(&c).await { if let Ok(m) = MachinedProxy::new(&c).await {
@ -188,25 +212,11 @@ async fn poll_async() -> (
} }
} }
if let Ok(c) = Connection::session().await { if let Ok(c) = Connection::session().await {
let (s, u) = fetch_failed(&c).await; let (_, u) = fetch_units(&c).await;
user_state = s;
user_units = u; user_units = u;
} }
(sys_state, user_state, sys_units, user_units, machines) (sys_state, sys_units, user_units, machines)
}
fn unit_jsons<'a>(units: &'a [UnitTuple], scope: &'a str, machine: &'a str) -> Vec<UnitJson<'a>> {
units
.iter()
.map(|u| UnitJson {
name: &u.0,
description: &u.1,
sub_state: &u.4,
scope,
machine,
})
.collect()
} }
impl cxx_qt::Initialize for qobject::SystemdService { impl cxx_qt::Initialize for qobject::SystemdService {
@ -217,33 +227,54 @@ impl cxx_qt::Initialize for qobject::SystemdService {
impl qobject::SystemdService { impl qobject::SystemdService {
fn poll(mut self: Pin<&mut Self>) { 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<UnitJson> = Vec::with_capacity(sys_units.len() + user_units.len()); let (sys_failed, sys_running, sys_total) = partition_units(sys_units, "system", "");
all_failed.extend(unit_jsons(&sys_units, "system", "")); let (user_failed, user_running, user_total) = partition_units(user_units, "user", "");
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 containers: Vec<ContainerJson> = machines let mut failed: Vec<UnitJson> = Vec::with_capacity(sys_failed.len() + user_failed.len());
.iter() failed.extend(sys_failed);
.map(|(n, c, s)| ContainerJson { failed.extend(user_failed);
name: n, let mut running: Vec<UnitJson> = Vec::with_capacity(sys_running.len() + user_running.len());
class: c, running.extend(sys_running);
service: s, running.extend(user_running);
system_state: "unknown",
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(), failed_units: Vec::new(),
}) running_units: Vec::new(),
.collect(); });
let containers_json = serde_json::to_string(&containers).unwrap_or_else(|_| "[]".into()); }
self.as_mut().set_system_state(QString::from(sys_state)); let machines_json = serde_json::to_string(&all_machines).unwrap_or_else(|_| "[]".into());
self.as_mut().set_user_state(QString::from(user_state));
self.as_mut().set_failed_count(local_failed_count);
self.as_mut() self.as_mut()
.set_failed_units_json(QString::from(failed_json)); .set_machines_json(QString::from(machines_json));
self.as_mut().set_failed_count(count);
self.as_mut()
.set_containers_json(QString::from(containers_json));
} }
fn restart_unit(self: Pin<&mut Self>, name: QString, scope: QString, machine: QString) { fn restart_unit(self: Pin<&mut Self>, name: QString, scope: QString, machine: QString) {

View file

@ -13,38 +13,31 @@ Column {
onHeightChanged: root.contentResized() 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 { Repeater {
model: S.SystemdService.containers model: S.SystemdService.machines
delegate: Column { delegate: Column {
id: _row id: _row
required property var modelData required property var modelData
required property int index
width: root.width width: root.width
Separator {} Separator {
visible: _row.index > 0
}
SystemdMachineSection { SystemdMachineSection {
width: _row.width width: _row.width
accentColor: root.accentColor accentColor: root.accentColor
machineName: _row.modelData.name machineName: _row.modelData.isLocal ? "" : _row.modelData.name
title: _row.modelData.name title: _row.modelData.name
marker: "" marker: _row.modelData.marker ?? ""
systemState: _row.modelData.systemState ?? "unknown" systemState: _row.modelData.systemState ?? "unknown"
units: _row.modelData.failedUnits ?? [] runningCount: _row.modelData.runningCount ?? 0
startExpanded: (_row.modelData.failedUnits ?? []).length > 0 totalCount: _row.modelData.totalCount ?? 0
failedUnits: _row.modelData.failedUnits ?? []
runningUnits: _row.modelData.runningUnits ?? []
onContentResized: root.contentResized()
} }
} }
} }

View file

@ -4,8 +4,9 @@ import QtQuick
import "../services" as S import "../services" as S
import NovaStats as NS import NovaStats as NS
// One section of the systemd applet: a header with title + state chip, // One section of the systemd applet: a header with title, aggregate counts,
// expandable list of failed units underneath. // state chip; an auto-expanded list of failed units (hidden when empty); a
// lazy-loaded, collapsed-by-default list of running units.
Column { Column {
id: root id: root
@ -14,13 +15,21 @@ Column {
required property string title required property string title
required property string marker required property string marker
required property string systemState required property string systemState
required property var units required property int runningCount
property bool startExpanded: false required property int totalCount
required property var failedUnits
required property var runningUnits
signal contentResized
onHeightChanged: root.contentResized()
width: parent?.width ?? 0 width: parent?.width ?? 0
property bool _expanded: startExpanded property bool _runningExpanded: false
readonly property int _failedCount: (failedUnits ?? []).length
// Header
Item { Item {
width: root.width width: root.width
height: 32 height: 32
@ -51,10 +60,29 @@ Column {
elide: Text.ElideRight 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 { Rectangle {
id: _stateChip id: _stateChip
anchors.right: _chevron.left anchors.right: parent.right
anchors.rightMargin: 8 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: root.systemState !== "unknown" visible: root.systemState !== "unknown"
color: { color: {
@ -79,25 +107,71 @@ Column {
font.family: NS.ThemeService.fontFamily 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 { Text {
id: _chevron
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root._expanded ? "" : "" text: root._runningExpanded ? "" : ""
color: NS.ThemeService.base04 color: NS.ThemeService.base04
font.pixelSize: NS.ThemeService.fontSize - 2 font.pixelSize: NS.ThemeService.fontSize - 2
font.family: NS.ThemeService.iconFontFamily font.family: NS.ThemeService.iconFontFamily
} }
TapHandler { 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 { Repeater {
model: root._expanded ? root.units : [] model: root._runningExpanded ? root.runningUnits : []
delegate: SystemdUnitRow { delegate: SystemdUnitRow {
required property var modelData required property var modelData
unitName: modelData.name unitName: modelData.name

View file

@ -4,29 +4,18 @@ import QtQuick
import NovaStats as NS import NovaStats as NS
// Thin wrapper around NS.SystemdService: drives the poll Timer and parses the // Thin wrapper around NS.SystemdService: drives the poll Timer and parses the
// JSON-encoded list properties into JS arrays for QML consumers. Restart helper // JSON-encoded machine tree into a JS array for QML consumers.
// proxies through to the Rust singleton.
QtObject { QtObject {
id: root id: root
readonly property string hostname: NS.SystemdService.hostname 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 readonly property int failedCount: NS.SystemdService.failedCount
// Parsed [{ name, description, subState, scope, machine }, ...] // [{ name, isLocal, marker, systemState, runningCount, totalCount,
readonly property var failedUnits: { // failedUnits: [...], runningUnits: [...] }, ...]
readonly property var machines: {
try { try {
return JSON.parse(NS.SystemdService.failedUnitsJson); return JSON.parse(NS.SystemdService.machinesJson);
} catch (e) {
return [];
}
}
// Parsed [{ name, class, service, systemState, failedUnits: [...] }, ...]
readonly property var containers: {
try {
return JSON.parse(NS.SystemdService.containersJson);
} catch (e) { } catch (e) {
return []; return [];
} }