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.
//
// Lists of units and machines are exposed to QML as JSON-encoded QString props
// (`failedUnitsJson`, `containersJson`) rather than QList<QVariantMap>. 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<QVariantMap> when a release ships
// with both fixes.
// The machine tree is exposed to QML as a JSON-encoded QString (`machinesJson`)
// rather than QList<QVariantMap>. 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<QVariantMap> 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<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)
-> zbus::Result<zbus::zvariant::OwnedObjectPath>;
@ -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<UnitJson<'a>>,
failed_units: Vec<UnitJson>,
#[serde(rename = "runningUnits")]
running_units: Vec<UnitJson>,
}
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<UnitTuple>) {
async fn fetch_units(bus: &Connection) -> (String, Vec<UnitTuple>) {
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<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() -> (
String,
String,
Vec<UnitTuple>,
Vec<UnitTuple>,
@ -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<UnitJson<'a>> {
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<UnitJson> = 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<ContainerJson> = machines
.iter()
.map(|(n, c, s)| ContainerJson {
name: n,
class: c,
service: s,
system_state: "unknown",
let mut failed: Vec<UnitJson> = Vec::with_capacity(sys_failed.len() + user_failed.len());
failed.extend(sys_failed);
failed.extend(user_failed);
let mut running: Vec<UnitJson> = 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) {

View file

@ -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()
}
}
}

View file

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

View file

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