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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user