Files
openidea/src/features/services/ops.ts
T
Jaw 55836e1b49 chore: Format the codebase with oxfmt and check formatting in CI (#135)
Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown.

Co-authored-by: jaw52 <[email protected]>
2026-10-28 19:35:16 +08:00

271 lines
9.4 KiB
TypeScript

/** 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<ServiceNode, { kind: "compose" }>,
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 === "<none>",
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;
}