Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
39ccb08119 Merge claude/2 2026-06-25 12:56:08 +01:00
librelad
e33701ee52 feat(admin/storage): filter images by in-use/unused, in-use first
The Images list on /admin/system/storage now has an All / In use / Unused
segmented filter (with live per-group counts), and the default All view
sorts in-use images to the top — the ones you can't reclaim lead, the
reclaimable ones follow. Select all / Clear All act on the visible rows,
so they honour the active filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-25 12:56:08 +01:00
2 changed files with 132 additions and 16 deletions

View File

@ -84,10 +84,52 @@
.sys-images-toolbar { .sys-images-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: 14px; flex-wrap: wrap;
gap: 10px 14px;
margin: 10px 0 6px; margin: 10px 0 6px;
} }
.sys-images-actions {
display: flex;
align-items: center;
gap: 14px;
}
/* Storage → Images: All / In use / Unused segmented filter. */
.sys-img-filter {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
background: rgba(var(--text-rgb), 0.05);
border: 1px solid rgba(var(--text-rgb), 0.10);
border-radius: 9px;
}
.sys-img-filter-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: 0;
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
line-height: 1.4;
border-radius: 7px;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.sys-img-filter-btn:hover { color: var(--text-primary); }
.sys-img-filter-btn.is-active {
background: rgba(var(--accent-rgb), 0.16);
color: var(--accent);
}
.sys-img-filter-n {
font-size: 10px;
font-variant-numeric: tabular-nums;
opacity: 0.65;
}
.sys-img-filter-btn.is-active .sys-img-filter-n { opacity: 0.9; }
.sys-images-list { margin-top: 4px; } .sys-images-list { margin-top: 4px; }
.sys-image-item .task-header { cursor: default; } .sys-image-item .task-header { cursor: default; }
.sys-img-icon { color: rgba(var(--text-rgb), 0.6); flex-shrink: 0; } .sys-img-icon { color: rgba(var(--text-rgb), 0.6); flex-shrink: 0; }

View File

