ux(system): drop the Volumes category from the Storage view

LibrePortal apps keep data in bind mounts, so Docker named-volume
accounting is always ~empty and just reads as a confusing "0 B". Now that
per-app on-disk usage covers the real "what's filling my disk" question,
remove volumes end to end: the donut slice, category card, "Largest
volumes" table and the System-page count, plus the backend's volume
summation and top_volumes payload. Reclaim copy no longer references
volumes (it reassures about app data instead).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 20:26:13 +01:00
parent 10b814cd93
commit 17fe4d6ed5
3 changed files with 26 additions and 62 deletions

View File

@ -22,8 +22,11 @@
// //
// GET /api/system/storage // GET /api/system/storage
// `docker system df` — total + reclaimable per category (images, // `docker system df` — total + reclaimable per category (images,
// containers, volumes, build cache). Cached for STORAGE_TTL_MS because // containers, build cache). Cached for STORAGE_TTL_MS because this is one
// this is one of the more expensive calls on a busy daemon. // of the more expensive calls on a busy daemon. Named volumes are omitted:
// LibrePortal apps keep data in bind mounts, so volume accounting is always
// ~empty here — per-app on-disk usage is generated separately (see
// webuiSystemAppStorage / /data/system/app_storage.json).
// //
// Mounted at /api/system in routes.js (so paths are /api/system/containers // Mounted at /api/system in routes.js (so paths are /api/system/containers
// etc.). Uses the shared docker util (utils/docker.js) which talks to the // etc.). Uses the shared docker util (utils/docker.js) which talks to the
@ -336,9 +339,9 @@ router.get('/storage', async (req, res) => {
// Roll the verbose response up into headline numbers per category. // Roll the verbose response up into headline numbers per category.
// "Reclaimable" across this page reflects exactly what the Reclaim // "Reclaimable" across this page reflects exactly what the Reclaim
// button frees: dangling images + the whole build cache. Tagged-but- // button frees: dangling images + the whole build cache. Tagged-but-
// unused images, stopped containers and volumes are deliberately NOT // unused images and stopped containers are deliberately NOT counted —
// counted — the safe prune leaves them alone — so the headline number // the safe prune leaves them alone — so the headline number matches the
// matches the button's effect instead of overstating it. // button's effect instead of overstating it.
const isDangling = (im) => { const isDangling = (im) => {
const tags = im.RepoTags || []; const tags = im.RepoTags || [];
return tags.length === 0 || tags.every(t => t.includes('<none>')); return tags.length === 0 || tags.every(t => t.includes('<none>'));
@ -361,14 +364,6 @@ router.get('/storage', async (req, res) => {
}, },
{ count: 0, size: 0, reclaimable: 0 } { count: 0, size: 0, reclaimable: 0 }
); );
const sumVolumes = (df.Volumes || []).reduce(
(a, v) => {
a.count++;
a.size += (v.UsageData && v.UsageData.Size) || 0;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
const sumBuild = (df.BuildCache || []).reduce( const sumBuild = (df.BuildCache || []).reduce(
(a, b) => { (a, b) => {
a.count++; a.count++;
@ -392,29 +387,15 @@ router.get('/storage', async (req, res) => {
containers: im.Containers || 0, containers: im.Containers || 0,
created: im.Created, created: im.Created,
})); }));
// Largest volumes — reclaimable or otherwise. Useful when /docker const total = sumImages.size + sumContainers.size + sumBuild.size;
// is hitting 90%+. const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumBuild.reclaimable;
const topVolumes = (df.Volumes || [])
.slice()
.sort((a, b) => ((b.UsageData?.Size) || 0) - ((a.UsageData?.Size) || 0))
.slice(0, 10)
.map(v => ({
name: v.Name,
driver: v.Driver,
size: (v.UsageData && v.UsageData.Size) || 0,
ref_count: (v.UsageData && v.UsageData.RefCount) || 0,
}));
const total = sumImages.size + sumContainers.size + sumVolumes.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumVolumes.reclaimable + sumBuild.reclaimable;
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.json({ res.json({
total, reclaimable, total, reclaimable,
images: sumImages, images: sumImages,
containers: sumContainers, containers: sumContainers,
volumes: sumVolumes,
build_cache: sumBuild, build_cache: sumBuild,
top_images: topImages, top_images: topImages,
top_volumes: topVolumes,
updated: new Date().toISOString(), updated: new Date().toISOString(),
}); });
} catch (err) { } catch (err) {

View File

@ -335,7 +335,7 @@ class AdminSystem {
const head = ` const head = `
<div class="sys-section-head"> <div class="sys-section-head">
<h2>Storage</h2> <h2>Storage</h2>
<span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images · ${dk.volumes ?? 0} volumes</span> <span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images</span>
</div>`; </div>`;
if (!s || !s.total || !SP) { if (!s || !s.total || !SP) {
const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.'; const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.';
@ -368,7 +368,7 @@ class AdminSystem {
<div class="sys-storage-srows">${rows}</div> <div class="sys-storage-srows">${rows}</div>
<div class="sys-storage-summary-foot"> <div class="sys-storage-summary-foot">
<span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span> <span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span>
<button type="button" class="sys-storage-more" data-sys-storage>View largest images &amp; volumes </button> <button type="button" class="sys-storage-more" data-sys-storage>View storage breakdown </button>
</div> </div>
</div> </div>
</div>`; </div>`;

View File

@ -1,16 +1,18 @@
// Admin → System → Storage — Docker disk-usage breakdown. // Admin → System → Storage — where the disk is going.
// //
// Mounted at /admin/config/system/storage. Visualises `docker system df`: // Mounted at /admin/config/system/storage. Two data sources:
// //
// - Headline total + reclaimable // - Storage by app — on-disk size of each app's bind-mounted data, from the
// - Donut chart split by category (images / containers / volumes / build cache) // generated /data/system/app_storage.json (du, not docker — see the
// - Per-category cards (count + size + reclaimable) // generator). The headline view for LibrePortal, whose data lives in bind
// - Top 10 images by size // mounts, not named volumes.
// - Top 10 volumes by size // - Docker engine `docker system df` — headline total + reclaimable, a
// per-category donut (images / containers / build cache), per-category cards,
// and the top images by size.
// //
// One backend call (GET /api/system/storage), cached server-side for 5s. // One backend call (GET /api/system/storage), cached server-side for 5s.
// A "Reclaim space" button runs the safe prune (build cache + dangling // A "Reclaim space" button runs the safe prune (build cache + dangling
// images, never volumes) via the system_reclaim task, then re-reads usage. // images, never app data) via the system_reclaim task, then re-reads usage.
class SystemStoragePage { class SystemStoragePage {
constructor(rootId = 'config-section') { constructor(rootId = 'config-section') {
@ -81,7 +83,7 @@ class SystemStoragePage {
if (window.showConfirmation) { if (window.showConfirmation) {
window.showConfirmation( window.showConfirmation(
'Reclaim space', 'Reclaim space',
'Clear the build cache and dangling images? Volumes and images in use are left untouched.', 'Clear the build cache and dangling images? Images in use and your app data are left untouched.',
run, run,
'Reclaim space', 'Reclaim space',
'Cancel', 'Cancel',
@ -115,9 +117,9 @@ class SystemStoragePage {
<a href="/admin/config/system" data-back>Admin · System</a> <a href="/admin/config/system" data-back>Admin · System</a>
</div> </div>
<h1>Storage</h1> <h1>Storage</h1>
<p class="sys-storage-sub">Docker disk usage images, containers, volumes, and build cache.</p> <p class="sys-storage-sub">On-disk space by app, plus Docker's images, containers, and build cache.</p>
</div> </div>
<button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (volumes and images in use are kept)"> <button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (images in use and app data are kept)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reclaim space Reclaim space
</button> </button>
@ -131,7 +133,6 @@ class SystemStoragePage {
static segmentsFrom(d) { static segmentsFrom(d) {
return [ return [
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} }, { key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
{ key: 'volumes', label: 'Volumes', color: 'status-success', data: (d && d.volumes) || {} },
{ key: 'containers', label: 'Containers', color: 'status-info', data: (d && d.containers) || {} }, { key: 'containers', label: 'Containers', color: 'status-info', data: (d && d.containers) || {} },
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} }, { key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
]; ];
@ -242,7 +243,6 @@ class SystemStoragePage {
</div>`; </div>`;
const topImages = Array.isArray(d.top_images) ? d.top_images : []; const topImages = Array.isArray(d.top_images) ? d.top_images : [];
const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : [];
const imagesTable = topImages.length ? ` const imagesTable = topImages.length ? `
<div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div> <div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div>
@ -262,23 +262,6 @@ class SystemStoragePage {
</table> </table>
</div>` : ''; </div>` : '';
const volumesTable = topVolumes.length ? `
<div class="sys-section-head"><h2>Largest volumes</h2><span class="sys-chart-meta">top ${topVolumes.length} by size</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Name</th><th>Driver</th><th>Size</th><th>Refs</th></tr></thead>
<tbody>
${topVolumes.map(v => `
<tr>
<td class="sys-app-name">${fmt.escape(v.name)}</td>
<td>${fmt.escape(v.driver || '—')}</td>
<td>${fmt.bytes(v.size)}</td>
<td>${v.ref_count}${v.ref_count === 0 ? ' <span class="sys-storage-orphan">orphan</span>' : ''}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '';
// Storage by app — the number Docker can't give us: the on-disk size of // Storage by app — the number Docker can't give us: the on-disk size of
// each app's bind-mounted data, measured by the generator. This is the // each app's bind-mounted data, measured by the generator. This is the
// useful "what's eating my disk" view for LibrePortal, where app data // useful "what's eating my disk" view for LibrePortal, where app data
@ -315,7 +298,7 @@ class SystemStoragePage {
</div> </div>
${appBody}`; ${appBody}`;
body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}${volumesTable}`; body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}`;
} }
} }