Prevent removing in-use images and keep Unused filter stable during bulk delete.
Release rolling / release (push) Successful in 8m55s
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:
+48
-28
@@ -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)
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user