From 38b64dda38c76fa11424cba8ed012cd94a56362e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 22 Oct 2026 11:18:54 +0200 Subject: 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 --- desktop/modules/vm/Docker.qml | 149 ++++++++++++++++++++++++++++++++ desktop/modules/vm/README.md | 17 ++++ desktop/modules/vm/VmModule.qml | 23 +++-- desktop/modules/vm/VmPage.qml | 182 +++++++++++++++++++++++++++++++++++++++- desktop/modules/vm/VmTile.qml | 19 ++++- 5 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 desktop/modules/vm/Docker.qml (limited to 'desktop/modules/vm') diff --git a/desktop/modules/vm/Docker.qml b/desktop/modules/vm/Docker.qml new file mode 100644 index 0000000..10199b2 --- /dev/null +++ b/desktop/modules/vm/Docker.qml @@ -0,0 +1,149 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The docker half of the vm page, shaped like Virsh: the docker CLI, an +// event stream that always runs, and a stats poll only while the page is up. +// Containers are grouped by compose project, because one compose stack is a +// dozen containers that start and stop together; a container outside any +// project is a group of one. +Singleton { + id: root + + // project -> [{ id, name, state, status, image }], names sorted + property var groups: ({}) + property var projects: [] + property var stats: ({}) // short id -> { cpu, mem, memTotal } + + property bool sampling: false + + signal actionFailed(string target, string action, string message) + + // --- discovery ------------------------------------------------------- + + Process { + id: listProc + command: ["docker", "ps", "-a", "--format", + '{{.ID}}\t{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Label "com.docker.compose.project"}}\t{{.Image}}'] + stdout: StdioCollector { + onStreamFinished: { + const g = {}; + for (const line of text.split("\n")) { + const f = line.split("\t"); + if (f.length < 6) continue; + const p = f[4] || f[1]; + (g[p] = g[p] ?? []).push({ id: f[0], name: f[1], state: f[2], + status: f[3], image: f[5] }); + } + for (const p in g) g[p].sort((a, b) => a.name.localeCompare(b.name)); + root.groups = g; + root.projects = Object.keys(g).sort(); + } + } + // No daemon, or no permission on the socket: the section is empty. + onExited: code => { if (code !== 0) { root.groups = {}; root.projects = []; } } + } + + // --- stats ----------------------------------------------------------- + // + // `docker stats --no-stream` samples for about 2s before printing, which + // is the same cadence as the timer; a tick that lands while it is still + // running is a no-op. CPU is docker's own figure, 100% per core. + + function _bytes(s) { + const m = s.match(/([\d.]+)\s*([kKMGT]?i?B)/); + if (!m) return -1; + const k = { B: 1, kB: 1e3, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, + KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3, TiB: 1024 ** 4 }[m[2]] ?? 1; + return parseFloat(m[1]) * k; + } + + Process { + id: statsProc + command: ["docker", "stats", "--no-stream", "--format", + "{{.ID}}\t{{.CPUPerc}}\t{{.MemUsage}}"] + stdout: StdioCollector { + onStreamFinished: { + const s = {}; + for (const line of text.split("\n")) { + const f = line.split("\t"); + if (f.length < 3) continue; + const [used, total] = f[2].split("/"); + s[f[0]] = { cpu: parseFloat(f[1]), mem: root._bytes(used), + memTotal: root._bytes(total ?? "") }; + } + root.stats = s; + } + } + } + + // --- actions --------------------------------------------------------- + + // ids: the containers to act on; target names them in a failure notice. + function act(ids, target, action) { + actProc.target = target; actProc.action = action; + actProc.command = ["docker", action].concat(ids); + actProc.running = true; + } + + Process { + id: actProc + property string target: "" + property string action: "" + stderr: StdioCollector { id: actErr } + onExited: code => { + if (code !== 0) + root.actionFailed(actProc.target, actProc.action, + actErr.text.trim() || `exited ${code}`); + root.refreshList(); + } + } + + // --- refresh --------------------------------------------------------- + + function refreshList() { listProc.running = true; } + + Timer { + interval: 2000 + running: root.sampling + repeat: true + triggeredOnStart: true + onTriggered: statsProc.running = true + } + + // Only lifecycle events: health checks fire exec_* events every few + // seconds per container, which would turn this into a poll. The stream + // dies with the daemon, so it is retried rather than left dead. + Process { + id: events + running: true + command: ["docker", "events", "--filter", "type=container", + "--filter", "event=create", "--filter", "event=start", + "--filter", "event=die", "--filter", "event=pause", + "--filter", "event=unpause", "--filter", "event=destroy", + "--filter", "event=health_status", + "--format", "{{.Action}}"] + stdout: SplitParser { onRead: root.refreshList() } + onRunningChanged: if (running) root.refreshList() + onExited: retry.start() + } + + Timer { + id: retry + interval: 10000 + onTriggered: events.running = true + } +} diff --git a/desktop/modules/vm/README.md b/desktop/modules/vm/README.md index 43a3271..1f8481e 100644 --- a/desktop/modules/vm/README.md +++ b/desktop/modules/vm/README.md @@ -78,6 +78,23 @@ is constructed: `Virsh.sampling` follows the page, set in the page's lazily loaded, so a closed page costs one idle process waiting on an event socket. +## Containers + +Docker containers sit below the VMs, in `Docker.qml`, built the same way: +the docker CLI, `docker events` running for the whole session, and `docker +stats --no-stream` on the 2s timer only while the page is up. They are +grouped by `com.docker.compose.project`, since a compose stack is a dozen +containers that start and stop together; a container outside a project is a +group of one. The card dot is green when the whole group runs, yellow when +part of it does. CPU is docker's own figure, 100% per core. + +The event stream is filtered to lifecycle events: health checks emit +`exec_*` events every few seconds per container, which unfiltered would +turn the stream into a poll. It dies with the daemon, so it is retried every +10s. Verbs are start, stop, restart and resume, per container or for the +whole group by id, so no compose file is needed; nothing removes a +container. + ## Two things that bite **A config with no visible window exits.** The drawer is closed most of the diff --git a/desktop/modules/vm/VmModule.qml b/desktop/modules/vm/VmModule.qml index bc6b190..fa88cef 100644 --- a/desktop/modules/vm/VmModule.qml +++ b/desktop/modules/vm/VmModule.qml @@ -29,7 +29,7 @@ Module { page: Component { Page { - title: "Virtual machines" + title: "VMs and containers" VmPage { width: parent.width } } } @@ -38,14 +38,21 @@ Module { // the notification is the only feedback, since virsh output goes nowhere. property Process notifyProc: Process {} + function notify(target, action, message) { + notifyProc.command = ["notify-send", "--app-name=vm-manager", + "--urgency=critical", "--icon=error", + `${action} failed: ${target}`, message]; + notifyProc.running = false; + notifyProc.running = true; + } + property Connections conn: Connections { target: Virsh - function onActionFailed(vm, action, message) { - mod.notifyProc.command = ["notify-send", "--app-name=vm-manager", - "--urgency=critical", "--icon=error", - `${action} failed: ${vm}`, message]; - mod.notifyProc.running = false; - mod.notifyProc.running = true; - } + function onActionFailed(vm, action, message) { mod.notify(vm, action, message); } + } + + property Connections dockerConn: Connections { + target: Docker + function onActionFailed(target, action, message) { mod.notify(target, "docker " + action, message); } } } 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 diff --git a/desktop/modules/vm/VmTile.qml b/desktop/modules/vm/VmTile.qml index a86258a..370b468 100644 --- a/desktop/modules/vm/VmTile.qml +++ b/desktop/modules/vm/VmTile.qml @@ -33,10 +33,25 @@ Row { } } + // Then one per compose project, green only when all of it is up. + Repeater { + model: Docker.projects + + Rectangle { + required property string modelData + readonly property var cs: Docker.groups[modelData] ?? [] + readonly property int up: cs.filter(c => c.state === "running").length + + anchors.verticalCenter: parent.verticalCenter + width: 7; height: 7; radius: 2 + color: up === 0 ? Theme.overlay : up === cs.length ? Theme.green : Theme.yellow + } + } + Text { anchors.verticalCenter: parent.verticalCenter - visible: Virsh.names.length === 0 - text: "no VMs" + visible: Virsh.names.length === 0 && Docker.projects.length === 0 + text: "none" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext } -- cgit v1.2.3