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:
@@ -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"><none></span>';
|
: '<span class="badge bg-dark border border-secondary"><none></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
@@ -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)
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user