Prevent removing in-use images and keep Unused filter stable during bulk delete.
Release rolling / release (push) Successful in 8m55s

Disable select/delete for images with container usage, and broadcast image lists with usage attached so the Unused tab no longer flashes the full inventory mid-removal.
This commit is contained in:
Raven Scott
2026-07-25 08:31:36 -04:00
parent 93962e17c2
commit dcb5b3db4f
4 changed files with 150 additions and 50 deletions
+48 -28
View File
@@ -2,6 +2,7 @@
* Image RPC handlers.
*/
import { docker } from '../services/docker.js'
import { peers } from '../core/peer-registry.js'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import {
@@ -120,36 +121,55 @@ export function buildImagePushOpts(opts = {}) {
return out
}
/**
* List Docker images with per-image container usage attached.
* Shared by listImages RPC and live event broadcasts so Used/Unused filters stay correct.
* @param {object} [opts]
* @returns {Promise<object[]>}
*/
export async function listImagesWithUsage(opts = {}) {
const listOpts = { all: opts.all !== false }
if (opts.filters) listOpts.filters = opts.filters
if (opts.dangling === true) {
listOpts.filters = { ...(listOpts.filters || {}), dangling: ['true'] }
}
if (opts.reference) {
listOpts.filters = {
...(listOpts.filters || {}),
reference: [String(opts.reference)],
}
}
const images = await docker.listImages(listOpts)
const containers = await docker.listContainers({ all: true })
const imageUsage = {}
for (const container of containers) {
const imageId = container.ImageID
if (!imageUsage[imageId]) imageUsage[imageId] = []
imageUsage[imageId].push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
})
}
return images.map((image) => ({
...image,
usage: imageUsage[image.Id] || [],
}))
}
export async function broadcastImages() {
try {
const images = await listImagesWithUsage({ all: true })
peers.broadcast(Pushes.images, { type: 'images', data: images })
} catch (err) {
logger.error('Failed to broadcast images', { error: err.message })
}
}
export function registerImageHandlers(session) {
session.respond('listImages', async (args = {}) => {
const listOpts = { all: args.all !== false }
if (args.filters) listOpts.filters = args.filters
if (args.dangling === true) {
listOpts.filters = { ...(listOpts.filters || {}), dangling: ['true'] }
}
if (args.reference) {
listOpts.filters = {
...(listOpts.filters || {}),
reference: [String(args.reference)],
}
}
let images = await docker.listImages(listOpts)
const containers = await docker.listContainers({ all: true })
const imageUsage = {}
for (const container of containers) {
const imageId = container.ImageID
if (!imageUsage[imageId]) imageUsage[imageId] = []
imageUsage[imageId].push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
})
}
let imagesWithUsage = images.map((image) => ({
...image,
usage: imageUsage[image.Id] || [],
}))
let imagesWithUsage = await listImagesWithUsage(args)
const total = imagesWithUsage.length
const offset = Math.max(0, Number(args.offset) || 0)
+14 -6
View File
@@ -6,6 +6,7 @@ import { docker, extractVolumesList } from './docker.js'
import { peers } from '../core/peer-registry.js'
import { Pushes } from '../../shared/protocol.js'
import { broadcastContainers } from '../handlers/containers.js'
import { broadcastImages } from '../handlers/images.js'
import { onDockerEvent } from './alerts.js'
import logger from '../utils/logger.js'
@@ -15,6 +16,8 @@ let stopped = false
let reconnectAttempt = 0
/** Coalesce noisy container events into one list broadcast */
let containerListBroadcastTimer = null
/** Coalesce image delete/untag bursts into one usage-aware broadcast */
let imageListBroadcastTimer = null
const BASE_DELAY_MS = 2000
const MAX_DELAY_MS = 30_000
@@ -45,6 +48,16 @@ function scheduleContainerListBroadcast() {
}, 400)
}
function scheduleImageListBroadcast() {
if (imageListBroadcastTimer) return
imageListBroadcastTimer = setTimeout(() => {
imageListBroadcastTimer = null
broadcastImages().catch((err) => {
logger.debug('image list broadcast failed', { error: err.message })
})
}, 400)
}
export async function startDockerEventStream() {
stopped = false
await openEventStream()
@@ -111,12 +124,7 @@ async function openEventStream() {
}
if (event.Type === 'image') {
try {
const images = await docker.listImages({ all: true })
peers.broadcast(Pushes.images, { type: 'images', data: images })
} catch (e) {
logger.debug('image list on event failed', { error: e.message })
}
scheduleImageListBroadcast()
}
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {