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
+67 -16
View File
@@ -2332,16 +2332,20 @@ function applyRoleUI() {
el.classList.toggle('role-hidden', !allowed); el.classList.toggle('role-hidden', !allowed);
// Only force-disable for insufficient role. Never force-enable — that // Only force-disable for insufficient role. Never force-enable — that
// would wipe container lifecycle state (e.g. Start disabled while running). // would wipe container lifecycle state (e.g. Start disabled while running).
// Also never clear data-in-use locks (images used by containers).
if ('disabled' in el) { if ('disabled' in el) {
if (!allowed) { if (!allowed) {
el.disabled = true; el.disabled = true;
el.dataset.roleDisabled = '1'; el.dataset.roleDisabled = '1';
} else if (el.dataset.roleDisabled === '1') { } else if (el.dataset.roleDisabled === '1') {
el.disabled = false; if (el.dataset.inUse !== '1') {
el.disabled = false;
}
delete el.dataset.roleDisabled; delete el.dataset.roleDisabled;
} }
} }
el.setAttribute('aria-disabled', allowed ? 'false' : 'true'); const lockedInUse = el.dataset.inUse === '1';
el.setAttribute('aria-disabled', allowed && !lockedInUse ? 'false' : 'true');
if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`; if (!allowed) el.title = el.title || `Requires ${el.dataset.minRole} role`;
}); });
// Re-apply container start/stop/terminal enablement (role pass must not re-enable them) // Re-apply container start/stop/terminal enablement (role pass must not re-enable them)
@@ -3774,13 +3778,26 @@ function filterImages(filter) {
renderImages(allImages); renderImages(allImages);
} }
function isImageInUse(image) {
return Array.isArray(image?.usage) && image.usage.length > 0;
}
function isImageUnused(image) {
return Array.isArray(image?.usage) && image.usage.length === 0;
}
function renderImages(images) { function renderImages(images) {
// Store all images // Preserve usage when a push omits it so Used/Unused filters stay stable mid-delete
allImages = images || []; const prevUsageById = new Map((allImages || []).map((img) => [img.Id, img.usage]));
allImages = (images || []).map((image) => {
if (Array.isArray(image.usage)) return image;
const prev = prevUsageById.get(image.Id);
return prev !== undefined ? { ...image, usage: prev } : image;
});
// Calculate filter counts // Calculate filter counts
const usedCount = allImages.filter(image => image.usage && image.usage.length > 0).length; const usedCount = allImages.filter(isImageInUse).length;
const unusedCount = allImages.filter(image => !image.usage || image.usage.length === 0).length; const unusedCount = allImages.filter(isImageUnused).length;
// Update filter badge counts // Update filter badge counts
const allCountEl = document.getElementById('filter-count-all'); const allCountEl = document.getElementById('filter-count-all');
@@ -3794,9 +3811,9 @@ function renderImages(images) {
// Filter images based on current filter + search // Filter images based on current filter + search
let filteredImages = allImages; let filteredImages = allImages;
if (currentImageFilter === 'used') { if (currentImageFilter === 'used') {
filteredImages = allImages.filter(image => image.usage && image.usage.length > 0); filteredImages = allImages.filter(isImageInUse);
} else if (currentImageFilter === 'unused') { } else if (currentImageFilter === 'unused') {
filteredImages = allImages.filter(image => !image.usage || image.usage.length === 0); filteredImages = allImages.filter(isImageUnused);
} }
const imgSearch = document.getElementById('image-search')?.value?.trim().toLowerCase() || ''; const imgSearch = document.getElementById('image-search')?.value?.trim().toLowerCase() || '';
if (imgSearch) { if (imgSearch) {
@@ -3850,6 +3867,7 @@ function renderImages(images) {
const size = formatBytes(image.Size); const size = formatBytes(image.Size);
const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown'; const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown';
const usage = image.usage ? image.usage.length : 0; const usage = image.usage ? image.usage.length : 0;
const inUse = isImageInUse(image);
const tagBadges = const tagBadges =
tags.length > 0 tags.length > 0
? tags ? tags
@@ -3861,11 +3879,17 @@ function renderImages(images) {
: '<span class="badge bg-dark border border-secondary">&lt;none&gt;</span>'; : '<span class="badge bg-dark border border-secondary">&lt;none&gt;</span>';
const tagsJson = encodeURIComponent(JSON.stringify(tags)); const tagsJson = encodeURIComponent(JSON.stringify(tags));
const canPull = tags.length > 0 && tags[0] !== '<none>:<none>'; const canPull = tags.length > 0 && tags[0] !== '<none>:<none>';
const selectDisabled = inUse
? 'disabled data-in-use="1" title="In use by a container — cannot select for removal"'
: '';
const removeDisabled = inUse
? 'disabled data-in-use="1" aria-disabled="true" title="In use by a container — stop or remove containers using this image first"'
: 'title="Remove"';
return ` return `
<tr> <tr>
<td data-col="select"> <td data-col="select">
<input type="checkbox" class="image-checkbox" data-image-id="${image.Id}" onchange="updateBulkActionsImagesToolbar()"> <input type="checkbox" class="image-checkbox" data-image-id="${image.Id}" ${selectDisabled} onchange="updateBulkActionsImagesToolbar()">
</td> </td>
<td data-col="repository" class="text-break" style="max-width: 14rem;">${repo}</td> <td data-col="repository" class="text-break" style="max-width: 14rem;">${repo}</td>
<td data-col="tags" style="max-width: 12rem;">${tagBadges}</td> <td data-col="tags" style="max-width: 12rem;">${tagBadges}</td>
@@ -3891,7 +3915,7 @@ function renderImages(images) {
</button>` </button>`
: '' : ''
} }
<button class="btn btn-outline-danger action-remove-image" data-image-id="${image.Id}" title="Remove" data-min-role="admin"> <button class="btn btn-outline-danger action-remove-image" data-image-id="${image.Id}" ${removeDisabled} data-min-role="admin">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</div> </div>
@@ -3901,10 +3925,28 @@ function renderImages(images) {
}).join(''); }).join('');
// Add event listeners // Add event listeners
applyRoleUI(); applyRoleUI();
// Re-lock in-use controls — applyRoleUI must not re-enable them after a role change
imagesList.querySelectorAll('[data-in-use="1"]').forEach((el) => {
el.disabled = true;
el.setAttribute('aria-disabled', 'true');
});
const selectAllImages = document.getElementById('select-all-images');
const selectableCheckboxes = imagesList.querySelectorAll('.image-checkbox:not(:disabled)');
if (selectAllImages) {
selectAllImages.disabled = selectableCheckboxes.length === 0;
selectAllImages.checked = false;
selectAllImages.indeterminate = false;
}
imagesList.querySelectorAll('.action-remove-image').forEach(btn => { imagesList.querySelectorAll('.action-remove-image').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (btn.disabled || btn.dataset.inUse === '1') return;
const imageId = btn.dataset.imageId; const imageId = btn.dataset.imageId;
const image = allImages.find((img) => img.Id === imageId);
if (isImageInUse(image)) {
showAlert('warning', 'Cannot remove an image that is in use by a container');
return;
}
showConfirmModal('Are you sure you want to remove this image?', () => { showConfirmModal('Are you sure you want to remove this image?', () => {
sendCommand('removeImage', { id: imageId, force: true }); sendCommand('removeImage', { id: imageId, force: true });
setTimeout(() => loadImages(), 1000); setTimeout(() => loadImages(), 1000);
@@ -8460,8 +8502,13 @@ function getSelectedContainers() {
} }
function getSelectedImages() { function getSelectedImages() {
const checkboxes = document.querySelectorAll('.image-checkbox:checked'); const checkboxes = document.querySelectorAll('.image-checkbox:checked:not(:disabled)');
return Array.from(checkboxes).map(cb => cb.dataset.imageId); return Array.from(checkboxes)
.map((cb) => cb.dataset.imageId)
.filter((id) => {
const image = allImages.find((img) => img.Id === id);
return image && !isImageInUse(image);
});
} }
function updateBulkActionsToolbar() { function updateBulkActionsToolbar() {
@@ -8600,8 +8647,9 @@ function toggleSelectAllContainers(checkbox) {
} }
function toggleSelectAllImages(checkbox) { function toggleSelectAllImages(checkbox) {
const checkboxes = document.querySelectorAll('.image-checkbox'); // Never select images that are in use by a container
checkboxes.forEach(cb => { const checkboxes = document.querySelectorAll('.image-checkbox:not(:disabled)');
checkboxes.forEach((cb) => {
cb.checked = checkbox.checked; cb.checked = checkbox.checked;
}); });
updateBulkActionsImagesToolbar(); updateBulkActionsImagesToolbar();
@@ -8768,7 +8816,10 @@ function startExecTerminal(containerId, execId) {
async function bulkRemoveImages() { async function bulkRemoveImages() {
const selected = getSelectedImages(); const selected = getSelectedImages();
if (selected.length === 0) return; if (selected.length === 0) {
showAlert('warning', 'No removable images selected (in-use images cannot be removed)');
return;
}
let confirmed = false; let confirmed = false;
await new Promise((resolve) => { await new Promise((resolve) => {
+48 -28
View File
@@ -2,6 +2,7 @@
* Image RPC handlers. * Image RPC handlers.
*/ */
import { docker } from '../services/docker.js' import { docker } from '../services/docker.js'
import { peers } from '../core/peer-registry.js'
import * as validation from '../utils/validation.js' import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js' import { Pushes } from '../../shared/protocol.js'
import { import {
@@ -120,36 +121,55 @@ export function buildImagePushOpts(opts = {}) {
return out 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) { export function registerImageHandlers(session) {
session.respond('listImages', async (args = {}) => { session.respond('listImages', async (args = {}) => {
const listOpts = { all: args.all !== false } let imagesWithUsage = await listImagesWithUsage(args)
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] || [],
}))
const total = imagesWithUsage.length const total = imagesWithUsage.length
const offset = Math.max(0, Number(args.offset) || 0) 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 { peers } from '../core/peer-registry.js'
import { Pushes } from '../../shared/protocol.js' import { Pushes } from '../../shared/protocol.js'
import { broadcastContainers } from '../handlers/containers.js' import { broadcastContainers } from '../handlers/containers.js'
import { broadcastImages } from '../handlers/images.js'
import { onDockerEvent } from './alerts.js' import { onDockerEvent } from './alerts.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
@@ -15,6 +16,8 @@ let stopped = false
let reconnectAttempt = 0 let reconnectAttempt = 0
/** Coalesce noisy container events into one list broadcast */ /** Coalesce noisy container events into one list broadcast */
let containerListBroadcastTimer = null let containerListBroadcastTimer = null
/** Coalesce image delete/untag bursts into one usage-aware broadcast */
let imageListBroadcastTimer = null
const BASE_DELAY_MS = 2000 const BASE_DELAY_MS = 2000
const MAX_DELAY_MS = 30_000 const MAX_DELAY_MS = 30_000
@@ -45,6 +48,16 @@ function scheduleContainerListBroadcast() {
}, 400) }, 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() { export async function startDockerEventStream() {
stopped = false stopped = false
await openEventStream() await openEventStream()
@@ -111,12 +124,7 @@ async function openEventStream() {
} }
if (event.Type === 'image') { if (event.Type === 'image') {
try { scheduleImageListBroadcast()
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 })
}
} }
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) { if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
+21
View File
@@ -2488,6 +2488,27 @@ input.reg-tag-check:focus-visible,
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8h9' stroke='%230a0c10' stroke-width='2.2' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M3.5 8h9' stroke='%230a0c10' stroke-width='2.2' stroke-linecap='round'/%3E%3C/svg%3E");
} }
.table input[type='checkbox']:disabled,
input.image-checkbox:disabled,
#select-all-images:disabled {
opacity: 0.4;
cursor: not-allowed;
border-color: var(--border-strong);
background-color: var(--bg-tertiary);
}
.table input[type='checkbox']:disabled:hover,
input.image-checkbox:disabled:hover,
#select-all-images:disabled:hover {
border-color: var(--border-strong);
background-color: var(--bg-tertiary);
}
button.action-remove-image:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── Responsive resource tables (no horizontal scrollbar) ─── */ /* ─── Responsive resource tables (no horizontal scrollbar) ─── */
.view, .view,
.view .container-fluid, .view .container-fluid,