fix(resources): exclude rollback-held images from reclaimable figure (#1808)

The Resources disk footprint and reclaim banner counted rollback-hold
images (sencho-rb/*:hold) as reclaimable, but every prune path refuses
to remove held images, so the advertised figure exceeded what any prune
could actually free. getDiskUsage now supports an opt-in held-image
exclusion wired into the docker-df readout and both prune estimate
paths, so the banner, the plan, and the result agree. The docs state
the exclusion explicitly.
This commit is contained in:
Anso
2026-08-09 00:18:33 -04:00
committed by GitHub
parent 4aae09db4a
commit 12d80c5ca7
3 changed files with 387 additions and 11 deletions
+335 -1
View File
@@ -70,8 +70,21 @@ beforeEach(() => {
vi.clearAllMocks();
composeDirRef.current = '/test/compose';
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
// Empty held sets by default so the held-image exclusion paths (getDiskUsage
// with excludeHeldImages, estimateManagedReclaim) are hermetic. The
// DatabaseService mock above exposes none of the recovery-row queries, so
// an unspied getHeldImageIds would return null and the fail-closed
// predicate would treat every image as held. Tests that need specific
// holds re-spy after this beforeEach runs.
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
});
/** Hold specific images via the stack-side recovery service for one test. */
const holdStackImages = (ids: string[]) => {
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(ids));
};
// ── validateApiData ────────────────────────────────────────────────────
describe('DockerController - validateApiData', () => {
@@ -383,7 +396,12 @@ describe('DockerController - getDiskUsage', () => {
expect(usage.reclaimableContainerCount).toBe(2);
});
it('estimateSystemReclaim returns same bytes as getDiskUsage for every target type', async () => {
it('estimateSystemReclaim returns same bytes as getDiskUsage for every target type when no images are held', async () => {
// Scoped to the no-holds case: estimateSystemReclaim('images') now
// excludes rollback-held images (getDiskUsage({ excludeHeldImages: true })),
// so the equality only holds when the held set is empty (the beforeEach
// default). The deliberate divergence with a hold present is asserted by
// the companion test below.
mockDocker.df.mockResolvedValue({
LayersSize: 1_000_000_000,
Images: [
@@ -424,6 +442,214 @@ describe('DockerController - getDiskUsage', () => {
expect(mockDocker.listNetworks).not.toHaveBeenCalled();
expect(mockDocker.listContainers).not.toHaveBeenCalled();
});
it('estimateSystemReclaim images estimate excludes held bytes; other targets are untouched', async () => {
// The deliberate divergence: the estimate claims "what a prune can free",
// so the images estimate must drop the held image's unique bytes while the
// other targets still match the raw daemon figure exactly.
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
LayersSize: 2_000_000,
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
{ Id: 'used-1', Containers: 1, Size: 800, VirtualSize: 800, SharedSize: 0 },
],
Containers: [
{ State: 'exited', SizeRw: 200 },
],
Volumes: [
{ UsageData: { RefCount: 0, Size: 400 } },
],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
// Base reclaimable images: LayersSize 2_000_000 minus the in-use image's
// 800 = 1_999_200. Held unique bytes = 1_000. Estimate = 1_998_200.
expect(usage.reclaimableImages).toBe(1_999_200);
const estimate = await dc.estimateSystemReclaim('images', []);
expect(estimate.reclaimableBytes).toBe(usage.reclaimableImages - 1_000);
const containers = await dc.estimateSystemReclaim('containers', []);
expect(containers.reclaimableBytes).toBe(usage.reclaimableContainers);
const volumes = await dc.estimateSystemReclaim('volumes', []);
expect(volumes.reclaimableBytes).toBe(usage.reclaimableVolumes);
const networks = await dc.estimateSystemReclaim('networks', []);
expect(networks.reclaimableBytes).toBe(0);
});
// ── held-image exclusion (excludeHeldImages) ──────────────────────────
it('getDiskUsage default keeps rollback-held images in the reclaimable figure', async () => {
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage();
// Default call keeps the daemon's own figure: held images are "unused
// Docker data" for callers whose claim is not "what a prune can free".
expect(usage.reclaimableImages).toBe(1_500);
expect(usage.reclaimableImageCount).toBe(2);
});
it('getDiskUsage with excludeHeldImages subtracts held bytes and count on the daemon-reclaimable fast path', async () => {
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
expect(usage.reclaimableImages).toBe(500);
expect(usage.reclaimableImageCount).toBe(1);
});
it('getDiskUsage with excludeHeldImages subtracts held unique bytes on the LayersSize fallback path', async () => {
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
LayersSize: 2_000,
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 200 },
{ Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
{ Id: 'used-1', Containers: 1, Size: 800, VirtualSize: 800, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
// Base: LayersSize 2_000 minus the in-use image's 800 = 1_200, count 2.
// Held unique bytes = 1_000 - 200 = 800. Excluded: 400, count 1.
expect(usage.reclaimableImages).toBe(400);
expect(usage.reclaimableImageCount).toBe(1);
});
it('getDiskUsage with excludeHeldImages clamps at zero when held bytes exceed the figure', async () => {
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 100 },
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
expect(usage.reclaimableImages).toBe(0);
expect(usage.reclaimableImageCount).toBe(0);
});
it('getDiskUsage with excludeHeldImages does not subtract a held image that a container references', async () => {
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
// Held but in use: never part of the reclaimable set, so exclusion
// must leave both bytes and count untouched.
{ Id: 'held-1', Containers: 1, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
expect(usage.reclaimableImages).toBe(1_500);
expect(usage.reclaimableImageCount).toBe(1);
});
it('getDiskUsage with excludeHeldImages fails closed to zero when the held lookup fails', async () => {
// A null held lookup means "held state unknown"; the predicate protects
// every image, matching the delete guard and the prune plan. The figure
// claims 0 rather than advertising bytes no prune can free.
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'free-1', Containers: 0, Size: 1_500, VirtualSize: 1_500, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const excluded = await dc.getDiskUsage({ excludeHeldImages: true });
expect(excluded.reclaimableImages).toBe(0);
expect(excluded.reclaimableImageCount).toBe(0);
const plain = await dc.getDiskUsage();
expect(plain.reclaimableImages).toBe(1_500);
expect(plain.reclaimableImageCount).toBe(1);
});
it('getDiskUsage with excludeHeldImages subtracts service-scoped holds too', async () => {
// The unified predicate unions stack and service holds; a regression that
// dropped the service arm from the figure paths would not be caught by
// the stack-only tests.
vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['held-svc']));
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'held-svc', Containers: 0, Size: 700, VirtualSize: 700, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 800, VirtualSize: 800, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
expect(usage.reclaimableImages).toBe(800);
expect(usage.reclaimableImageCount).toBe(1);
});
it('getDiskUsage with excludeHeldImages clamps bytes but keeps the remaining count', async () => {
// Mixed boundary: held bytes exceed the daemon figure (bytes clamp to 0)
// while a non-held image keeps the count positive. Pins the bytes-side
// clamp independently of the count-side one.
holdStackImages(['held-1']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 100 },
Images: [
{ Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'free-1', Containers: 0, Size: 50, VirtualSize: 50, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
const dc = DockerController.getInstance(1);
const usage = await dc.getDiskUsage({ excludeHeldImages: true });
expect(usage.reclaimableImages).toBe(0);
expect(usage.reclaimableImageCount).toBe(1);
});
});
// ── pruneSystem ────────────────────────────────────────────────────────
@@ -751,6 +977,78 @@ describe('DockerController - getClassifiedResources', () => {
const img = result.images.find(i => i.Id === 'img-unrelated');
expect(img?.rollbackProtected).toBe(true);
});
it('getDiskUsageClassified excludes held bytes from the reclaimable figure but keeps inventory totals', async () => {
// The actual producer of the hero and treemap figures (GET /system/docker-df):
// the held image's bytes and count leave reclaimableImages, while the
// managed/unmanaged inventory totals are untouched.
holdStackImages(['img-held']);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'img-held', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
{ Id: 'img-free', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
{ Id: 'img-used', Containers: 1, Size: 2_000, VirtualSize: 2_000, SharedSize: 0 },
{ Id: 'img-unmanaged', Containers: 1, Size: 300, VirtualSize: 300, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-held', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 1_000, Containers: 0 },
{ Id: 'img-free', RepoTags: ['myregistry/app:1.4'], Size: 500, Containers: 0 },
{ Id: 'img-used', RepoTags: ['myregistry/app:2.0'], Size: 2_000, Containers: 1 },
{ Id: 'img-unmanaged', RepoTags: ['other/container:1'], Size: 300, Containers: 1 },
]);
mockDocker.listContainers.mockResolvedValue([
{ Id: 'c1', ImageID: 'img-used', Labels: { 'com.docker.compose.project': 'my-stack' } },
{ Id: 'c2', ImageID: 'img-unmanaged', Labels: { 'com.docker.compose.project': 'other' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getDiskUsageClassified(['my-stack']);
expect(result.reclaimableImages).toBe(500);
expect(result.reclaimableImageCount).toBe(1);
expect(result.managedImageBytes).toBe(2_000);
expect(result.unmanagedImageBytes).toBe(300);
});
it('getDiskUsageClassified stays internally consistent when the held lookup fails', async () => {
// Split-read state: the reclaimable figure and the rollback badges run
// independent held lookups. A null stack lookup fails both sides closed,
// so the response claims 0 reclaimable images while every classified
// image is badged protected, never the reverse.
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
mockDocker.df.mockResolvedValue({
ImageUsage: { Reclaimable: 1_500 },
Images: [
{ Id: 'img-free', Containers: 0, Size: 1_500, VirtualSize: 1_500, SharedSize: 0 },
],
Volumes: [],
Containers: [],
});
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-free', RepoTags: ['myregistry/app:1.4'], Size: 1_500, Containers: 0 },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getDiskUsageClassified(['my-stack']);
expect(result.reclaimableImages).toBe(0);
expect(result.reclaimableImageCount).toBe(0);
// The badge side (a separate /system/resources response) fails closed the
// same way under the same null lookup, so the two surfaces never disagree
// in direction.
const classified = await dc.getClassifiedResources(['my-stack']);
const img = classified.images.find(i => i.Id === 'img-free');
expect(img?.rollbackProtected).toBe(true);
});
});
// ── pruneManagedOnly / estimateManagedReclaim (images) ─────────────────
@@ -785,6 +1083,42 @@ describe('DockerController - managed image prune accounting', () => {
expect(result.reclaimableBytes).toBe(1200);
});
it('estimateManagedReclaim skips a held image even when its compose labels match', async () => {
// docker tag preserves labels, so a held image can carry the stack's
// com.docker.compose.project label and would otherwise pass the
// repo/label classifier. The held check runs before classification so
// the estimate matches the destructive path (pruneManagedOnly).
holdStackImages(['img-held']);
mockDocker.df.mockResolvedValue({
Images: [
{ Id: 'img-held', SharedSize: 0 },
{ Id: 'img-free', SharedSize: 0 },
],
});
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-held', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-free', Containers: 0, Size: 600, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
expect(result.reclaimableBytes).toBe(600);
});
it('estimateManagedReclaim fails closed to zero when the held lookup fails', async () => {
vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
mockDocker.df.mockResolvedValue(sharedLayerDfMock);
mockDocker.listImages.mockResolvedValue(unusedManagedListMock);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.estimateManagedReclaim('images', ['any-stack']);
expect(result.reclaimableBytes).toBe(0);
});
it('pruneManagedOnly reports the LayersSize delta as the reclaimed total', async () => {
// df-before reports 5000 bytes on disk; df-after reports 3400. The
// honest reclaim is 1600, independent of per-image Size/SharedSize.
+48 -8
View File
@@ -324,7 +324,16 @@ class DockerController {
return data as T;
}
public async getDiskUsage() {
/**
* Docker daemon disk usage, with an optional rollback-hold exclusion.
* `excludeHeldImages` makes the figure mean "what a prune can actually
* free": rollback-held images (sencho-rb/*:hold) are counted by the daemon
* as unused but every Sencho prune path refuses to remove them, so figures
* that claim prunability must subtract them. The default keeps the raw
* daemon math for callers whose claim is "unused Docker data" (the
* MonitorService janitor alert), not "what a prune frees".
*/
public async getDiskUsage(opts?: { excludeHeldImages: boolean }) {
const df = await this.docker.df();
const reclaimableContainers = (items: any[]) => {
@@ -402,9 +411,31 @@ class DockerController {
};
const imageUsage = (df as { ImageUsage?: { Reclaimable?: number } }).ImageUsage;
const images = df.Images
let images = df.Images
? reclaimableImages(df.Images, df.LayersSize ?? 0, imageUsage?.Reclaimable)
: { bytes: 0, count: 0 };
if (opts?.excludeHeldImages === true && Array.isArray(df.Images) && df.Images.length > 0) {
// Only images that counted as reclaimable (Containers === 0) are
// adjusted, so bytes and count stay consistent subsets of the base
// figures. Dynamic import avoids the static cycle (StackUpdateRecoveryService
// imports this class; see getClassifiedResources below for the same pattern).
const { buildUnifiedHeldImagePredicate } = await import('./recoveryHeldImages');
const isImageHeld = buildUnifiedHeldImagePredicate(this.nodeId);
const sharedSizes = DockerController.mapSharedSizesFromDf(df);
let heldBytes = 0;
let heldCount = 0;
for (const img of df.Images) {
if (!img?.Id || (img.Containers ?? 0) !== 0 || !isImageHeld(img.Id)) continue;
heldBytes += DockerController.imageUniqueBytes(img, sharedSizes);
heldCount += 1;
}
images = {
bytes: Math.max(0, images.bytes - heldBytes),
count: Math.max(0, images.count - heldCount),
};
}
const containers = df.Containers ? reclaimableContainers(df.Containers) : { bytes: 0, count: 0 };
const volumes = df.Volumes ? reclaimableVolumes(df.Volumes) : { bytes: 0, count: 0 };
const buildCache = df.BuildCache ? reclaimableBuildCache(df.BuildCache) : { bytes: 0, count: 0 };
@@ -941,8 +972,13 @@ class DockerController {
Containers?: number;
}>;
const { repoKeys } = buildManagedImageRepoSet(managedContainers, rawImages);
// Dynamic import for the same cycle reason as getDiskUsage: a held image
// can carry compose labels (docker tag preserves them), so the repo/label
// classifier would otherwise count it and over-report the estimate.
const { buildUnifiedHeldImagePredicate } = await import('./recoveryHeldImages');
const isImageHeld = buildUnifiedHeldImagePredicate(this.nodeId);
const prunable = rawImages.filter((img) => {
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id)) return false;
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id) || isImageHeld(img.Id)) return false;
return classifyManagedImageCandidate({
imageId: img.Id,
labels: img.Labels,
@@ -966,15 +1002,17 @@ class DockerController {
/**
* Non-destructive estimate for the `all` (system) prune scope. Uses only
* `docker system df` so the readout matches the daemon's own reclaimable
* totals for each resource type. `knownStackNames` is retained for
* call-site symmetry with {@link estimateManagedReclaim} and is unused
* by this method.
* totals for each resource type, except the images figure, which excludes
* rollback-held images so the estimate matches what the prune plan will
* actually remove (the plan skips held images). `knownStackNames` is
* retained for call-site symmetry with {@link estimateManagedReclaim} and
* is unused by this method.
*/
public async estimateSystemReclaim(
target: 'containers' | 'images' | 'networks' | 'volumes',
_knownStackNames: string[],
): Promise<{ reclaimableBytes: number }> {
const df = await this.getDiskUsage();
const df = await this.getDiskUsage(target === 'images' ? { excludeHeldImages: true } : undefined);
if (target === 'images') return { reclaimableBytes: df.reclaimableImages };
if (target === 'containers') return { reclaimableBytes: df.reclaimableContainers };
if (target === 'volumes') return { reclaimableBytes: df.reclaimableVolumes };
@@ -1653,7 +1691,9 @@ class DockerController {
unmanagedVolumeBytes: number;
}> {
const [base, classified] = await Promise.all([
this.getDiskUsage(),
// Exclude rollback-held images so the banner/treemap figure matches
// what a prune can actually free (see getDiskUsage docs).
this.getDiskUsage({ excludeHeldImages: true }),
this.getClassifiedResources(knownStackNames),
]);
+4 -2
View File
@@ -15,6 +15,8 @@ Once enabled for a node, an amber banner leads the view whenever there is reclai
The hero stays hidden when there is nothing to reclaim, keeping the view focused on the rest of your inventory.
The total does not include images Sencho holds as rollback protection points: a prune plan never removes those images. They surface in the Rollback tab and are freed once the hold is released or superseded.
To set the banner aside without pruning, use the **×** in its top-right corner. It stays hidden on that browser until the reclaimable total grows past the amount it held when you dismissed it, so a small, stubborn remainder will not keep reappearing while a genuine new build-up still surfaces.
The banner is off by default and applies per node. To turn it on, open **Settings → Monitoring → Docker & Storage** and switch on **Show reclaimable-space banner**.
@@ -31,7 +33,7 @@ The left card under the hero breaks down your Docker disk usage into three tiles
|------|---------|
| **Sencho managed** (green) | Images and volumes **currently used** by stacks in your `COMPOSE_DIR` |
| **External** (amber) | Images and volumes used by Docker projects outside Sencho |
| **Reclaimable** (neutral) | Unused images, stopped containers, and dangling volumes Docker can free |
| **Reclaimable** (neutral) | Unused images, stopped containers, and dangling volumes Docker can free. Images Sencho holds as rollback protection points are not included; they surface in the Rollback tab and are freed once the hold is released or superseded |
The managed tile does not count free (unused) images. Those rows appear under reclaimable space and in the Images tab as unused until you prune them.
@@ -168,6 +170,6 @@ When no unmanaged containers are detected, the tab shows a success state with th
The Unmanaged tab filters the running Sencho container out by design so it cannot be selected and purged. Other containers started outside Sencho (with `docker run` or by a Compose project outside `COMPOSE_DIR`) still appear normally. To inspect the Sencho container itself, use **Host Console** or `docker ps` on the host.
</Accordion>
<Accordion title="The reclaim banner will not go away, or shows a small amount that will not prune">
The banner mirrors what Docker reports as reclaimable on the node, and it counts only the resources a standard prune can actually remove. After a prune, some storage drivers still report a few megabytes as reclaimable that the prune does not free. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → Monitoring → Docker & Storage**.
The banner starts from what Docker reports as reclaimable on the node, subtracts images Sencho holds as rollback protection points, and counts only the resources a standard prune can actually remove. Held images are never part of that total and never appear in a prune plan, so the residue after a prune is normally the storage driver still reporting a few megabytes that the prune did not free. Held images are listed in the **Rollback** tab and are freed once the hold is released or superseded. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → Monitoring → Docker & Storage**.
</Accordion>
</AccordionGroup>