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);
|
||||
// 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"><none></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) => {
|
||||
|
||||
Reference in New Issue
Block a user