From 6cb2568d2903ab457e1facb23fd5fe1a159976a3 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:37:41 -0400 Subject: [PATCH] fix(monitoring): name untagged images in Docker Disk Usage Docker Disk Usage listed many images as ":" with the subtitle "Dangling local image", including images that running containers use. Docker keeps no tag for an image in two common cases: - the tag moved to a newer pull or build (a redeploy of an application or of the panel itself), while an exited swarm task from the previous deploy still holds the old image; - the image was pulled by digest, as compose does for "name:tag@sha256:" references, so no tag is stored locally at all. The name is now taken from the reference the image's containers were created from (running containers first), and from RepoDigests when no container uses the image. A digest-only name keeps a short digest, so "repo@sha256:<12 chars>". The subtitle says which case it is: "Previous version: the tag now points to another image", "Pulled by digest, no local tag", or "Dangling local image" when nothing uses it. Source lists the references. Containers are now matched to images by image id from docker container inspect. docker ps gives no image id and shows a bare id once the tag has moved, so digest-pinned containers were reported as "no containers reported" before. Used by also marks containers that are not running, e.g. "panel.1.abc (exited)", which shows why an old image is kept. Checked against a real host's data: all 28 untagged images got their names, and Used by matched the CONTAINERS column of docker system df -v. Co-Authored-By: Claude Opus 5.5 --- .../monitoring/docker-disk-usage.test.ts | 106 +++++++++++++- packages/server/src/utils/docker/utils.ts | 138 ++++++++++++++---- 2 files changed, 213 insertions(+), 31 deletions(-) diff --git a/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts b/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts index 157c7b3cb26..3630aa9e37a 100644 --- a/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts +++ b/apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts @@ -124,6 +124,7 @@ describe("parseDockerDiskUsageVerbose", () => { { id: "274333ea2993", image: "2e7598a736d0", + imageId: "sha256:2e7598a736d0full", name: "dokploy.1.test", }, ], @@ -152,7 +153,7 @@ describe("parseDockerDiskUsageVerbose", () => { expect(enriched.images[0]).toEqual( expect.objectContaining({ name: ":", - subtitle: "Dangling local image", + subtitle: "Untagged local image", }), ); expect(enriched.images[0]?.meta).toEqual( @@ -184,6 +185,109 @@ describe("parseDockerDiskUsageVerbose", () => { ]), ); }); + + test("names untagged images after the references that created them", () => { + const details = parseDockerDiskUsageVerbose( + [ + "Images space usage:", + "", + "REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS", + " d0ef856968db 2 hours ago 2.45GB 74.87MB 2.373GB 1", + " 3a02d38405dc 5 months ago 114MB 0B 114MB 2", + " b1c5c0aec2ab 9 hours ago 1.42GB 1.416GB 0B 0", + ].join("\n"), + ); + const redisDigest = + "sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1"; + + const enriched = enrichDockerDiskUsageDetails(details, { + containers: [ + { + id: "old-panel-task", + image: "ghcr.io/example/panel:dev", + imageId: "sha256:d0ef856968dbfull", + name: "panel.1.old", + state: "exited", + }, + { + id: "redis-a", + image: `redis:8-alpine@${redisDigest}`, + imageId: "sha256:3a02d38405dcfull", + name: "stack-redis-1", + state: "running", + }, + { + id: "redis-b", + image: `redis:8-alpine@${redisDigest}`, + imageId: "sha256:3a02d38405dcfull", + name: "stack-redis-rate-limit-1", + state: "running", + }, + ], + imageInspects: new Map([ + [ + "d0ef856968db", + { + Id: "sha256:d0ef856968dbfull", + RepoDigests: ["ghcr.io/example/panel@sha256:08556bfbd114b366"], + RepoTags: [], + }, + ], + [ + "3a02d38405dc", + { + Id: "sha256:3a02d38405dcfull", + RepoDigests: [`redis@${redisDigest}`], + RepoTags: [], + }, + ], + [ + "b1c5c0aec2ab", + { + Id: "sha256:b1c5c0aec2abfull", + RepoDigests: [ + "registry.example.com:5000/team/grafana@sha256:b01e2b61edddaf1f8ed9", + ], + RepoTags: [], + }, + ], + ]), + }); + const byId = new Map(enriched.images.map((image) => [image.id, image])); + + expect(byId.get("d0ef856968db")).toEqual( + expect.objectContaining({ + name: "ghcr.io/example/panel:dev", + subtitle: "Previous version: the tag now points to another image", + }), + ); + expect(byId.get("d0ef856968db")?.meta).toEqual( + expect.arrayContaining([ + { label: "Source", value: "ghcr.io/example/panel:dev" }, + { label: "Used by", value: "panel.1.old (exited)" }, + ]), + ); + expect(byId.get("3a02d38405dc")).toEqual( + expect.objectContaining({ + name: "redis:8-alpine", + subtitle: "Pulled by digest, no local tag", + }), + ); + expect(byId.get("3a02d38405dc")?.meta).toEqual( + expect.arrayContaining([ + { + label: "Used by", + value: "stack-redis-1, stack-redis-rate-limit-1", + }, + ]), + ); + expect(byId.get("b1c5c0aec2ab")).toEqual( + expect.objectContaining({ + name: "registry.example.com:5000/team/grafana@sha256:b01e2b61eddd", + subtitle: "Dangling local image", + }), + ); + }); }); describe("limitDockerDiskUsageDetails", () => { diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index c6f5874a3e5..e6c1fe02e1d 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -362,7 +362,9 @@ const sortDiskUsageDetails = (items: DockerDiskUsageDetailItem[]) => interface DockerDiskUsageContainerReference { id: string; image: string; + imageId: string; name: string; + state?: string; } interface DockerImageInspect { @@ -410,23 +412,59 @@ const parseJsonArray = (stdout: string): T[] => { const isDanglingImageName = (name: string) => name === ":"; -const isSameImageReference = ( - containerImage: string, - detail: DockerDiskUsageDetailItem, - inspect?: DockerImageInspect, -) => { - const fullId = inspect?.Id ?? ""; - const shortId = detail.id; +const isImageIdReference = (reference: string) => + /^(sha256:)?[a-f0-9]{12,64}$/.test(reference); - return ( - containerImage === detail.name || - containerImage === shortId || - containerImage.startsWith(shortId) || - (Boolean(fullId) && containerImage === fullId) || - (Boolean(fullId) && fullId.startsWith(`sha256:${containerImage}`)) +const splitImageReference = (reference: string) => { + const [repositoryAndTag = reference, digest] = reference.split("@"); + const lastPathSegment = repositoryAndTag.slice( + repositoryAndTag.lastIndexOf("/") + 1, ); + return { + digest, + hasTag: lastPathSegment.includes(":"), + repositoryAndTag, + }; }; +const getImageReferenceName = (reference: string) => { + const { digest, hasTag, repositoryAndTag } = splitImageReference(reference); + if (hasTag || !digest) { + return repositoryAndTag; + } + return `${repositoryAndTag}@${digest.slice(0, "sha256:".length + 12)}`; +}; + +// Docker drops the tag from an image once the tag moves to a newer pull or +// build, and never stores one for an image pulled by digest; the reference a +// container was created from still says what the image is. +const describeUntaggedImage = (references: string[], usedBy: string[]) => { + const hasMovedTag = references.some((reference) => { + const { digest, hasTag } = splitImageReference(reference); + return hasTag && !digest; + }); + if (hasMovedTag) { + return "Previous version: the tag now points to another image"; + } + if (references.length > 0) { + return "Pulled by digest, no local tag"; + } + return usedBy.length > 0 ? "Untagged local image" : "Dangling local image"; +}; + +const isContainerOfImage = ( + container: DockerDiskUsageContainerReference, + detail: DockerDiskUsageDetailItem, + inspect?: DockerImageInspect, +) => + container.imageId === inspect?.Id || + container.imageId.startsWith(`sha256:${detail.id}`); + +const formatContainerName = (container: DockerDiskUsageContainerReference) => + container.state && container.state !== "running" + ? `${container.name} (${container.state})` + : container.name; + const formatListValue = (items: string[], emptyValue = "--") => items.length > 0 ? items.join(", ") : emptyValue; @@ -452,18 +490,29 @@ export const enrichDockerDiskUsageDetails = ( inspect?.RepoTags?.filter((tag: string) => tag !== ":") ?? []; const repoDigests = inspect?.RepoDigests ?? []; - const usedBy = containers - .filter((container) => - isSameImageReference(container.image, detail, inspect), - ) - .map((container) => container.name); + const imageContainers = containers + .filter((container) => isContainerOfImage(container, detail, inspect)) + .sort( + (a, b) => + Number(b.state === "running") - Number(a.state === "running"), + ); + const usedBy = imageContainers.map(formatContainerName); + const isUntagged = isDanglingImageName(detail.name); + const references = [ + ...new Set( + imageContainers + .map((container) => container.image) + .filter((reference) => !isImageIdReference(reference)), + ), + ]; + const nameReference = references[0] ?? repoDigests[0]; const imagePath = getImagePathFromInspect(inspect); const extraMeta = [ { label: "Full image id", value: fullId }, { label: "Source", - value: isDanglingImageName(detail.name) - ? "dangling local image" + value: isUntagged + ? formatListValue(references, "dangling local image") : detail.name, }, ...(repoTags.length > 0 @@ -487,8 +536,12 @@ export const enrichDockerDiskUsageDetails = ( (meta) => meta.label !== "Containers" || usedBy.length === 0, ), ], - subtitle: isDanglingImageName(detail.name) - ? "Dangling local image" + name: + isUntagged && nameReference + ? getImageReferenceName(nameReference) + : detail.name, + subtitle: isUntagged + ? describeUntaggedImage(references, usedBy) : detail.subtitle, }; }), @@ -688,26 +741,51 @@ export const limitDockerDiskUsageDetails = ( volumes: limitDiskUsageDetailItems(details.volumes, detailLimit), }); +// docker ps has no image id and prints a bare id instead of the reference once +// the tag has moved on, so only inspect ties a container to an untagged image. +const dockerContainerReferenceFormat = + '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"state":{{json .State.Status}}}'; + const getDockerDiskUsageContainerReferences = async (): Promise< DockerDiskUsageContainerReference[] > => { + const { stdout: idsStdout } = await execAsync("docker ps -aq --no-trunc"); + const containerIds = idsStdout + .split("\n") + .map((containerId) => containerId.trim()) + .filter(Boolean); + if (containerIds.length === 0) { + return []; + } + const { stdout } = await execAsync( - "docker ps -a --no-trunc --format '{{json .}}'", + `docker container inspect --format ${quoteShellArg( + dockerContainerReferenceFormat, + )} ${containerIds.map((containerId) => quoteShellArg(containerId)).join(" ")}`, ); return parseJsonLines<{ - ID?: string; - Image?: string; - Names?: string; + id?: string; + image?: string; + imageId?: string; + name?: string; + state?: string; }>(stdout).flatMap((container) => { - if (!container.ID || !container.Image || !container.Names) { + if ( + !container.id || + !container.image || + !container.imageId || + !container.name + ) { return []; } return { - id: container.ID, - image: container.Image, - name: container.Names, + id: container.id, + image: container.image, + imageId: container.imageId, + name: container.name.replace(/^\//, ""), + state: container.state, }; }); };