Add a Containers page, grouped by project and led by what is exposed

Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.

Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.

The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.

Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.

Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 20:49:04 -04:00
parent 536958430f
commit ac5e6e2130
10 changed files with 1621 additions and 1 deletions
@@ -0,0 +1,558 @@
// Rootless podman-compose stacks, led by what needs attention.
//
// The grouping is the compose project, because that is the unit a person thinks
// in: not "thirteen containers" but "the Command Center database" and "the
// capacity planner's Supabase". State decides prominence within that grouping --
// what is running gets rows, what is stopped collapses to a line -- so neither
// axis has to be chosen over the other.
//
// The findings at the top are the same crossing the Firewall page reports, seen
// from the side that can close it: the firewall knows only that something is
// listening, while this page knows which container, which compose file, and
// which token is missing from it.
import Quickshell
import QtQuick
import qs.config
import qs.services
Item {
id: root
objectName: "containers"
// "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: ""
// Projects whose stopped containers have been expanded, by project name.
property var expanded: []
function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; }
function toggleExpanded(name: string): void {
root.expanded = root.isExpanded(name)
? root.expanded.filter(entry => entry !== name)
: root.expanded.concat([name]);
}
function uptimeOf(container: var): string {
const status = String(container.status ?? "");
// podman already phrases this well ("Up 32 hours (healthy)"); the health
// is shown separately, so only the duration is wanted here.
const match = /^Up ([^(]+)/.exec(status);
return match ? match[1].trim() : "";
}
function portSummary(container: var): string {
const ports = container.ports ?? [];
if (ports.length === 0)
return "no published ports";
return ports.map(port => {
const host = String(port.hostIp ?? "");
const where = host === "" ? "every interface" : host;
return where + ":" + port.hostPort + " → " + port.containerPort;
}).join(", ");
}
Component.onCompleted: Containers.refresh()
// ── the list ────────────────────────────────────────────────────────────
SettingsPage {
anchors.fill: parent
visible: Containers.logTarget === ""
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow {
visible: Containers.lastError !== ""
label: "That did not work"
detail: Containers.lastError
value: ""
divider: false
}
TextRow {
visible: Containers.scanned && !Containers.available
label: "podman is not available"
detail: "Nothing here can be shown until podman is installed."
value: ""
divider: false
}
// ── finding: published to the network ───────────────────────────────
SettingsCard {
visible: Containers.reachable.length > 0
title: Containers.reachable.length === 1
? "A container is published to your whole network"
: "Containers are published to your whole network"
subtitle: "These bind every interface, so any machine on your network can connect. "
+ "For a development database, loopback is almost always what you want. "
+ "Binding adds an address to the compose file and changes nothing else."
Repeater {
model: Containers.reachable
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.container ?? "")
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ " · " + String(modelData.image ?? "")
+ (modelData.configFile
? "\n" + Containers.shorten(String(modelData.configFile))
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Open compose file"
visible: String(modelData.configFile ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.openFile(String(modelData.configFile))
}
SettingsButton {
text: "Bind to localhost"
tone: "accent"
// Needs both halves of the label to find the entry in
// the compose file; the Supabase CLI records neither.
visible: String(modelData.configFile ?? "") !== ""
&& String(modelData.service ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.bindLocal(
String(modelData.project), String(modelData.service))
}
}
}
}
TextRow {
visible: Containers.reachable.length > 0
label: "After binding"
detail: "The change takes effect the next time the stack comes up, because a "
+ "published port is fixed when the container is created."
value: ""
divider: false
}
}
// ── finding: disk nothing references ────────────────────────────────
SettingsCard {
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
subtitle: "Images and volumes no container references. Removing them frees the space; "
+ "anything still needed downloads again on next use."
Repeater {
model: Containers.unusedImages.slice(0, 5)
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: ""
value: Containers.formatBytes(Number(modelData.size ?? 0))
}
}
TextRow {
visible: Containers.unusedImages.length > 5
label: "and " + (Containers.unusedImages.length - 5) + " more"
detail: ""
value: ""
}
SettingRow {
width: parent.width
label: root.confirmingPrune === "images"
? "Remove " + Containers.unusedImages.length + " images?"
: "Unused images"
detail: root.confirmingPrune === "images"
? "Each is removed by name. Nothing that a container references is touched."
: Containers.unusedImages.length + " images, "
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
visible: Containers.unusedImages.length > 0
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "images" ? "" : "images"
}
SettingsButton {
visible: root.confirmingPrune === "images"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneImages();
}
}
}
}
SettingRow {
width: parent.width
visible: Containers.unusedVolumes.length > 0
label: root.confirmingPrune === "volumes"
? "Remove " + Containers.unusedVolumes.length + " volumes?"
: "Unused volumes"
detail: root.confirmingPrune === "volumes"
? "A volume holds data. These are the ones podman reports as referenced by "
+ "nothing, but removing them cannot be undone."
: Containers.unusedVolumes.length + " volumes, "
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "volumes" ? "" : "volumes"
}
SettingsButton {
visible: root.confirmingPrune === "volumes"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneVolumes();
}
}
}
}
}
// ── the stacks ──────────────────────────────────────────────────────
Repeater {
model: Containers.projects
delegate: SettingsCard {
id: projectCard
required property var modelData
readonly property var runningSet:
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
readonly property var stoppedSet:
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
readonly property string projectName: String(projectCard.modelData.name ?? "")
title: String(projectCard.modelData.title ?? "")
subtitle: {
const total = Number(projectCard.modelData.total ?? 0);
const up = Number(projectCard.modelData.running ?? 0);
const where = String(projectCard.modelData.configFile ?? "");
const counts = up === 0
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
: up + " of " + total + " running";
return where === "" ? counts : counts + " · " + Containers.shorten(where);
}
SettingRow {
width: parent.width
label: "The whole stack"
detail: projectCard.runningSet.length === 0
? "Starts every container this project defines."
: "Stopping leaves the containers in place; nothing is removed."
controlWidth: 250
divider: projectCard.runningSet.length > 0
|| projectCard.stoppedSet.length > 0
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Start all"
visible: projectCard.stoppedSet.length > 0
enabled: !Containers.busy
onClicked: Containers.startProject(projectCard.projectName)
}
SettingsButton {
text: "Restart all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.restartProject(projectCard.projectName)
}
SettingsButton {
text: "Stop all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.stopProject(projectCard.projectName)
}
}
}
Repeater {
model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
value: {
const health = String(modelData.health ?? "");
const up = root.uptimeOf(modelData);
if (health !== "" && up !== "")
return health + " · " + up;
return health !== "" ? health : up;
}
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Restart"
enabled: !Containers.busy
onClicked: Containers.restart(String(modelData.name))
}
SettingsButton {
text: "Stop"
enabled: !Containers.busy
onClicked: Containers.stop(String(modelData.name))
}
}
}
}
SettingRow {
width: parent.width
visible: projectCard.stoppedSet.length > 0
activatable: true
divider: root.isExpanded(projectCard.projectName)
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ projectCard.stoppedSet.length
+ (projectCard.stoppedSet.length === 1
? " stopped container" : " stopped containers")
detail: {
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
return bad.length === 0
? ""
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
}
onActivated: root.toggleExpanded(projectCard.projectName)
}
Repeater {
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < projectCard.stoppedSet.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Start"
enabled: !Containers.busy
onClicked: Containers.start(String(modelData.name))
}
}
}
}
}
}
// ── containers no compose project claims ────────────────────────────
SettingsCard {
visible: Containers.loose.length > 0
title: "Not part of a project"
subtitle: "Started directly rather than by a compose file."
Repeater {
model: Containers.loose
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < Containers.loose.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: modelData.state === "running" ? "Stop" : "Start"
enabled: !Containers.busy
onClicked: modelData.state === "running"
? Containers.stop(String(modelData.name))
: Containers.start(String(modelData.name))
}
}
}
}
}
TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers"
detail: "Nothing has been created on this machine yet."
value: ""
divider: false
}
}
// ── logs ────────────────────────────────────────────────────────────────
//
// A drill-in rather than a panel inside the scrolling page: logs need their
// own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it.
Item {
anchors.fill: parent
visible: Containers.logTarget !== ""
Column {
id: logHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
spacing: 6
Row {
width: parent.width
spacing: 12
SettingsButton {
text: " Back"
anchors.verticalCenter: parent.verticalCenter
onClicked: Containers.closeLogs()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Containers.logTarget
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 22
font.weight: Font.DemiBold
}
}
Text {
width: parent.width
text: Containers.logError !== ""
? Containers.logError
: (Containers.logFollowing
? "Following. The last " + Containers.logLines.count + " lines are shown."
: "The stream has ended.")
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: logHeader.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 14
anchors.bottomMargin: 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.75)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
ListView {
id: logList
anchors.fill: parent
anchors.margins: 12
clip: true
model: Containers.logLines
spacing: 1
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 400
// Stay pinned to the newest line while the reader is already at
// the bottom, and leave the view alone the moment they scroll up
// to read something.
property bool pinned: true
onContentYChanged: logList.pinned =
logList.contentY >= logList.contentHeight - logList.height - 24
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
delegate: Text {
required property string line
width: logList.width - 24
text: line
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
}
}
}
}
}