205 lines
7.7 KiB
QML
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.";
|
|
}
|
|
}
|
|
}
|