@ -24,6 +24,7 @@ class SystemStoragePage {
this._timer = null; this._timer = null;
this._expanded = new Set(); // app names whose folder breakdown is open this._expanded = new Set(); // app names whose folder breakdown is open
this._selectedImageIds = new Set(); // images ticked for bulk delete this._selectedImageIds = new Set(); // images ticked for bulk delete
this._imageFilter = 'all'; // images list filter: all | inuse | unused
this._deleting = false; // a remove-images task is in flight this._deleting = false; // a remove-images task is in flight
this._onClick = this._onClick.bind(this); this._onClick = this._onClick.bind(this);
this._onChange = this._onChange.bind(this); this._onChange = this._onChange.bind(this);
@ -67,12 +68,25 @@ class SystemStoragePage {
// Remove a single image. // Remove a single image.
const del = e.target.closest('[data-image-delete]'); const del = e.target.closest('[data-image-delete]');
if (del) { this._deleteImages([del.getAttribute('data-image-delete')]); return; } if (del) { this._deleteImages([del.getAttribute('data-image-delete')]); return; }
// Clear All / Delete Selected. // Filter chip (All / In use / Unused) — re-render the list in place,
// dropping any selection that belongs to the now-hidden group.
const filt = e.target.closest('[data-image-filter]');
if (filt) {
const f = filt.getAttribute('data-image-filter');
if (f && f !== this._imageFilter) {
this._imageFilter = f;
this._selectedImageIds.clear();
this._rerenderImages();
}
return;
}
// Clear All / Delete Selected. With no explicit selection, act on the
// rows currently shown (i.e. honour the active filter).
const clr = e.target.closest('[data-images-clear]'); const clr = e.target.closest('[data-images-clear]');
if (clr) { if (clr) {
const ids = this._selectedImageIds.size const ids = this._selectedImageIds.size
? [...this._selectedImageIds] ? [...this._selectedImageIds]
: ((this.data && this.data.image_list) || []).map(im => im.id); : this._visibleImages().map(im => im.id);
this._deleteImages(ids); this._deleteImages(ids);
return; return;
} }
@ -354,7 +368,7 @@ class SystemStoragePage {
dockerSection = ` dockerSection = `
<div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable the daemon's own usage, separate from your app data</span></div> <div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable the daemon's own usage, separate from your app data</span></div>
<div class="sys-storage-cards">${cards}</div> <div class="sys-storage-cards">${cards}</div>
${this._renderImages(images)}`; <div id="sys-images-region">${this._renderImages(images)}</div>`;
} }
body.innerHTML = `${headline}${appsSection}${dockerSection}`; body.innerHTML = `${headline}${appsSection}${dockerSection}`;
@ -410,6 +424,41 @@ class SystemStoragePage {
return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`; return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
} }
// True when an image is backing at least one container.
_imageInUse(im) { return (im && im.containers > 0); }
// In-use images float to the top. Array#sort is stable, so within each
// group the backend's largest-first order is preserved.
_sortImages(images) {
return images.slice().sort((a, b) =>
(this._imageInUse(a) ? 0 : 1) - (this._imageInUse(b) ? 0 : 1));
}
// Does an image pass the active filter chip? "unused" means "not in use" —
// tagged-but-idle images and dangling <none> layers alike.
_imageMatchesFilter(im) {
if (this._imageFilter === 'inuse') return this._imageInUse(im);
if (this._imageFilter === 'unused') return !this._imageInUse(im);
return true;
}
// The rows actually on screen (sorted + filtered) — what Select all and a
// selection-less Clear All operate on.
_visibleImages() {
const all = Array.isArray(this.data && this.data.image_list) ? this.data.image_list : [];
return this._sortImages(all).filter(im => this._imageMatchesFilter(im));
}
// Re-render just the images region in place (on a filter change), leaving
// the donut/cards above untouched.
_rerenderImages() {
const region = this.root()?.querySelector('#sys-images-region');
if (!region) { this._render(); return; }
const images = Array.isArray(this.data && this.data.image_list) ? this.data.image_list : [];
region.innerHTML = this._renderImages(images);
this._updateImageSelectionUI();
}
_renderImages(images) { _renderImages(images) {
const fmt = window.SystemFmt; const fmt = window.SystemFmt;
const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`; const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
@ -418,24 +467,49 @@ class SystemStoragePage {
return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`; return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`;
} }
// Clear All / Select all live in the section head (where the count used // Filter chips with a live count each; "all" leads with in-use images.
// to be), so the list itself is just the rows in a dark container. const inUse = images.reduce((n, im) => n + (this._imageInUse(im) ? 1 : 0), 0);
const head = ` const counts = { all: images.length, inuse: inUse, unused: images.length - inUse };
<div class="sys-section-head sys-images-head"> const chips = [
<h2>Images</h2> { key: 'all', label: 'All' },
<div class="sys-images-toolbar"> { key: 'inuse', label: 'In use' },
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images"> { key: 'unused', label: 'Unused' },
].map(c => {
const on = this._imageFilter === c.key;
return `<button type="button" class="sys-img-filter-btn${on ? ' is-active' : ''}" data-image-filter="${c.key}" aria-pressed="${on}">${c.label}<span class="sys-img-filter-n">${counts[c.key]}</span></button>`;
}).join('');
const visible = this._sortImages(images).filter(im => this._imageMatchesFilter(im));
// Clear All / Select all act on the visible rows, so they only make
// sense when the active filter actually has some.
const actions = visible.length ? `
<div class="sys-images-actions">
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove the images shown below">
${trash}<span class="clear-btn-label">Clear All</span> ${trash}<span class="clear-btn-label">Clear All</span>
</button> </button>
<label class="task-select-all" title="Select all images"> <label class="task-select-all" title="Select all images shown">
<input type="checkbox" data-images-select-all id="sys-images-select-all"> <input type="checkbox" data-images-select-all id="sys-images-select-all">
<span class="task-select-box" aria-hidden="true"></span> <span class="task-select-box" aria-hidden="true"></span>
<span class="task-select-all-label">Select all</span> <span class="task-select-all-label">Select all</span>
</label> </label>
</div>` : '';
const head = `
<div class="sys-section-head sys-images-head">
<h2>Images</h2>
<div class="sys-images-toolbar">
<div class="sys-img-filter" role="group" aria-label="Filter images">${chips}</div>
${actions}
</div> </div>
</div>`; </div>`;
const rows = images.map(im => { if (!visible.length) {
const what = this._imageFilter === 'inuse' ? 'in-use' : 'unused';
return `${head}<div class="sys-storage-app-empty">No ${what} images.</div>`;
}
const rows = visible.map(im => {
const id = fmt.escape(im.id || ''); const id = fmt.escape(im.id || '');
const pill = this._imagePill(im); const pill = this._imagePill(im);
const selected = this._selectedImageIds.has(im.id); const selected = this._selectedImageIds.has(im.id);