/** What the Services toolbar, context menu and `docker.*` actions do with a node of a connection. */ import { tx, txTemplate } from "@/platform/i18n"; import type { PruneTarget } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import type { MenuItem } from "@/ui"; import { confirm, confirmChecked } from "@/workbench/messages"; import { promptText } from "./dialogs"; import { isRunning, nodeContainers, nodeLabel, shortId, type DockerTarget, type ServiceNode } from "./model"; import { composeOp, connectionState, containerOp, prune, pullImage, refresh, removeObjects, useServices } from "./store"; export function canStart(t: DockerTarget | null): boolean { return !!t && nodeContainers(t.node).some((c) => !isRunning(c)); } export function canStop(t: DockerTarget | null): boolean { return !!t && nodeContainers(t.node).some(isRunning); } export function canRestart(t: DockerTarget | null): boolean { return !!t && (t.node.kind === "container" || t.node.kind === "compose"); } export function canRemove(t: DockerTarget | null): boolean { return !!t && ["container", "compose", "image", "volume", "network"].includes(t.node.kind); } export async function start(t: DockerTarget | null) { if (!t) return; if (t.node.kind === "compose") return composeAction(t.conn, t.node, "start"); const ids = nodeContainers(t.node).filter((c) => !isRunning(c)).map((c) => c.id); if (ids.length) await containerOp(t.conn, ids, "start"); } export async function stop(t: DockerTarget | null) { if (!t) return; if (t.node.kind === "compose") return composeAction(t.conn, t.node, "stop"); const ids = nodeContainers(t.node).filter(isRunning).map((c) => c.id); if (ids.length) await containerOp(t.conn, ids, "stop"); } export async function restart(t: DockerTarget | null) { if (!t) return; if (t.node.kind === "compose") return composeAction(t.conn, t.node, "restart"); const ids = nodeContainers(t.node).map((c) => c.id); if (ids.length) await containerOp(t.conn, ids, "restart"); } function composeAction(conn: string, n: Extract, op: "start" | "stop" | "restart" | "down") { const first = n.containers[0]; return composeOp(conn, n.project, first?.composeFiles ?? [], first?.composeWorkingDir ?? null, op).then(() => undefined); } function deleteQuestion(kind: "container" | "image" | "volume" | "network", label: string, running: boolean) { switch (kind) { case "container": return { title: "Delete Container", message: running ? tx`Delete container '${label}'? It is running and will be stopped.` : tx`Delete container '${label}'?`, }; case "image": return { title: "Delete Image", message: tx`Delete image '${label}'?` }; case "volume": return { title: "Delete Volume", message: tx`Delete volume '${label}'?` }; case "network": return { title: "Delete Network", message: tx`Delete network '${label}'?` }; } } export async function remove(t: DockerTarget | null) { if (!t || !canRemove(t)) return; const n = t.node; const label = nodeLabel(n); if (n.kind === "compose") { const ok = await confirm({ title: "Compose Down", message: tx`Stop and remove the containers and networks of '${label}'?`, ok: "Down", }); if (ok) await composeAction(t.conn, n, "down"); return; } if (n.kind !== "container" && n.kind !== "image" && n.kind !== "volume" && n.kind !== "network") return; const what = n.kind; const running = n.kind === "container" && isRunning(n.container); const answer = await confirmChecked({ ...deleteQuestion(what, label, running), ok: "Delete", checkbox: n.kind === "image" ? "Force (also untag / remove when used by stopped containers)" : undefined, }); if (!answer) return; const id = n.kind === "container" ? n.container.id : n.kind === "image" ? n.image.id : n.kind === "volume" ? n.volume.name : n.network.id; await removeObjects(t.conn, what, [id], running || answer.checked); } /** Opens a terminal tab with a shell in the container (bash when present, else sh). */ export function execShell(t: DockerTarget | null) { const c = t?.node.kind === "container" ? t.node.container : null; if (!t || !c || !isRunning(c)) return; const snap = connectionState(t.conn).snapshot; void executeAction("terminal.runCommand", { program: snap?.executable ?? "docker", args: [...(snap?.globalArgs ?? []), "exec", "-it", c.id, "sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"], name: c.name, }); } export async function pullPrompt(conn: string) { const ref = await promptText("Pull Image", "Image to pull:", "e.g. postgres:16 or ghcr.io/org/app:latest"); if (ref) await pullImage(conn, ref); } const PRUNE: Array<[PruneTarget, string, string]> = [ ["containers", "Stopped Containers", "Remove all stopped containers?"], ["images", "Dangling Images", "Remove all dangling (untagged) images?"], ["allImages", "Unused Images", "Remove all images not used by a container?"], ["volumes", "Unused Volumes", "Remove all anonymous volumes not used by a container? Their data is lost."], ["networks", "Unused Networks", "Remove all networks not used by a container?"], ["system", "Everything Unused (System Prune)", "Remove stopped containers, unused networks, dangling images and the build cache?"], ]; export async function prunePrompt(conn: string, target: PruneTarget) { const entry = PRUNE.find(([t]) => t === target); if (!entry) return; const ok = await confirm({ title: txTemplate(`Prune ${entry[1]}`), message: entry[2], ok: "Prune" }); if (ok) await prune(conn, target); } function pruneLabel(target: PruneTarget): string { switch (target) { case "containers": return tx("Prune Unused Containers…"); case "images": return tx("Prune Dangling Images…"); case "allImages": return tx("Prune Unused Images…"); case "volumes": return tx("Prune Unused Volumes…"); case "networks": return tx("Prune Unused Networks…"); case "system": return tx("Prune Everything Unused…"); } } export function pruneMenu(conn: string): MenuItem[] { return PRUNE.map(([target, label]) => ({ label, run: () => void prunePrompt(conn, target) })); } const copy = (text: string) => void navigator.clipboard?.writeText(text); export function showDetail(tab: "log" | "inspect") { useServices.setState({ detailTab: tab }); } /** Context menu of a node of a connection's subtree. */ export function nodeMenu(t: DockerTarget): MenuItem[] { const { conn, node: n } = t; const items: MenuItem[] = []; if (n.kind === "container" || n.kind === "compose") { items.push( { label: "Start", icon: "run", disabled: !canStart(t), run: () => void start(t) }, { label: "Stop", icon: "stop", disabled: !canStop(t), run: () => void stop(t) }, { label: "Restart", icon: "rerun", run: () => void restart(t) }, ); } if (n.kind === "container") { const running = isRunning(n.container); items.push( n.container.state === "paused" ? { label: "Unpause", icon: "resume", run: () => void containerOp(conn, [n.container.id], "unpause") } : { label: "Pause", icon: "pause", disabled: !running, run: () => void containerOp(conn, [n.container.id], "pause") }, { kind: "separator" }, { label: "Show Log", icon: "file-log", run: () => showDetail("log") }, { label: "Inspect", icon: "info", run: () => showDetail("inspect") }, { label: "Create Terminal", icon: "terminal", disabled: !running, run: () => execShell(t) }, { kind: "separator" }, { label: "Copy Container ID", icon: "copy", run: () => copy(n.container.id) }, { label: "Copy Image Name", run: () => copy(n.container.image) }, ); } if (n.kind === "image" || n.kind === "volume" || n.kind === "network") { items.push({ label: "Inspect", icon: "info", run: () => showDetail("inspect") }); if (n.kind === "image") items.push( { label: "Pull Again", icon: "git-pull", disabled: n.image.repository === "", run: () => void pullImage(conn, nodeLabel(n)) }, { label: "Copy Image ID", icon: "copy", run: () => copy(shortId(n.image.id)) }, ); } if (n.kind === "group" && n.group === "images") items.push({ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) }); if (n.kind === "group") { const target: PruneTarget = n.group === "containers" ? "containers" : n.group === "images" ? "images" : n.group === "volumes" ? "volumes" : "networks"; items.push({ label: pruneLabel(target), run: () => void prunePrompt(conn, target), }); } if (n.kind === "docker") items.push( { label: "Refresh", icon: "refresh", run: () => void refresh(conn) }, { label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) }, { label: "Prune", submenu: pruneMenu(conn) }, ); if (canRemove(t)) { items.push({ kind: "separator" }, { label: n.kind === "compose" ? "Compose Down" : "Delete…", icon: "trash", danger: true, run: () => void remove(t) }); } return items; }