aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-22 18:22:06 +0200
committerDanilo M. <danix@danix.xyz>2026-10-22 18:22:06 +0200
commit8cab337fdd895cb754de56199f219705be2ec61a (patch)
treea7b6fc891720b62da6df69150eef3795c7ec02a0
parentaf956df74341308aea5f19eb9077ae1664f8a202 (diff)
downloadquickshell-8cab337fdd895cb754de56199f219705be2ec61a.tar.gz
quickshell-8cab337fdd895cb754de56199f219705be2ec61a.zip
vm: update, open and prune for docker containers
Update runs `docker compose -p <project> -f <files> 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:<port> 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 <noreply@anthropic.com>
-rw-r--r--desktop/modules/vm/Docker.qml93
-rw-r--r--desktop/modules/vm/README.md20
-rw-r--r--desktop/modules/vm/VmPage.qml92
3 files changed, 191 insertions, 14 deletions
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] === "<none>:<none>" ? 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 <project> -f <files> 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:<port>`
+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}`)
+ }
+ }
+ }
}
}
}