// 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 ------------------------------------------------------- // Published host TCP ports, from `{{.Ports}}`: "0.0.0.0:3000->8080/tcp, // [::]:3000->8080/tcp, 9000/tcp" gives [3000]. An unpublished port has no // arrow, and the v4 and v6 bindings of one port are the same port. function _ports(s) { const re = /:(\d+)->\d+\/tcp/g, out = []; let m; while ((m = re.exec(s)) !== null) if (!out.includes(+m[1])) out.push(+m[1]); return out; } Process { id: listProc command: ["docker", "ps", "-a", "--format", '{{.ID}}\t{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Label "com.docker.compose.project"}}\t{{.Image}}\t{{.Label "com.docker.compose.project.config_files"}}\t{{.Ports}}'] stdout: StdioCollector { onStreamFinished: { const g = {}; for (const line of text.split("\n")) { const f = line.split("\t"); if (f.length < 8) 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], files: f[6], ports: root._ports(f[7]) }); } 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(); } } // Pull and recreate a compose stack from its own compose files, read off // the container labels. `up -d` recreates only what changed, and starts // any stopped service of the stack. Its own Process, because a pull runs // for minutes and must not be clobbered by a start or stop meanwhile. property string updating: "" // the project being updated function update(project, files) { const cmd = ["docker", "compose", "-p", project]; for (const f of files.split(",")) cmd.push("-f", f); updProc.command = cmd.concat(["up", "-d", "--pull", "always"]); root.updating = project; updProc.running = false; updProc.running = true; } Process { id: updProc stderr: StdioCollector { id: updErr } onExited: code => { if (code !== 0) root.actionFailed(root.updating, "update", updErr.text.trim().split("\n").pop() || `exited ${code}`); root.updating = ""; root.refreshList(); } } // Images no container uses, running or stopped: `prune -a`, not just the // dangling ones. A stopped stack keeps its images. property bool pruning: false property string pruned: "" // docker's "Total reclaimed space" line property var unused: [] // [{ name, size }], what prune would delete Process { id: imgProc command: ["docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Containers}}\t{{.ID}}"] stdout: StdioCollector { onStreamFinished: { const u = []; for (const line of text.split("\n")) { const f = line.split("\t"); if (f.length < 4 || f[2] !== "0") continue; u.push({ name: f[0] === ":" ? f[3] : f[0], size: f[1] }); } root.unused = u; } } } function prune() { root.pruning = true; root.pruned = ""; pruneProc.running = false; pruneProc.running = true; } Process { id: pruneProc command: ["docker", "image", "prune", "-a", "-f"] stdout: StdioCollector { onStreamFinished: root.pruned = text.trim().split("\n").pop() } stderr: StdioCollector { id: pruneErr } onExited: code => { if (code !== 0) root.actionFailed("images", "prune", pruneErr.text.trim() || `exited ${code}`); root.pruning = false; root.refreshList(); } } // --- refresh --------------------------------------------------------- function refreshList() { listProc.running = true; imgProc.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 } }