// 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 } } } } }