aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/vm/VmPage.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 11:18:54 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 11:18:54 +0200
commit38b64dda38c76fa11424cba8ed012cd94a56362e (patch)
tree7ce20fcb308c72d91f699db4ed15e779112f9e7e /desktop/modules/vm/VmPage.qml
parent761c1d31d80b3fd72602bb771f9eb2e86c31d20c (diff)
downloadquickshell-38b64dda38c76fa11424cba8ed012cd94a56362e.tar.gz
quickshell-38b64dda38c76fa11424cba8ed012cd94a56362e.zip
vm: show docker containers beside the VMs
Docker.qml mirrors Virsh: the docker CLI, an event stream that runs for the whole session, and `docker stats --no-stream` on the 2s timer only while the page is up. Containers are grouped by compose project, since a stack is a dozen containers that start and stop together; a container outside any project is a group of one. Verbs are start, stop, restart and resume, by container id, so no compose file is needed and nothing removes a container. The event stream is filtered to lifecycle events: health checks emit exec_* events every few seconds per container, which would make the stream a poll. It dies with the daemon, so it is retried every 10s. An inactive Loader keeps its last height and a Column still reserves it, so collapsing one card left an empty box where its detail had been. Both the container and VM detail loaders now hide while inactive; the VM one had the same bug, unseen with a single VM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/vm/VmPage.qml')
-rw-r--r--desktop/modules/vm/VmPage.qml182
1 files changed, 181 insertions, 1 deletions
diff --git a/desktop/modules/vm/VmPage.qml b/desktop/modules/vm/VmPage.qml
index 4f9e0aa..9c731f5 100644
--- a/desktop/modules/vm/VmPage.qml
+++ b/desktop/modules/vm/VmPage.qml
@@ -31,11 +31,13 @@ Column {
// correct the moment the page appears.
Virsh.sampling = true;
Virsh.refreshList();
+ Docker.sampling = true;
+ Docker.refreshList();
if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0];
if (page.selected) Virsh.loadSnapshots(page.selected);
}
- Component.onDestruction: Virsh.sampling = false
+ Component.onDestruction: { Virsh.sampling = false; Docker.sampling = false; }
onSelectedChanged: if (selected) Virsh.loadSnapshots(selected)
@@ -170,6 +172,9 @@ Column {
// Actions and snapshots, for the selected VM only.
Loader {
active: isSel
+ // An inactive Loader keeps its last height, and Column
+ // would still reserve it; hidden children take no space.
+ visible: active
width: parent.width
sourceComponent: detail
property string vmName: modelData
@@ -180,6 +185,181 @@ Column {
}
}
+ // --- containers --------------------------------------------------------
+ //
+ // One card per compose project, shaped like a VM row: a dot, the
+ // aggregate stats, and the containers with their verbs when selected.
+ // Nothing here is destructive enough to confirm: no rm, no prune.
+
+ property string selectedProject: ""
+
+ function containerActions(s) {
+ if (s === "running") return [["stop", "Stop"], ["restart", "Restart"]];
+ if (s === "paused") return [["unpause", "Resume"]];
+ return [["start", "Start"]];
+ }
+
+ Text {
+ visible: Docker.projects.length > 0
+ text: "Containers"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.subtext
+ }
+
+ Repeater {
+ model: Docker.projects
+
+ Rectangle {
+ id: card
+ required property string modelData
+ readonly property var cs: Docker.groups[modelData] ?? []
+ readonly property int up: cs.filter(c => c.state === "running").length
+ readonly property bool isSel: page.selectedProject === modelData
+ readonly property var st: cs.map(c => Docker.stats[c.id]).filter(s => s)
+ readonly property real cpu: st.reduce((a, s) => a + s.cpu, 0)
+ readonly property real mem: st.reduce((a, s) => a + Math.max(0, s.mem), 0)
+ readonly property real memTotal: st.length ? st[0].memTotal : -1
+
+ width: page.width
+ implicitHeight: cCol.implicitHeight + 24
+ radius: 10
+ color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent"
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: page.selectedProject = isSel ? "" : modelData
+ }
+
+ Column {
+ id: cCol
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 }
+ spacing: 10
+
+ Row {
+ spacing: 10
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 9; height: 9; radius: 5
+ color: up === 0 ? Theme.overlay : up === cs.length ? Theme.green : Theme.yellow
+ }
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: cs.length > 1 ? `${up}/${cs.length} running` : (cs[0]?.status ?? "")
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ }
+
+ Flow {
+ visible: up > 0
+ width: parent.width
+ spacing: 20
+
+ Stat {
+ label: "CPU"
+ value: st.length ? cpu.toFixed(0) + "%" : "—"
+ fraction: st.length ? cpu / 100 : -1
+ }
+ Stat {
+ label: "RAM"
+ value: st.length ? page.fmtBytes(mem) : "—"
+ fraction: memTotal > 0 ? mem / memTotal : -1
+ }
+ }
+
+ Loader {
+ active: isSel
+ visible: active
+ width: parent.width
+ sourceComponent: Column {
+ spacing: 8
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
+
+ // Whole-project verbs, by container id, so no compose
+ // file is needed. Pointless for a group of one.
+ Flow {
+ visible: cs.length > 1
+ width: parent.width
+ spacing: 8
+ Button {
+ visible: up < cs.length
+ text: "Start all"
+ onClicked: Docker.act(cs.map(c => c.id), modelData, "start")
+ }
+ Button {
+ visible: up > 0
+ text: "Stop all"
+ onClicked: Docker.act(cs.map(c => c.id), modelData, "stop")
+ }
+ Button {
+ visible: up > 0
+ text: "Restart all"
+ onClicked: Docker.act(cs.map(c => c.id), modelData, "restart")
+ }
+ }
+
+ Repeater {
+ model: cs
+
+ Column {
+ id: ctr
+ required property var modelData
+ readonly property var s: Docker.stats[modelData.id]
+ width: parent.width
+ spacing: 4
+
+ Row {
+ width: parent.width
+ spacing: 8
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 7; height: 7; radius: 4
+ color: page.stateColor(modelData.state)
+ }
+ Text {
+ width: parent.width - 15
+ elide: Text.ElideRight
+ text: modelData.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+ }
+
+ Row {
+ spacing: 10
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.status
+ + (s && modelData.state === "running"
+ ? ` ${s.cpu.toFixed(0)}% ${page.fmtBytes(s.mem)}` : "")
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.overlay
+ }
+ Repeater {
+ model: page.containerActions(modelData.state)
+ Button {
+ required property var modelData
+ text: modelData[1]
+ onClicked: Docker.act([ctr.modelData.id], ctr.modelData.name, modelData[0])
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
Component {
id: detail