diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index f7633d654f9..b6c6089118f 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -67,6 +67,11 @@ const redactWebServerSettings = (settings: T) => settings; vi.mock("@dokploy/server", () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", + DOKPLOY_KEEP_IMAGES_MAX: 5, + DOKPLOY_KEEP_IMAGES_MIN: 3, + getDokployImageKeepCount: vi.fn(), + getDokployImages: vi.fn(), IS_CLOUD: false, checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, @@ -419,6 +424,7 @@ describe("settings Docker server boundary", () => { "v0.30.6", "off_v0.29.8/Fork_159+next", "v0.30.0", + undefined, ); expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [ "-c", @@ -426,6 +432,37 @@ describe("settings Docker server boundary", () => { ]); }); + it("passes the image keep count to the AgentHits update command", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "off_v0.29.8/Fork_159+next", + updateAvailable: true, + updateSource: "agenthits", + latestOfficialVersion: "v0.30.0", + }); + mocks.getAgentHitsUpdateCommand.mockReturnValue("agenthits update command"); + + await expect(createCaller().updateServer({ keepImages: 4 })).resolves.toBe( + true, + ); + + expect(mocks.getAgentHitsUpdateCommand).toHaveBeenCalledWith( + "v0.30.6", + "off_v0.29.8/Fork_159+next", + "v0.30.0", + 4, + ); + }); + + it("rejects image keep counts outside 3 to 5", async () => { + await expect( + createCaller().updateServer({ keepImages: 2 }), + ).rejects.toThrow(); + await expect( + createCaller().updateServer({ keepImages: 10 }), + ).rejects.toThrow(); + expect(mocks.spawnAsync).not.toHaveBeenCalled(); + }); + it("keeps official updates on the official Dokploy image path", async () => { mocks.getUpdateData.mockResolvedValue({ latestVersion: "v0.30.0", @@ -446,6 +483,29 @@ describe("settings Docker server boundary", () => { expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled(); }); + it("turns the image cleanup off on official updates", async () => { + mocks.getUpdateData.mockResolvedValue({ + latestVersion: "v0.30.0", + updateAvailable: true, + updateSource: "official", + }); + + await expect( + createCaller().updateServer({ keepImages: null }), + ).resolves.toBe(true); + + expect(mocks.spawnAsync).toHaveBeenCalledWith("docker", [ + "service", + "update", + "--force", + "--image", + "dokploy/dokploy:v0.30.0", + "--env-add", + "DOKPLOY_KEEP_IMAGES=0", + "dokploy", + ]); + }); + it("requires api.read before generating the OpenAPI document", async () => { const result = await createCaller().getOpenApiDocument(); diff --git a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts new file mode 100644 index 00000000000..f7045a5123d --- /dev/null +++ b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() })); + +vi.mock("@dokploy/server/db", () => ({ + db: {}, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync, + execAsyncRemote: vi.fn(), +})); + +const { + cleanupOldDokployImages, + getAgentHitsUpdateCommand, + getDokployImageCleanupCommand, + getDokployImageKeepCount, + getDokployImageRepositories, + getDokployImages, +} = await import("@dokploy/server/services/settings"); + +describe("Dokploy image cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("DOKPLOY_KEEP_IMAGES", undefined); + vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined); + vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_TAG", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + [undefined, null], + ["0", null], + ["2", null], + ["3", 3], + ["5", 5], + ["6", null], + ["3.5", null], + ["abc", null], + ])("reads keep count %s as %s", (value, expected) => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", value); + expect(getDokployImageKeepCount()).toBe(expected); + }); + + it("targets the fork and official image repositories without tags", () => { + expect(getDokployImageRepositories()).toEqual([ + "ghcr.io/agenthits/dokploy", + "dokploy/dokploy", + ]); + + vi.stubEnv( + "DOKPLOY_AGENTHITS_UPDATE_IMAGE", + "registry.local:5000/team/dokploy:dev@sha256:abc", + ); + expect(getDokployImageRepositories()).toEqual([ + "registry.local:5000/team/dokploy", + "dokploy/dokploy", + ]); + }); + + it("keeps the newest images and only removes exited dokploy task containers", () => { + const command = getDokployImageCleanupCommand(4); + + expect(command).toContain( + "--filter reference\\=ghcr.io/agenthits/dokploy --filter reference\\=dokploy/dokploy", + ); + expect(command).toContain("tail -n +5"); + expect(command).toContain("--filter status=exited"); + expect(command).toContain( + "--filter label=com.docker.swarm.service.name=dokploy", + ); + expect(command).not.toMatch(/\brm (-f|--force)\b/); + }); + + it("does nothing at startup when the cleanup is off", async () => { + await cleanupOldDokployImages(); + expect(execAsync).not.toHaveBeenCalled(); + }); + + it("runs the cleanup at startup when a keep count is set", async () => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3"); + execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + await cleanupOldDokployImages(); + + expect(execAsync).toHaveBeenCalledWith(getDokployImageCleanupCommand(3)); + }); + + it("does not throw when the cleanup fails", async () => { + vi.stubEnv("DOKPLOY_KEEP_IMAGES", "3"); + execAsync.mockRejectedValue(new Error("docker unavailable")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(cleanupOldDokployImages()).resolves.toBeUndefined(); + }); + + it("stores the keep count on the service only when it is given", () => { + expect(getAgentHitsUpdateCommand("v0.30.6")).not.toContain( + "DOKPLOY_KEEP_IMAGES", + ); + expect(getAgentHitsUpdateCommand("v0.30.6", null, null, 3)).toContain( + "--env-add DOKPLOY_KEEP_IMAGES\\=3 \\\n\tdokploy", + ); + expect(getAgentHitsUpdateCommand("v0.30.6", null, null, null)).toContain( + "--env-add DOKPLOY_KEEP_IMAGES\\=0", + ); + }); +}); + +describe("Dokploy image list", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("DOKPLOY_AGENTHITS_UPDATE_IMAGE", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const image = (id: string, createdAt: string, fork: string) => + JSON.stringify({ + id: `sha256:${id}`, + createdAt, + size: 4455354111, + tags: [`ghcr.io/agenthits/dokploy:agenthits-dev-${id}`], + config: { + Env: [ + "PATH=/usr/bin|/bin", + `DOKPLOY_FORK_VERSION=${fork}`, + "DOKPLOY_OFFICIAL_VERSION=v0.30.6", + ], + }, + }); + const container = (id: string, running: boolean, service: string) => + JSON.stringify({ image: `sha256:${id}`, running, service }); + + it("reads versions, sizes and containers newest first", async () => { + execAsync.mockImplementation(async (command: string) => { + if (command.startsWith("docker image ls")) { + return { stdout: "sha256:aaa\nsha256:bbb\nsha256:ccc\n", stderr: "" }; + } + if (command.startsWith("docker image inspect")) { + return { + stdout: [ + image("aaa", "2026-10-07T10:26:35.154227243Z", "Fork_412+aaa"), + image("ccc", "2026-10-07T08:50:17.72561574Z", "Fork_400+ccc"), + image("bbb", "2026-10-07T09:30:23.012650643Z", "Fork_408+bbb"), + ].join("\n"), + stderr: "", + }; + } + if (command.startsWith("docker system df")) { + return { + stdout: JSON.stringify({ + Images: [{ ID: "sha256:aaa", UniqueSize: "4.115GB" }], + }), + stderr: "", + }; + } + return { + stdout: [ + container("aaa", true, "dokploy"), + container("bbb", false, "dokploy"), + container("bbb", false, "dokploy"), + container("ccc", false, ""), + ].join("\n"), + stderr: "", + }; + }); + + const images = await getDokployImages(); + + expect(images.map((item) => item.forkVersion)).toEqual([ + "Fork_412+aaa", + "Fork_408+bbb", + "Fork_400+ccc", + ]); + expect(images[0]).toMatchObject({ + isCurrent: true, + uniqueSizeBytes: 4115000000, + officialVersion: "v0.30.6", + }); + expect(images[1]).toMatchObject({ + isCurrent: false, + dokployTaskContainers: 2, + otherContainers: 0, + uniqueSizeBytes: null, + }); + expect(images[2]).toMatchObject({ otherContainers: 1 }); + }); + + it("returns nothing when there are no Dokploy images", async () => { + execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + await expect(getDokployImages()).resolves.toEqual([]); + expect(execAsync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/dokploy/__test__/settings/dokploy-image-plan.test.ts b/apps/dokploy/__test__/settings/dokploy-image-plan.test.ts new file mode 100644 index 00000000000..0b20b7f57a0 --- /dev/null +++ b/apps/dokploy/__test__/settings/dokploy-image-plan.test.ts @@ -0,0 +1,81 @@ +import type { DokployImageInfo } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; +import { planDokployImageCleanup } from "@/components/dashboard/settings/web-server/dokploy-image-plan"; + +const image = ( + fork: string, + overrides: Partial = {}, +): DokployImageInfo => ({ + id: `sha256:${fork}`, + tags: [], + createdAt: "2026-10-07T10:00:00Z", + sizeBytes: 4_450_000_000, + uniqueSizeBytes: 4_100_000_000, + forkVersion: fork, + officialVersion: "v0.30.6", + isCurrent: false, + dokployTaskContainers: 0, + otherContainers: 0, + ...overrides, +}); + +const actions = (plan: ReturnType) => + plan.rows.map((row) => `${row.version}:${row.action}`); + +describe("planDokployImageCleanup", () => { + const images = [ + image("v4", { isCurrent: true }), + image("v3", { dokployTaskContainers: 2 }), + image("v2", { dokployTaskContainers: 1 }), + image("v1"), + ]; + + it("keeps everything when the cleanup is off", () => { + const plan = planDokployImageCleanup(images, null, "v5"); + expect(actions(plan)).toEqual([ + "v5:new", + "v4:keep", + "v3:keep", + "v2:keep", + "v1:keep", + ]); + expect(plan.removeCount).toBe(0); + }); + + it("counts the pending build as one of the kept images", () => { + const plan = planDokployImageCleanup(images, 3, "v5"); + expect(actions(plan)).toEqual([ + "v5:new", + "v4:keep", + "v3:keep", + "v2:remove", + "v1:remove", + ]); + expect(plan.freedBytes).toBe(8_200_000_000); + expect(plan.removedContainers).toBe(1); + }); + + it("does not add a pending row when the new build is already pulled", () => { + const plan = planDokployImageCleanup(images, 3, "v4"); + expect(actions(plan)).toEqual([ + "v4:keep", + "v3:keep", + "v2:keep", + "v1:remove", + ]); + }); + + it("never plans to delete images that other containers or the running server use", () => { + const plan = planDokployImageCleanup( + [ + image("v3"), + image("v2", { otherContainers: 1 }), + image("v1", { isCurrent: true }), + ], + 1, + null, + ); + expect(actions(plan)).toEqual(["v3:keep", "v2:blocked", "v1:blocked"]); + expect(plan.removeCount).toBe(0); + }); +}); diff --git a/apps/dokploy/__test__/settings/request-log-authorization.test.ts b/apps/dokploy/__test__/settings/request-log-authorization.test.ts index 5495e47f152..f9066263f0a 100644 --- a/apps/dokploy/__test__/settings/request-log-authorization.test.ts +++ b/apps/dokploy/__test__/settings/request-log-authorization.test.ts @@ -90,6 +90,11 @@ const mocks = vi.hoisted(() => ({ vi.mock("@dokploy/server", () => ({ CLEANUP_CRON_JOB: "0 0 * * *", DEFAULT_UPDATE_DATA: {}, + DOKPLOY_KEEP_IMAGES_ENV: "DOKPLOY_KEEP_IMAGES", + DOKPLOY_KEEP_IMAGES_MAX: 5, + DOKPLOY_KEEP_IMAGES_MIN: 3, + getDokployImageKeepCount: vi.fn(), + getDokployImages: vi.fn(), IS_CLOUD: false, checkGPUStatus: mocks.checkGPUStatus, checkPortInUse: mocks.checkPortInUse, diff --git a/apps/dokploy/components/dashboard/settings/web-server/dokploy-image-plan.ts b/apps/dokploy/components/dashboard/settings/web-server/dokploy-image-plan.ts new file mode 100644 index 00000000000..c21d359638c --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/dokploy-image-plan.ts @@ -0,0 +1,96 @@ +import type { DokployImageInfo } from "@dokploy/server/index"; + +export type DokployImageAction = "new" | "keep" | "remove" | "blocked"; + +export interface PlannedDokployImage { + key: string; + version: string; + image: DokployImageInfo | null; + action: DokployImageAction; +} + +export interface DokployImagePlan { + rows: PlannedDokployImage[]; + removeCount: number; + freedBytes: number; + removedContainers: number; + totalBytes: number; +} + +const getImageTag = (image: DokployImageInfo) => + image.tags[0]?.split(":").pop() ?? null; + +export const getDokployImageVersion = (image: DokployImageInfo) => + image.forkVersion ?? getImageTag(image) ?? image.id.slice(7, 19); + +const isImageOfVersion = (image: DokployImageInfo, version: string) => + image.forkVersion === version || + image.tags.some((tag) => tag.endsWith(`:${version}`)); + +/** + * Mirrors getDokployImageCleanupCommand: the newest `keep` images stay, older + * ones go unless another container still uses them. A pending update counts + * as the newest image because the cleanup runs after it is pulled. + */ +export const planDokployImageCleanup = ( + images: DokployImageInfo[], + keep: number | null, + pendingVersion: string | null, +): DokployImagePlan => { + const hasPending = + !!pendingVersion && + !images.some((image) => isImageOfVersion(image, pendingVersion)); + const ordered: (DokployImageInfo | null)[] = [ + ...(hasPending ? [null] : []), + ...images, + ]; + + const rows = ordered.map((image, index): PlannedDokployImage => { + if (!image) { + return { + key: "pending", + version: pendingVersion ?? "", + image: null, + action: "new", + }; + } + let action: DokployImageAction = "keep"; + if (keep !== null && index >= keep) { + // Without an update the current container keeps running, so its image stays. + const stillRunning = image.isCurrent && !pendingVersion; + action = image.otherContainers > 0 || stillRunning ? "blocked" : "remove"; + } + return { + key: image.id, + version: getDokployImageVersion(image), + image, + action, + }; + }); + + const removed = rows.filter((row) => row.action === "remove"); + return { + rows, + removeCount: removed.length, + freedBytes: removed.reduce( + (sum, row) => + sum + (row.image?.uniqueSizeBytes ?? row.image?.sizeBytes ?? 0), + 0, + ), + removedContainers: removed.reduce( + (sum, row) => sum + (row.image?.dokployTaskContainers ?? 0), + 0, + ), + totalBytes: images.reduce((sum, image) => sum + image.sizeBytes, 0), + }; +}; + +export const formatImageSize = (bytes: number) => { + if (bytes >= 1e9) { + return `${(bytes / 1e9).toFixed(2)} GB`; + } + if (bytes >= 1e6) { + return `${Math.round(bytes / 1e6)} MB`; + } + return `${Math.round(bytes / 1e3)} KB`; +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/dokploy-images-info.tsx b/apps/dokploy/components/dashboard/settings/web-server/dokploy-images-info.tsx new file mode 100644 index 00000000000..02af515316c --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/dokploy-images-info.tsx @@ -0,0 +1,207 @@ +import type { DokployImageInfo } from "@dokploy/server/index"; +import { format, formatDistanceToNow } from "date-fns"; +import { HardDrive, Loader2, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { + type DokployImageAction, + formatImageSize, + planDokployImageCleanup, +} from "./dokploy-image-plan"; + +interface Props { + keepImages: number | null; + pendingVersion: string | null; + enabled: boolean; +} + +interface PopoverProps { + images: DokployImageInfo[] | undefined; + isLoading: boolean; + keepImages: number | null; + pendingVersion: string | null; +} + +const rowStyles: Record = { + new: "border-dashed border-emerald-500/50 bg-emerald-500/5", + keep: "border-emerald-500/30 bg-emerald-500/5", + remove: "border-red-500/50 bg-red-500/10", + blocked: "border-yellow-500/40 bg-yellow-500/10", +}; + +const actionBadges: Record< + DokployImageAction, + { label: string; className: string } +> = { + new: { label: "New build", className: "bg-emerald-600 text-white" }, + keep: { label: "Keep", className: "bg-emerald-600/80 text-white" }, + remove: { label: "Delete", className: "bg-red-600 text-white" }, + blocked: { label: "In use", className: "bg-yellow-600 text-white" }, +}; + +export const DokployImagesInfo = ({ + keepImages, + pendingVersion, + enabled, +}: Props) => { + const { data: images, isLoading } = api.settings.getDokployImages.useQuery( + undefined, + { enabled }, + ); + return ( + + ); +}; + +export const DokployImagesPopover = ({ + images, + isLoading, + keepImages, + pendingVersion, +}: PopoverProps) => { + const plan = images + ? planDokployImageCleanup(images, keepImages, pendingVersion) + : null; + + return ( + + + + + +
+

Dokploy images on this server

+

+ {keepImages === null + ? "Cleanup is off: every image stays." + : `The newest ${keepImages} stay${pendingVersion ? ", counting the new build" : ""}. Red ones are deleted after the update.`} +

+
+ + {isLoading && ( +
+ + Reading Docker images... +
+ )} + + {plan && plan.rows.length === 0 && ( +

No Dokploy images found.

+ )} + + {plan && plan.rows.length > 0 && ( +
    + {plan.rows.map((row, index) => { + const badge = actionBadges[row.action]; + const image = row.image; + return ( +
  • + + {index + 1} + +
    +
    + + {row.version} + + {badge.label} + {image?.isCurrent && ( + Running now + )} +
    +
    + {image ? ( + <> + {format( + new Date(image.createdAt), + "dd.MM.yyyy HH:mm", + )}{" "} + ( + {formatDistanceToNow(new Date(image.createdAt), { + addSuffix: true, + })} + ) + {image.officialVersion && + ` · ${image.officialVersion}`} + + ) : ( + "Downloaded during the update" + )} +
    + {row.action === "remove" && + !!image?.dokployTaskContainers && ( +
    + + {image.dokployTaskContainers} stopped Dokploy{" "} + {image.dokployTaskContainers === 1 + ? "container" + : "containers"} +
    + )} + {row.action === "blocked" && ( +
    + Used by a container, so it is not deleted +
    + )} +
    + {image && ( +
    +
    + {formatImageSize(image.sizeBytes)} +
    + {image.uniqueSizeBytes !== null && ( +
    + frees {formatImageSize(image.uniqueSizeBytes)} +
    + )} +
    + )} +
  • + ); + })} +
+ )} + + {plan && keepImages !== null && ( +
+ + {plan.removeCount > 0 + ? `Deletes ${plan.removeCount} ${plan.removeCount === 1 ? "image" : "images"}, frees about ${formatImageSize(plan.freedBytes)}` + : "Nothing to delete"} +
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx new file mode 100644 index 00000000000..3edf1a3b6f2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-image-cleanup.tsx @@ -0,0 +1,76 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { DokployImagesInfo } from "./dokploy-images-info"; + +const IMAGE_KEEP_OPTIONS = [3, 4, 5]; +const DEFAULT_IMAGE_KEEP_COUNT = 3; + +interface Props { + keepImages: number | null; + onChange: (keepImages: number | null) => void; + disabled?: boolean; + pendingVersion: string | null; + loadImages: boolean; +} + +export const ToggleImageCleanup = ({ + keepImages, + onChange, + disabled, + pendingVersion, + loadImages, +}: Props) => { + const enabled = keepImages !== null; + + return ( +
+
+ + onChange(checked ? DEFAULT_IMAGE_KEEP_COUNT : null) + } + id="imageCleanupToggle" + disabled={disabled} + /> + + +
+
+

+ Saved with the next update. After restarting, Dokploy deletes older + images and the stopped containers that keep them. +

+ +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index fe9ebb207b3..be1bb5012a1 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -28,6 +28,7 @@ import { } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates"; +import { ToggleImageCleanup } from "./toggle-image-cleanup"; import { UpdateWebServer } from "./update-webserver"; interface Props { @@ -58,6 +59,12 @@ export const UpdateServer = ({ updateData?.updateSource ?? "official", ); const [isOpenInternal, setIsOpenInternal] = useState(false); + const { data: savedKeepImages } = + api.settings.getDokployImageKeepCount.useQuery(); + const [keepImagesChoice, setKeepImagesChoice] = useState(); + // Stays undefined until the saved value loads, so the update leaves it as is. + const keepImages = + keepImagesChoice !== undefined ? keepImagesChoice : savedKeepImages; const isAgentHitsUpdate = updateSource === "agenthits" || dokployVersionData?.isFork; @@ -291,8 +298,17 @@ export const UpdateServer = ({ )} -
+
+
@@ -301,7 +317,10 @@ export const UpdateServer = ({ Cancel {isUpdateAvailable ? ( - + ) : (