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
}
}
}
}
}
@@ -128,6 +128,7 @@ Rectangle {
case "sharing": return sharingPage;
case "firewall": return firewallPage;
case "printers": return printersPage;
case "containers": return containersPage;
case "services": return healthPage;
case "about": return aboutPage;
default: return homePage;
@@ -172,6 +173,7 @@ Rectangle {
Component { id: usersPage; UsersPage {} }
Component { id: sharingPage; SharingPage {} }
Component { id: firewallPage; FirewallPage {} }
Component { id: containersPage; ContainersPage {} }
Component { id: printersPage; PrintersPage {} }
Component { id: accessibilityPage; AccessibilityPage {} }
Component { id: powerPage; PowerPage {} }
@@ -29,6 +29,7 @@ Rectangle {
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
@@ -4,6 +4,7 @@ AppearancePage 1.0 AppearancePage.qml
AvatarPicker 1.0 AvatarPicker.qml
ConnectivityPage 1.0 ConnectivityPage.qml
FirewallPage 1.0 FirewallPage.qml
ContainersPage 1.0 ContainersPage.qml
GamingPage 1.0 GamingPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
+566
View File
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
"""Rootless containers, grouped by the project that defines them.
Every container on this machine is created by podman-compose and labelled with
the project it belongs to, so the grouping is read from the labels rather than
invented. Acting on a group is then done with plain `podman` over the labelled
set -- never `podman-compose down`, which would REMOVE the containers. Nothing
here creates, recreates, or removes a container: the compose file is the source
of truth for what exists, and it belongs to the repository, not to this tool.
Rootless throughout, so nothing here needs privilege.
The one exception to "does not touch the compose file" is `bind-local`, which
exists because a development database published on every interface is worth
closing and the fix is a single token. It prepends a loopback bind address and
leaves the rest of the line byte-for-byte -- variables, quoting and style
intact -- then re-parses to confirm only that value moved. Anything it cannot
read unambiguously it refuses rather than guesses.
panama-containers snapshot
panama-containers start NAME | stop NAME | restart NAME
panama-containers project-start NAME | project-stop NAME | project-restart NAME
panama-containers prune-images | prune-volumes
panama-containers bind-local PROJECT SERVICE
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
import yaml
# A container or project name as podman and compose accept them. Deliberately
# strict: these values reach an argv, and nothing legitimate needs more.
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
# Project names that say nothing, because podman-compose defaults to the
# directory holding the compose file. "docker" is a directory, not a project.
ANONYMOUS = {"docker", "compose", "containers", "container", "db", "dev", "local", "src"}
# Substituted before a port mapping is split, because ${POSTGRES_PORT:-5432}
# contains a colon and would otherwise be torn in half.
INTERPOLATION = re.compile(r"\$\{[^}]*\}")
# Host addresses that mean "every interface", and so mean reachable.
EVERY_INTERFACE = {"", "0.0.0.0", "::", "[::]", "*"}
LOOPBACK = "127.0.0.1"
class BoundaryError(RuntimeError):
"""A user-visible validation or podman failure."""
def podman_binary() -> str:
"""The podman to call.
Overridable so that stopping a container and removing an image can be
tested without stopping a real container or removing a real image. The
containers on this machine are a working development database; a test suite
has no business touching them.
"""
return os.environ.get("PANAMA_CONTAINERS_PODMAN", "podman")
def run(command: list[str], timeout: float = 60.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError as error:
raise BoundaryError("podman is not installed.") from error
except subprocess.TimeoutExpired as error:
raise BoundaryError("podman did not respond.") from error
def podman_json(arguments: list[str], timeout: float = 60.0) -> list | dict:
result = run([podman_binary(), *arguments], timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else "podman could not be read.")
try:
return json.loads(result.stdout or "[]")
except json.JSONDecodeError as error:
raise BoundaryError("podman returned something unreadable.") from error
def podman_do(arguments: list[str], failure: str, timeout: float = 120.0) -> None:
result = run([podman_binary(), *arguments], timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or "").strip().splitlines()
raise BoundaryError(detail[-1] if detail else failure)
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
if not pattern.match(value or ""):
raise BoundaryError(message)
return value
# ── reading ──────────────────────────────────────────────────────────────────
def split_mapping(mapping: str) -> list[str]:
"""The parts of a compose port mapping, with interpolations kept whole."""
masked: list[str] = []
placeholder = "\x00{}\x00"
def keep(match: re.Match[str]) -> str:
masked.append(match.group(0))
return placeholder.format(len(masked) - 1)
stand_in = INTERPOLATION.sub(keep, mapping)
return [
re.sub(r"\x00(\d+)\x00", lambda m: masked[int(m.group(1))], part)
for part in stand_in.split(":")
]
def health_of(status: str) -> str:
"""The health podman prints inside the status line, when it prints one."""
match = re.search(r"\((healthy|unhealthy|starting)\)", status or "")
return match.group(1) if match else ""
def containers() -> list[dict]:
raw = podman_json(["ps", "-a", "--format", "json"])
result: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
labels = entry.get("Labels") or {}
names = entry.get("Names") or []
status = str(entry.get("Status") or "")
ports = [
{
"hostIp": str(port.get("host_ip") or ""),
"hostPort": int(port.get("host_port") or 0),
"containerPort": int(port.get("container_port") or 0),
"protocol": str(port.get("protocol") or "tcp"),
"range": int(port.get("range") or 1),
}
for port in (entry.get("Ports") or [])
]
result.append({
"id": str(entry.get("Id") or "")[:12],
"name": names[0] if names else str(entry.get("Id") or "")[:12],
"image": str(entry.get("Image") or ""),
"state": str(entry.get("State") or ""),
"status": status,
"health": health_of(status),
"exitCode": int(entry.get("ExitCode") or 0),
"startedAt": int(entry.get("StartedAt") or 0),
"restarts": int(entry.get("Restarts") or 0),
"project": str(labels.get("com.docker.compose.project") or ""),
"service": str(labels.get("com.docker.compose.service") or ""),
"configFile": str(labels.get("com.docker.compose.project.config_files") or ""),
"workingDir": str(labels.get("com.docker.compose.project.working_dir") or ""),
# Published ports whose host address is every interface.
"ports": ports,
})
return result
def resolve_config(path: str, working_dir: str) -> str:
"""The compose file a label points at, normalised.
podman-compose records the path as it was invoked, which is how a perfectly
valid label ends up containing "/scripts/../compose.yml".
"""
if not path:
return ""
first = path.split(",")[0].strip()
if not first:
return ""
candidate = Path(first)
if not candidate.is_absolute() and working_dir:
candidate = Path(working_dir) / candidate
try:
return str(candidate.resolve(strict=False))
except OSError:
return str(candidate)
def repository_name(config_file: str) -> str:
"""The repository a compose file lives in, for naming a project sensibly."""
if not config_file:
return ""
result = run(
["git", "-C", str(Path(config_file).parent), "rev-parse", "--show-toplevel"],
timeout=10.0,
)
if result.returncode != 0:
return ""
return Path(result.stdout.strip()).name if result.stdout.strip() else ""
def display_name(project: str, config_file: str) -> str:
"""A project name worth showing.
podman-compose names a project after the directory holding its compose
file, so a database stack can end up called "docker". Where the name says
nothing, the repository it lives in says more.
"""
if project.lower() not in ANONYMOUS:
return project
return repository_name(config_file) or project
def projects_of(entries: list[dict]) -> list[dict]:
"""Containers grouped by the compose project that declares them."""
grouped: dict[str, dict] = {}
for container in entries:
key = container["project"]
if not key:
continue
group = grouped.get(key)
if group is None:
config_file = resolve_config(container["configFile"], container["workingDir"])
group = grouped[key] = {
"name": key,
"title": display_name(key, config_file),
"configFile": config_file,
"workingDir": container["workingDir"],
"containers": [],
}
group["containers"].append(container)
result = []
for group in grouped.values():
group["containers"].sort(key=lambda c: (c["state"] != "running", c["name"]))
group["running"] = sum(1 for c in group["containers"] if c["state"] == "running")
group["total"] = len(group["containers"])
result.append(group)
# Whatever is running comes first; a project nobody is using can wait.
result.sort(key=lambda g: (-g["running"], g["title"].lower()))
return result
def exposures(entries: list[dict]) -> list[dict]:
"""Published ports any machine on the network can reach.
A container is only reachable if it is running AND publishes on an address
that is not loopback. A stopped container publishes nothing, whatever its
compose file says -- so it is reported as something that WILL expose, not
something that does.
"""
found: list[dict] = []
for container in entries:
for port in container["ports"]:
if port["hostIp"] not in EVERY_INTERFACE:
continue
if port["hostPort"] <= 0:
continue
found.append({
"container": container["name"],
"project": container["project"],
"service": container["service"],
"configFile": resolve_config(container["configFile"], container["workingDir"]),
"image": container["image"],
"hostPort": port["hostPort"],
"containerPort": port["containerPort"],
"protocol": port["protocol"],
"running": container["state"] == "running",
})
found.sort(key=lambda e: (not e["running"], e["hostPort"]))
return found
def disk() -> dict:
"""What the container store costs, and what of it nothing references."""
usage = podman_json(["system", "df", "--format", "json"])
totals = {
str(row.get("Type") or ""): row
for row in (usage if isinstance(usage, list) else [])
}
def raw(kind: str, field: str) -> int:
return int((totals.get(kind) or {}).get(field) or 0)
images = podman_json(["images", "--format", "json"])
unused = []
for image in images if isinstance(images, list) else []:
if int(image.get("Containers") or 0) > 0:
continue
tags = image.get("Names") or image.get("RepoTags") or []
unused.append({
"id": str(image.get("Id") or "")[:12],
"name": tags[0] if tags else "<untagged>",
"size": int(image.get("Size") or 0),
})
unused.sort(key=lambda i: -i["size"])
# Podman's own answer to "does anything reference this volume", rather
# than MountCount, which is a runtime lock counter: it reads zero for a
# volume that a running container has mounted this second, and using it
# here would offer to delete a live database.
volumes = podman_json(["volume", "ls", "--filter", "dangling=true", "--format", "json"])
idle = [
{"name": str(volume.get("Name") or "")}
for volume in (volumes if isinstance(volumes, list) else [])
if volume.get("Name")
]
idle.sort(key=lambda v: v["name"])
return {
"imagesSize": raw("Images", "RawSize"),
"imagesReclaimable": raw("Images", "RawReclaimable"),
"containersSize": raw("Containers", "RawSize"),
"volumesSize": raw("Local Volumes", "RawSize"),
"volumesReclaimable": raw("Local Volumes", "RawReclaimable"),
"unusedImages": unused,
"unusedVolumes": idle,
}
def snapshot() -> dict:
entries = containers()
grouped = projects_of(entries)
return {
"available": True,
"projects": grouped,
"loose": [c for c in entries if not c["project"]],
"running": sum(1 for c in entries if c["state"] == "running"),
"total": len(entries),
"exposed": exposures(entries),
"disk": disk(),
"error": "",
}
def unavailable(message: str) -> dict:
return {
"available": False, "projects": [], "loose": [], "running": 0, "total": 0,
"exposed": [], "disk": {
"imagesSize": 0, "imagesReclaimable": 0, "containersSize": 0,
"volumesSize": 0, "volumesReclaimable": 0,
"unusedImages": [], "unusedVolumes": [],
},
"error": message,
}
# ── acting ───────────────────────────────────────────────────────────────────
def find_container(name: str) -> dict:
for container in containers():
if container["name"] == name or container["id"] == name:
return container
raise BoundaryError(f"There is no container called {name}.")
def find_project(name: str) -> dict:
for project in projects_of(containers()):
if project["name"] == name:
return project
raise BoundaryError(f"There is no project called {name}.")
def act_on_container(verb: str, name: str) -> None:
container = find_container(name)
if verb == "start" and container["state"] == "running":
raise BoundaryError(f"{container['name']} is already running.")
if verb == "stop" and container["state"] != "running":
raise BoundaryError(f"{container['name']} is not running.")
podman_do([verb, container["name"]], f"{container['name']} could not be {verb}ed.")
def act_on_project(verb: str, name: str) -> None:
"""The whole stack, one container at a time, with plain podman.
Deliberately not `podman-compose down`: that removes containers, and this
tool does not remove what the compose file created.
"""
project = find_project(name)
wanted = "running" if verb == "stop" else "not running"
targets = [
container["name"] for container in project["containers"]
if (container["state"] == "running") == (wanted == "running")
] if verb != "restart" else [
container["name"] for container in project["containers"]
if container["state"] == "running"
]
if not targets:
raise BoundaryError(f"Nothing in {project['title']} needs to be {verb}ed.")
failures: list[str] = []
for target in targets:
result = run([podman_binary(), verb, target], timeout=120.0)
if result.returncode != 0:
failures.append(target)
if failures:
raise BoundaryError(f"Could not {verb} {', '.join(failures)}.")
def prune_images() -> None:
"""Remove images nothing references.
Scoped to exactly what the snapshot showed as unused, by id, so that an
image which gained a container between the panel rendering and the button
being pressed is not swept up by a blanket prune.
"""
unused = disk()["unusedImages"]
if not unused:
raise BoundaryError("Every image is in use.")
failures = []
for image in unused:
result = run([podman_binary(), "rmi", image["id"]], timeout=120.0)
if result.returncode != 0:
failures.append(image["name"])
if failures:
raise BoundaryError(f"Could not remove {len(failures)} image(s): {', '.join(failures[:3])}.")
def prune_volumes() -> None:
idle = disk()["unusedVolumes"]
if not idle:
raise BoundaryError("Every volume is in use.")
failures = []
for volume in idle:
result = run([podman_binary(), "volume", "rm", volume["name"]], timeout=120.0)
if result.returncode != 0:
failures.append(volume["name"])
if failures:
raise BoundaryError(f"Could not remove {len(failures)} volume(s): {', '.join(failures[:3])}.")
# ── the compose edit ─────────────────────────────────────────────────────────
def rewrite_mapping(mapping: str) -> str:
"""A published port bound to loopback, with everything else left alone.
Only the address is added. The host port keeps whatever form it had --
literal, ${VAR}, or ${VAR:-default} -- because rewriting it to the number
podman happens to report today would silently delete the variable that lets
the port be configured at all.
"""
parts = split_mapping(mapping.strip())
if len(parts) == 3:
raise BoundaryError(f"{mapping} already names an address.")
if len(parts) != 2:
raise BoundaryError(f"{mapping} is not a mapping this can read.")
return f"{LOOPBACK}:{parts[0]}:{parts[1]}"
def compose_ports(document: dict, service: str) -> list[str]:
services = document.get("services")
if not isinstance(services, dict) or service not in services:
raise BoundaryError(f"{service} is not in that compose file.")
definition = services.get(service)
if not isinstance(definition, dict):
raise BoundaryError(f"{service} is not readable in that compose file.")
ports = definition.get("ports")
if ports is None:
raise BoundaryError(f"{service} does not publish any ports.")
if not isinstance(ports, list) or not all(isinstance(p, str) for p in ports):
raise BoundaryError(f"The ports of {service} are not in a form this can edit.")
return ports
def bind_local(project_name: str, service: str) -> None:
"""Bind a service's published ports to loopback, in place.
Read to understand, edit as text so the file keeps its comments, quoting
and layout, then read back to confirm that exactly the intended values
moved and nothing else did.
"""
project = find_project(project_name)
path = Path(project["configFile"])
if not project["configFile"] or not path.is_file():
raise BoundaryError("The compose file for that project could not be found.")
original = path.read_text(encoding="utf-8")
try:
document = yaml.safe_load(original)
except yaml.YAMLError as error:
raise BoundaryError("That compose file could not be parsed.") from error
if not isinstance(document, dict):
raise BoundaryError("That compose file is not a mapping.")
current = compose_ports(document, service)
wanted = [rewrite_mapping(mapping) for mapping in current]
# Replace each mapping where it is written, not the line it sits on, so
# flow style, block style and inline comments all survive untouched. The
# mapping text is searched for within the service's own span only.
updated = original
for before, after in zip(current, wanted):
needle = before.strip()
occurrences = updated.count(needle)
if occurrences == 0:
raise BoundaryError(f"Could not find {needle} in the compose file.")
if occurrences > 1:
raise BoundaryError(
f"{needle} appears {occurrences} times in that file; "
"it is not clear which one belongs to this service.")
updated = updated.replace(needle, after)
if updated == original:
raise BoundaryError("That compose file already binds these ports to loopback.")
path.write_text(updated, encoding="utf-8")
# Read back. A change that was not applied, or that broke the document, is
# worse than no change at all.
try:
reread = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as error:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit would have broken that compose file; it was undone.") from error
if not isinstance(reread, dict) or compose_ports(reread, service) != wanted:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit did not take effect; it was undone.")
# Everything except this service's ports must be identical.
before_document = yaml.safe_load(original)
before_document["services"][service]["ports"] = wanted
if before_document != reread:
path.write_text(original, encoding="utf-8")
raise BoundaryError("The edit changed more than those ports; it was undone.")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] in ("start", "stop", "restart"):
act_on_container(arguments[0], require(NAME, arguments[1], "That is not a container name."))
elif len(arguments) == 2 and arguments[0] in ("project-start", "project-stop", "project-restart"):
verb = arguments[0].split("-", 1)[1]
act_on_project(verb, require(NAME, arguments[1], "That is not a project name."))
elif arguments == ["prune-images"]:
prune_images()
elif arguments == ["prune-volumes"]:
prune_volumes()
elif len(arguments) == 3 and arguments[0] == "bind-local":
bind_local(
require(NAME, arguments[1], "That is not a project name."),
require(NAME, arguments[2], "That is not a service name."),
)
else:
raise BoundaryError(
"Usage: panama-containers snapshot | start NAME | stop NAME | restart NAME | "
"project-start NAME | project-stop NAME | project-restart NAME | "
"prune-images | prune-volumes | bind-local PROJECT SERVICE")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = unavailable("")
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,202 @@
pragma Singleton
// Rootless podman-compose stacks: what they are, what they expose, what they cost.
//
// Grouping is read from the compose labels rather than invented, and acting on a
// group is done with 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 it
// belongs to the repository.
//
// Rootless throughout, so nothing here prompts for a password.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-containers"
// How many log lines are kept in memory. A busy container can emit
// thousands a minute, and the panel is for reading the recent past, not for
// archiving it.
readonly property int logLimit: 2000
property bool available: false
property var projects: []
property var loose: []
property int running: 0
property int total: 0
property var exposed: []
property var disk: ({})
property bool scanned: false
property string lastError: ""
// Guards read these Processes directly. A derived `busy` binding returns its
// cached value inside the handler that changes its dependency, which is how
// a write can be dropped without any error at all.
readonly property bool busy: query.running || mutation.running
// Published on every interface AND currently running: reachable now, as
// opposed to a stopped container that merely would be.
readonly property var reachable: root.exposed.filter(entry => entry.running === true)
readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true)
readonly property var unusedImages: root.disk.unusedImages ?? []
readonly property var unusedVolumes: root.disk.unusedVolumes ?? []
readonly property int reclaimable:
Number(root.disk.imagesReclaimable ?? 0) + Number(root.disk.volumesReclaimable ?? 0)
// The container whose logs are on screen, and whether podman is still
// feeding them. Empty when the log view is closed.
property string logTarget: ""
readonly property bool logFollowing: logs.running
property string logError: ""
readonly property alias logLines: logModel
// Decimal units, to match what podman itself prints.
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1000 && index < units.length - 1) {
value /= 1000;
index += 1;
}
return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index];
}
// Paths are shown relative to home: the interesting part of a compose path
// is where it sits in the repository, not the eight characters before it.
function shorten(path: string): string {
const home = Quickshell.env("HOME") ?? "";
return home !== "" && path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
}
// Hand a compose file to whatever opens text files -- which on this machine
// is Neovim in kitty.
function openFile(path: string): void {
if (path === "")
return;
opener.command = ["xdg-open", path];
opener.running = true;
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.projects = Array.isArray(parsed.projects) ? parsed.projects : [];
root.loose = Array.isArray(parsed.loose) ? parsed.loose : [];
root.running = Number(parsed.running ?? 0);
root.total = Number(parsed.total ?? 0);
root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : [];
root.disk = parsed.disk ?? ({});
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the state of the containers.";
console.warn("Containers: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function start(name: string): void { root.run(["start", name]); }
function stop(name: string): void { root.run(["stop", name]); }
function restart(name: string): void { root.run(["restart", name]); }
function startProject(name: string): void { root.run(["project-start", name]); }
function stopProject(name: string): void { root.run(["project-stop", name]); }
function restartProject(name: string): void { root.run(["project-restart", name]); }
function pruneImages(): void { root.run(["prune-images"]); }
function pruneVolumes(): void { root.run(["prune-volumes"]); }
// Bind a service's published ports to loopback by editing its compose file
// in place. The helper adds an address and leaves everything else alone, or
// refuses; it never rewrites a port it cannot read unambiguously.
function bindLocal(project: string, service: string): void {
root.run(["bind-local", project, service]);
}
// ── logs ────────────────────────────────────────────────────────────────
function openLogs(name: string): void {
root.closeLogs();
root.logTarget = name;
root.logError = "";
// --tail bounds the initial burst: a container running for weeks would
// otherwise deliver its entire history before the first line appears.
logs.command = ["podman", "logs", "--tail", "400", "--timestamps", "--follow", name];
logs.running = true;
}
function closeLogs(): void {
if (logs.running)
logs.running = false;
logModel.clear();
root.logTarget = "";
root.logError = "";
}
function appendLog(line: string): void {
if (root.logTarget === "")
return;
logModel.append({ line });
if (logModel.count > root.logLimit)
logModel.remove(0, logModel.count - root.logLimit);
}
Component.onCompleted: root.refresh()
ListModel { id: logModel }
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process { id: opener }
Process {
id: logs
// podman writes container output to both streams; a log view that showed
// only one would silently drop half of what the container said.
stdout: SplitParser { onRead: line => root.appendLog(line) }
stderr: SplitParser { onRead: line => root.appendLog(line) }
onExited: (code, status) => {
if (root.logTarget !== "" && code !== 0)
root.logError = "The log stream ended unexpectedly.";
}
}
}
@@ -79,6 +79,11 @@ Singleton {
{ label: "Open ports", detail: "Which ports the firewall permits", page: "firewall" },
{ label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" },
{ label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" },
{ label: "Containers", detail: "Rootless podman stacks you run for development", page: "containers" },
{ label: "Podman", detail: "Running containers, images and volumes", page: "containers" },
{ label: "Container logs", detail: "Follow what a container is printing", page: "containers" },
{ label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" },
{ label: "Published ports", detail: "Which containers are reachable from the network", page: "containers" },
{ label: "Remote login", detail: "Sign in to this machine over SSH", page: "sharing" },
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;