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);
// Only force-disable for insufficient role. Never force-enable — that
// 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 (!allowed) {
el.disabled = true;
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;
}
}
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`;
});
// Re-apply container start/stop/terminal enablement (role pass must not re-enable them)
@@ -3774,13 +3778,26 @@ function filterImages(filter) {
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) {
// Store all images
allImages = images || [];
// Preserve usage when a push omits it so Used/Unused filters stay stable mid-delete
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
const usedCount = allImages.filter(image => image.usage && image.usage.length > 0).length;
const unusedCount = allImages.filter(image => !image.usage || image.usage.length === 0).length;
const usedCount = allImages.filter(isImageInUse).length;
const unusedCount = allImages.filter(isImageUnused).length;
// Update filter badge counts
const allCountEl = document.getElementById('filter-count-all');
@@ -3794,9 +3811,9 @@ function renderImages(images) {
// Filter images based on current filter + search
let filteredImages = allImages;
if (currentImageFilter === 'used') {
filteredImages = allImages.filter(image => image.usage && image.usage.length > 0);
filteredImages = allImages.filter(isImageInUse);
} 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() || '';
if (imgSearch) {
@@ -3850,6 +3867,7 @@ function renderImages(images) {
const size = formatBytes(image.Size);
const created = image.Created ? new Date(image.Created * 1000).toLocaleDateString() : 'Unknown';
const usage = image.usage ? image.usage.length : 0;
const inUse = isImageInUse(image);
const tagBadges =
tags.length > 0
? tags
@@ -3861,11 +3879,17 @@ function renderImages(images) {
: '<span class="badge bg-dark border border-secondary">&lt;none&gt;</span>';
const tagsJson = encodeURIComponent(JSON.stringify(tags));
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 `
<tr>
<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 data-col="repository" class="text-break" style="max-width: 14rem;">${repo}</td>
<td data-col="tags" style="max-width: 12rem;">${tagBadges}</td>
@@ -3891,7 +3915,7 @@ function renderImages(images) {
</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>
</button>
</div>
@@ -3901,10 +3925,28 @@ function renderImages(images) {
}).join('');
// 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 => {
btn.addEventListener('click', () => {
if (btn.disabled || btn.dataset.inUse === '1') return;
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?', () => {
sendCommand('removeImage', { id: imageId, force: true });
setTimeout(() => loadImages(), 1000);
@@ -8460,8 +8502,13 @@ function getSelectedContainers() {
}
function getSelectedImages() {
const checkboxes = document.querySelectorAll('.image-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.dataset.imageId);
const checkboxes = document.querySelectorAll('.image-checkbox:checked:not(:disabled)');
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() {
@@ -8600,8 +8647,9 @@ function toggleSelectAllContainers(checkbox) {
}
function toggleSelectAllImages(checkbox) {
const checkboxes = document.querySelectorAll('.image-checkbox');
checkboxes.forEach(cb => {
// Never select images that are in use by a container
const checkboxes = document.querySelectorAll('.image-checkbox:not(:disabled)');
checkboxes.forEach((cb) => {
cb.checked = checkbox.checked;
});
updateBulkActionsImagesToolbar();
@@ -8768,7 +8816,10 @@ function startExecTerminal(containerId, execId) {
async function bulkRemoveImages() {
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;
await new Promise((resolve) => {