aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/modules/vm/Docker.qml149
-rw-r--r--desktop/modules/vm/README.md17
-rw-r--r--desktop/modules/vm/VmModule.qml23
-rw-r--r--desktop/modules/vm/VmPage.qml182
-rw-r--r--desktop/modules/vm/VmTile.qml19
5 files changed, 379 insertions, 11 deletions
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. <danix@danix.xyz>
+//
+// 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
}