Files
Panama/config/dot/quickshell/services/Containers.qml
T

205 lines
7.7 KiB
QML

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.";
}
}
}