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. The stopped half had // a property of its own that nothing ever read -- `exposed` already holds // both, and a second derived list nobody asks for is a list that can only // go wrong quietly. readonly property var reachable: 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."; } } }