diff --git a/containers/libreportal/frontend/components/admin/core/css/admin.css b/containers/libreportal/frontend/components/admin/core/css/admin.css index 310f270..bccd41f 100644 --- a/containers/libreportal/frontend/components/admin/core/css/admin.css +++ b/containers/libreportal/frontend/components/admin/core/css/admin.css @@ -84,10 +84,52 @@ .sys-images-toolbar { display: flex; align-items: center; - justify-content: flex-end; - gap: 14px; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px 14px; 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-image-item .task-header { cursor: default; } .sys-img-icon { color: rgba(var(--text-rgb), 0.6); flex-shrink: 0; } diff --git a/containers/libreportal/frontend/components/admin/system/js/system-storage-page.js b/containers/libreportal/frontend/components/admin/system/js/system-storage-page.js index 3b634d2..5671122 100644 --- a/containers/libreportal/frontend/components/admin/system/js/system-storage-page.js +++ b/containers/libreportal/frontend/components/admin/system/js/system-storage-page.js @@ -24,6 +24,7 @@ class SystemStoragePage { this._timer = null; this._expanded = new Set(); // app names whose folder breakdown is open 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._onClick = this._onClick.bind(this); this._onChange = this._onChange.bind(this); @@ -67,12 +68,25 @@ class SystemStoragePage { // Remove a single image. const del = e.target.closest('[data-image-delete]'); 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]'); if (clr) { const ids = this._selectedImageIds.size ? [...this._selectedImageIds] - : ((this.data && this.data.image_list) || []).map(im => im.id); + : this._visibleImages().map(im => im.id); this._deleteImages(ids); return; } @@ -354,7 +368,7 @@ class SystemStoragePage { dockerSection = `

Docker engine

${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable — the daemon's own usage, separate from your app data
${cards}
- ${this._renderImages(images)}`; +
${this._renderImages(images)}
`; } body.innerHTML = `${headline}${appsSection}${dockerSection}`; @@ -410,6 +424,41 @@ class SystemStoragePage { return ``; } + // 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 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) { const fmt = window.SystemFmt; const trash = ``; @@ -418,24 +467,49 @@ class SystemStoragePage { return `

Images

No images on disk.
`; } - // Clear All / Select all live in the section head (where the count used - // to be), so the list itself is just the rows in a dark container. + // Filter chips with a live count each; "all" leads with in-use images. + const inUse = images.reduce((n, im) => n + (this._imageInUse(im) ? 1 : 0), 0); + const counts = { all: images.length, inuse: inUse, unused: images.length - inUse }; + const chips = [ + { key: 'all', label: 'All' }, + { key: 'inuse', label: 'In use' }, + { key: 'unused', label: 'Unused' }, + ].map(c => { + const on = this._imageFilter === c.key; + return ``; + }).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 ? ` +
+ + +
` : ''; + const head = `

Images

- - +
${chips}
+ ${actions}
`; - const rows = images.map(im => { + if (!visible.length) { + const what = this._imageFilter === 'inuse' ? 'in-use' : 'unused'; + return `${head}
No ${what} images.
`; + } + + const rows = visible.map(im => { const id = fmt.escape(im.id || ''); const pill = this._imagePill(im); const selected = this._selectedImageIds.has(im.id);