From 8cab337fdd895cb754de56199f219705be2ec61a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 22 Oct 2026 18:22:06 +0200 Subject: vm: update, open and prune for docker containers Update runs `docker compose -p -f up -d --pull always` with the files read from the com.docker.compose.project.config_files label, so only stacks compose itself made get the button: a plain `docker run` container has no recipe to recreate it from, and the supabase CLI labels the project and nothing else. It has its own Process because a pull runs for minutes and must not be clobbered by a start or stop meanwhile. Published TCP ports become localhost: buttons opened by the default browser. Docker cannot say which port is a web UI, so every published one gets a button and a port ending in 443 is guessed https. Prune images runs `docker image prune -a -f` behind a confirm step, since a re-pull can be gigabytes. The images it would delete are listed under the header from `docker image ls`'s {{.Containers}} count, which docker 29 fills in; images of stopped containers are kept. That count reads 0 for images only used by `--rm` containers, such as the test-build ones, so the list is the warning. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/vm/Docker.qml | 93 +++++++++++++++++++++++++++++++++++++++++-- desktop/modules/vm/README.md | 20 ++++++++++ desktop/modules/vm/VmPage.qml | 92 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 191 insertions(+), 14 deletions(-) (limited to 'desktop/modules/vm') diff --git a/desktop/modules/vm/Docker.qml b/desktop/modules/vm/Docker.qml index 10199b2..aa3272c 100644 --- a/desktop/modules/vm/Docker.qml +++ b/desktop/modules/vm/Docker.qml @@ -34,19 +34,31 @@ Singleton { // --- 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}}'] + '{{.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 < 6) continue; + 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] }); + 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; @@ -112,9 +124,82 @@ Singleton { } } + // 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; } + function refreshList() { listProc.running = true; imgProc.running = true; } Timer { interval: 2000 diff --git a/desktop/modules/vm/README.md b/desktop/modules/vm/README.md index 1f8481e..ad3eb17 100644 --- a/desktop/modules/vm/README.md +++ b/desktop/modules/vm/README.md @@ -95,6 +95,26 @@ turn the stream into a poll. It dies with the daemon, so it is retried every whole group by id, so no compose file is needed; nothing removes a container. +Update is `docker compose -p -f up -d --pull always`, with +the files read from the `com.docker.compose.project.config_files` label. It +recreates only containers whose image changed, and also starts any stopped +service of the stack. Only stacks carrying that label get the button: a +plain `docker run` container has no recipe to recreate it from, and stacks +made by other tools (the supabase CLI labels the project and nothing else) +are updated by those tools. + +Prune images runs `docker image prune -a -f` after a confirm step: every +image no container uses, running or stopped, so a stopped stack keeps its +images. Docker's reclaimed-space line shows beside the button. The images it +would delete are listed under the header, from `docker image ls`'s +`{{.Containers}}` count (docker 29), refreshed with the container list. + +Each running container lists its published TCP ports as `localhost:` +buttons, opened with `Qt.openUrlExternally`, so the default browser takes them +(a new tab in a running Firefox). Docker cannot say which port serves a web +UI, so every published port gets a button, and a port ending in 443 is guessed +https; Portainer's 8000 is its agent tunnel and opens nothing useful. + ## Two things that bite **A config with no visible window exits.** The drawer is closed most of the diff --git a/desktop/modules/vm/VmPage.qml b/desktop/modules/vm/VmPage.qml index 9c731f5..2ac57f0 100644 --- a/desktop/modules/vm/VmPage.qml +++ b/desktop/modules/vm/VmPage.qml @@ -189,7 +189,8 @@ Column { // // 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. + // Nothing per container is destructive enough to confirm: no rm. Image + // prune is, since a re-pull can be gigabytes, so it asks first. property string selectedProject: "" @@ -199,11 +200,54 @@ Column { return [["start", "Start"]]; } - Text { + property bool pruneArmed: false + + Row { visible: Docker.projects.length > 0 - text: "Containers" - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - color: Theme.subtext + spacing: 10 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Containers" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + Button { + visible: !page.pruneArmed && (Docker.unused.length > 0 || Docker.pruning) + enabled: !Docker.pruning + text: Docker.pruning ? "Pruning…" : "Prune images" + onClicked: page.pruneArmed = true + } + Button { + visible: page.pruneArmed + danger: true + text: "Delete unused images" + onClicked: { page.pruneArmed = false; Docker.prune(); } + } + Button { + visible: page.pruneArmed + text: "Cancel" + onClicked: page.pruneArmed = false + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !page.pruneArmed && Docker.pruned !== "" + text: Docker.pruned + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + } + + // What prune would delete: images no container uses. + Repeater { + model: Docker.unused + Text { + required property var modelData + width: page.width + elide: Text.ElideMiddle + text: `${modelData.name} ${modelData.size}` + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } } Repeater { @@ -284,23 +328,30 @@ Column { 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. + // file is needed; pointless for a group of one. Update + // is the exception: it needs the compose files. Flow { - visible: cs.length > 1 + visible: cs.length > 1 || !!cs[0]?.files width: parent.width spacing: 8 Button { - visible: up < cs.length + visible: !!cs[0]?.files + enabled: Docker.updating === "" + text: Docker.updating === modelData ? "Updating…" : "Update" + onClicked: Docker.update(modelData, cs[0].files) + } + Button { + visible: cs.length > 1 && up < cs.length text: "Start all" onClicked: Docker.act(cs.map(c => c.id), modelData, "start") } Button { - visible: up > 0 + visible: cs.length > 1 && up > 0 text: "Stop all" onClicked: Docker.act(cs.map(c => c.id), modelData, "stop") } Button { - visible: up > 0 + visible: cs.length > 1 && up > 0 text: "Restart all" onClicked: Docker.act(cs.map(c => c.id), modelData, "restart") } @@ -352,6 +403,27 @@ Column { } } } + + // Published ports, opened by the default + // browser (a tab in a running one). + // ponytail: docker cannot say which port is a + // web UI, so every published port gets a + // button, and a port ending in 443 is guessed + // https. + Flow { + visible: modelData.ports.length > 0 + width: parent.width + spacing: 8 + Repeater { + model: ctr.modelData.ports + Button { + required property int modelData + text: `localhost:${modelData}` + onClicked: Qt.openUrlExternally( + `${modelData % 1000 === 443 ? "https" : "http"}://localhost:${modelData}`) + } + } + } } } } -- cgit v1.2.3