// 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 } }