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
559 lines
23 KiB
QML
559 lines
23 KiB
QML
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|