From c2071bbbfd583f5e33b807899f4857d3d325d20a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 12 Aug 2026 04:30:19 -0600 Subject: [PATCH] feat: add Images and Disk Usage tabs to docker dashboard - Images tab: list, inspect and delete docker images, with a force-delete fallback when an image is in use. - Disk Usage tab: docker system df summary as stat cards plus a Build Cache table (docker system df -v) with a prune action. --- .../docker/disk-usage/show-disk-usage.tsx | 428 ++++++++++++++++ .../dashboard/docker/images/show-images.tsx | 470 ++++++++++++++++++ apps/dokploy/pages/dashboard/docker.tsx | 10 + apps/dokploy/server/api/root.ts | 4 + .../server/api/routers/docker-disk-usage.ts | 66 +++ .../server/api/routers/docker-image.ts | 82 +++ packages/server/src/index.ts | 3 +- packages/server/src/services/docker-image.ts | 68 +++ packages/server/src/utils/docker/utils.ts | 51 +- 9 files changed, 1179 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx create mode 100644 apps/dokploy/components/dashboard/docker/images/show-images.tsx create mode 100644 apps/dokploy/server/api/routers/docker-disk-usage.ts create mode 100644 apps/dokploy/server/api/routers/docker-image.ts create mode 100644 packages/server/src/services/docker-image.ts diff --git a/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx new file mode 100644 index 000000000..0ce4f21ea --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + Database, + Gauge, + HardDrive, + Layers, + Loader2, + type LucideIcon, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DiskUsageItem = + inferRouterOutputs["dockerDiskUsage"]["getDiskUsage"][number]; +type BuildCacheBase = + inferRouterOutputs["dockerDiskUsage"]["getBuildCache"][number]; +type BuildCacheRow = BuildCacheBase & { key: string }; + +interface Props { + serverId?: string; +} + +const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [ + { type: "Images", title: "Images", icon: Layers }, + { type: "Containers", title: "Containers", icon: Boxes }, + { type: "Local Volumes", title: "Volumes", icon: HardDrive }, + { type: "Build Cache", title: "Build Cache", icon: Database }, +]; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const StatCard = ({ + icon: Icon, + title, + item, + isLoading, +}: { + icon: LucideIcon; + title: string; + item?: DiskUsageItem; + isLoading: boolean; +}) => ( + +
+ + {title} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
{item?.size ?? "-"}
+
+ {item?.totalCount ?? 0} total + {item?.active ?? 0} active + {item?.reclaimable ?? "-"} reclaimable +
+ + )} +
+); + +export const ShowDiskUsage = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Size", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: diskUsage, isLoading: isLoadingDiskUsage } = + api.dockerDiskUsage.getDiskUsage.useQuery({ serverId }); + const { data: buildCache, isLoading: isLoadingBuildCache } = + api.dockerDiskUsage.getBuildCache.useQuery({ serverId }); + const { mutateAsync: pruneBuildCache, isPending: isPruning } = + api.dockerDiskUsage.pruneBuildCache.useMutation(); + + const usageByType = useMemo( + () => new Map((diskUsage ?? []).map((item) => [item.type, item])), + [diskUsage], + ); + + const rows = useMemo( + () => + (buildCache ?? []).map((entry) => ({ + ...entry, + key: entry.id, + })), + [buildCache], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (entry) => + entry.id.toLowerCase().includes(query) || + entry.description.toLowerCase().includes(query) || + entry.type.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handlePrune = async () => { + try { + await pruneBuildCache({ serverId }); + toast.success("Build cache pruned"); + await Promise.all([ + utils.dockerDiskUsage.getBuildCache.invalidate(), + utils.dockerDiskUsage.getDiskUsage.invalidate(), + ]); + } catch (error) { + toast.error("Error pruning build cache", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.id.slice(0, 16)} + + ), + }, + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => {row.original.type}, + }, + { + accessorKey: "description", + header: "Description", + enableSorting: false, + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + }, + { + id: "Size", + accessorFn: (entry) => entry.sizeBytes, + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.size} + ), + }, + { + id: "created", + accessorKey: "createdSince", + header: "Created", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.createdSince} + + ), + }, + { + id: "lastUsed", + accessorKey: "lastUsedSince", + header: "Last Used", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.lastUsedSince} + + ), + }, + { + accessorKey: "usageCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.usageCount} + + ), + }, + { + id: "flags", + enableSorting: false, + header: "Flags", + cell: ({ row }) => ( +
+ {row.original.inUse && In use} + {row.original.shared && Shared} +
+ ), + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ {STAT_CARDS.map((stat) => ( + + ))} +
+ +
+ +
+
+ + + Build Cache + + + Cache layers kept by the Docker builder on the selected + server. + +
+ + + +
+
+ + {isLoadingBuildCache ? ( +
+ Loading... + +
+ ) : !buildCache?.length ? ( +
+
+ +
+
+

No build cache found

+

+ Layers cached by "docker build" on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No build cache entries match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/images/show-images.tsx b/apps/dokploy/components/dashboard/docker/images/show-images.tsx new file mode 100644 index 000000000..bb694f128 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/images/show-images.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type ImageBase = + inferRouterOutputs["dockerImage"]["getImages"][number]; +type ImageRow = ImageBase & { key: string }; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const getReference = (image: ImageBase) => + image.Repository !== "" && image.Tag !== "" + ? `${image.Repository}:${image.Tag}` + : image.ID; + +const FORCE_HINT_REGEX = + /must be forced|image is being used|referenced in multiple repositories/i; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowImageConfig = ({ + imageRef, + serverId, +}: { + imageRef: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery( + { imageRef, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Image Config + + docker image inspect output for "{imageRef}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowImages = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Repository", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [forcePrompt, setForcePrompt] = useState<{ + image: ImageRow; + message: string; + } | null>(null); + + const { data: images, isLoading } = api.dockerImage.getImages.useQuery({ + serverId, + }); + const { mutateAsync: removeImage } = + api.dockerImage.removeImage.useMutation(); + + const rows = useMemo( + () => + (images ?? []).map((image) => ({ + ...image, + key: `${image.ID}-${image.Repository}-${image.Tag}`, + })), + [images], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (image) => + image.Repository.toLowerCase().includes(query) || + image.Tag.toLowerCase().includes(query) || + image.ID.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handleDelete = async (image: ImageRow, force = false) => { + try { + await removeImage({ + repository: image.Repository, + tag: image.Tag, + id: image.ID, + force, + serverId, + }); + toast.success("Image deleted"); + setForcePrompt(null); + await utils.dockerImage.getImages.invalidate(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (!force && FORCE_HINT_REGEX.test(message)) { + setForcePrompt({ image, message }); + } else { + toast.error("Error deleting image", { description: message }); + } + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Repository", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Repository} +
+ ), + }, + { + accessorKey: "Tag", + header: ({ column }) => , + cell: ({ row }) => {row.original.Tag}, + }, + { + accessorKey: "ID", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ID} + + ), + }, + { + id: "Size", + accessorFn: (image) => parseSize(image.Size), + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.Size} + ), + }, + { + id: "Created", + accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.CreatedSince} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + handleDelete(row.original)} + > + + +
+ ), + }, + ], + [serverId], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Images + + + Manage the Docker images of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !images?.length ? ( +
+
+ +
+
+

No images found

+

+ Docker images pulled or built on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No images match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+ !open && setForcePrompt(null)} + > + + + Image is in use + + {forcePrompt?.message} Do you want to force the deletion? This may + affect containers still using this image. + + + + setForcePrompt(null)}> + Cancel + + + forcePrompt && handleDelete(forcePrompt.image, true) + } + > + Force delete + + + + +
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/docker.tsx b/apps/dokploy/pages/dashboard/docker.tsx index 47bfc3201..c73627c77 100644 --- a/apps/dokploy/pages/dashboard/docker.tsx +++ b/apps/dokploy/pages/dashboard/docker.tsx @@ -4,7 +4,9 @@ import type { GetServerSidePropsContext } from "next"; import { useRouter } from "next/router"; import type { ReactElement } from "react"; import superjson from "superjson"; +import { ShowDiskUsage } from "@/components/dashboard/docker/disk-usage/show-disk-usage"; import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events"; +import { ShowImages } from "@/components/dashboard/docker/images/show-images"; import { ShowContainers } from "@/components/dashboard/docker/show/show-containers"; import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes"; import { ShowNetworks } from "@/components/dashboard/networks/show-networks"; @@ -63,9 +65,11 @@ const Dashboard = () => { Containers Swarm + Images Volumes Networks Events + Disk Usage @@ -92,6 +96,9 @@ const Dashboard = () => { + + + @@ -101,6 +108,9 @@ const Dashboard = () => { + + + )} diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index d09a82940..ea9aed210 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -11,6 +11,8 @@ import { deploymentRouter } from "./routers/deployment"; import { destinationRouter } from "./routers/destination"; import { dnsProviderRouter } from "./routers/dns-provider"; import { dockerRouter } from "./routers/docker"; +import { dockerDiskUsageRouter } from "./routers/docker-disk-usage"; +import { dockerImageRouter } from "./routers/docker-image"; import { dockerVolumeRouter } from "./routers/docker-volume"; import { domainRouter } from "./routers/domain"; import { environmentRouter } from "./routers/environment"; @@ -72,6 +74,8 @@ export const appRouter = createTRPCRouter({ destination: destinationRouter, dnsProvider: dnsProviderRouter, docker: dockerRouter, + dockerDiskUsage: dockerDiskUsageRouter, + dockerImage: dockerImageRouter, dockerVolume: dockerVolumeRouter, domain: domainRouter, gitea: giteaRouter, diff --git a/apps/dokploy/server/api/routers/docker-disk-usage.ts b/apps/dokploy/server/api/routers/docker-disk-usage.ts new file mode 100644 index 000000000..5f1fcbb43 --- /dev/null +++ b/apps/dokploy/server/api/routers/docker-disk-usage.ts @@ -0,0 +1,66 @@ +import { + cleanupBuilders, + findServerById, + getBuildCache, + getDockerDiskUsage, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { audit } from "@/server/api/utils/audit"; +import { createTRPCRouter, withPermission } from "../trpc"; + +export const dockerDiskUsageRouter = createTRPCRouter({ + getDiskUsage: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getDockerDiskUsage(input.serverId); + }), + + getBuildCache: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getBuildCache(input.serverId); + }), + + pruneBuildCache: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await cleanupBuilders(input.serverId); + await audit(ctx, { + action: "delete", + resourceType: "docker", + resourceId: "build-cache", + resourceName: "Docker build cache", + }); + }), +}); diff --git a/apps/dokploy/server/api/routers/docker-image.ts b/apps/dokploy/server/api/routers/docker-image.ts new file mode 100644 index 000000000..dd825cabb --- /dev/null +++ b/apps/dokploy/server/api/routers/docker-image.ts @@ -0,0 +1,82 @@ +import { + findServerById, + getImageConfig, + getImages, + removeImage, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { audit } from "@/server/api/utils/audit"; +import { createTRPCRouter, withPermission } from "../trpc"; + +export const dockerImageRouter = createTRPCRouter({ + getImages: withPermission("docker", "read") + .input( + z.object({ + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getImages(input.serverId); + }), + + getImageConfig: withPermission("docker", "read") + .input( + z.object({ + imageRef: z.string().min(1), + serverId: z.string().optional(), + }), + ) + .query(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + return await getImageConfig(input.imageRef, input.serverId); + }), + + removeImage: withPermission("docker", "read") + .input( + z.object({ + repository: z.string(), + tag: z.string(), + id: z.string().min(1), + force: z.boolean().optional(), + serverId: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.serverId) { + const server = await findServerById(input.serverId); + if (server.organizationId !== ctx.session?.activeOrganizationId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + await removeImage( + { + repository: input.repository, + tag: input.tag, + id: input.id, + force: input.force, + }, + input.serverId, + ); + await audit(ctx, { + action: "delete", + resourceType: "docker", + resourceId: input.id, + resourceName: + input.repository !== "" && input.tag !== "" + ? `${input.repository}:${input.tag}` + : input.id, + }); + }), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 036f31bf6..b2a0c7f12 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,6 +18,7 @@ export * from "./services/deployment"; export * from "./services/destination"; export * from "./services/dns-provider"; export * from "./services/docker"; +export * from "./services/docker-image"; export * from "./services/docker-volume"; export * from "./services/domain"; export * from "./services/environment"; @@ -104,7 +105,6 @@ export * from "./utils/docker/compose/volume"; export * from "./utils/docker/domain"; export * from "./utils/docker/types"; export * from "./utils/docker/utils"; -export * from "./utils/vault"; export * from "./utils/filesystem/directory"; export * from "./utils/filesystem/ssh"; export * from "./utils/git-branch-validation"; @@ -141,6 +141,7 @@ export * from "./utils/traefik/redirect"; export * from "./utils/traefik/security"; export * from "./utils/traefik/types"; export * from "./utils/traefik/web-server"; +export * from "./utils/vault"; export * from "./utils/volume-backups/index"; export * from "./utils/watch-paths/should-deploy"; export * from "./verification/send-verification-email"; diff --git a/packages/server/src/services/docker-image.ts b/packages/server/src/services/docker-image.ts new file mode 100644 index 000000000..ca6ef6282 --- /dev/null +++ b/packages/server/src/services/docker-image.ts @@ -0,0 +1,68 @@ +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { quote } from "shell-quote"; + +export interface DockerImage { + Repository: string; + Tag: string; + ID: string; + Digest?: string; + CreatedAt: string; + CreatedSince: string; + Size: string; + SharedSize?: string; + UniqueSize?: string; + VirtualSize?: string; +} + +export const getImages = async (serverId?: string) => { + try { + const command = "docker images --format '{{json .}}'"; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return stdout + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as DockerImage); + } catch (error) { + console.error(error); + return []; + } +}; + +export const getImageConfig = async (imageRef: string, serverId?: string) => { + const command = `docker image inspect ${quote([String(imageRef ?? "")])}`; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + return JSON.parse(stdout.trim())[0]; +}; + +interface RemoveImageParams { + repository: string; + tag: string; + id: string; + force?: boolean; +} + +export const removeImage = async ( + { repository, tag, id, force }: RemoveImageParams, + serverId?: string, +) => { + const hasTaggedReference = + repository && tag && repository !== "" && tag !== ""; + const reference = hasTaggedReference ? `${repository}:${tag}` : id; + const command = `docker rmi ${force ? "-f " : ""}${quote([String(reference ?? "")])}`; + + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } +}; diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 42fb252f9..fccd979fd 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -291,9 +291,13 @@ const parseSizeToBytes = (size: string): number => { return value * (multipliers[unit] || 0); }; -export const getDockerDiskUsage = async (): Promise => { +export const getDockerDiskUsage = async ( + serverId?: string, +): Promise => { const command = "docker system df --format '{{json .}}'"; - const { stdout } = await execAsync(command); + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); const lines = stdout.trim().split("\n").filter(Boolean); return lines.map((line) => { @@ -309,6 +313,49 @@ export const getDockerDiskUsage = async (): Promise => { }); }; +export interface DockerBuildCacheItem { + id: string; + type: string; + description: string; + size: string; + sizeBytes: number; + createdSince: string; + lastUsedSince: string; + usageCount: number; + shared: boolean; + inUse: boolean; +} + +export const getBuildCache = async ( + serverId?: string, +): Promise => { + try { + const command = "docker system df -v --format '{{json .}}'"; + const { stdout } = serverId + ? await execAsyncRemote(serverId, command) + : await execAsync(command); + + const diskUsage = JSON.parse(stdout.trim()); + return ((diskUsage?.BuildCache ?? []) as Record[]).map( + (entry) => ({ + id: entry.ID ?? "", + type: entry.CacheType ?? "", + description: entry.Description ?? "", + size: entry.Size ?? "", + sizeBytes: parseSizeToBytes(entry.Size ?? ""), + createdSince: entry.CreatedSince ?? "", + lastUsedSince: entry.LastUsedSince ?? "", + usageCount: Number.parseInt(entry.UsageCount ?? "0", 10) || 0, + shared: entry.Shared === "true", + inUse: entry.InUse === "true", + }), + ); + } catch (error) { + console.error(error); + return []; + } +}; + /** * Volume cleanup should always be performed manually by the user. The reason is that during automatic cleanup, a volume may be deleted due to a stopped container, which is a dangerous situation. *