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
// `docker system df` — total + reclaimable per category (images,
// containers, volumes, build cache). Cached for STORAGE_TTL_MS because
// this is one of the more expensive calls on a busy daemon.
// containers, build cache). Cached for STORAGE_TTL_MS because this is one
// 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
// 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.
// "Reclaimable" across this page reflects exactly what the Reclaim
// button frees: dangling images + the whole build cache. Tagged-but-
// unused images, stopped containers and volumes are deliberately NOT
// counted — the safe prune leaves them alone — so the headline number
// matches the button's effect instead of overstating it.
// unused images and stopped containers are deliberately NOT counted —
// the safe prune leaves them alone — so the headline number matches the
// button's effect instead of overstating it.
const isDangling = (im) => {
const tags = im.RepoTags || [];
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 }
);
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(
(a, b) => {
a.count++;
@ -392,29 +387,15 @@ router.get('/storage', async (req, res) => {
containers: im.Containers || 0,
created: im.Created,
}));
// Largest volumes — reclaimable or otherwise. Useful when /docker
// is hitting 90%+.
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;
const total = sumImages.size + sumContainers.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumBuild.reclaimable;
res.set('Cache-Control', 'no-store');
res.json({
total, reclaimable,
images: sumImages,
containers: sumContainers,
volumes: sumVolumes,
build_cache: sumBuild,
top_images: topImages,
top_volumes: topVolumes,
updated: new Date().toISOString(),
});
} catch (err) {

View File

@ -335,7 +335,7 @@ class AdminSystem {
const head = `
<div class="sys-section-head">
<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>`;
if (!s || !s.total || !SP) {
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-summary-foot">
<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>`;

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
// - Donut chart split by category (images / containers / volumes / build cache)
// - Per-category cards (count + size + reclaimable)
// - Top 10 images by size
// - Top 10 volumes by size
// - Storage by app — on-disk size of each app's bind-mounted data, from the
// generated /data/system/app_storage.json (du, not docker — see the
// generator). The headline view for LibrePortal, whose data lives in bind
// mounts, not named volumes.
// - 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.
// 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 {
constructor(rootId = 'config-section') {
@ -81,7 +83,7 @@ class SystemStoragePage {
if (window.showConfirmation) {
window.showConfirmation(
'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,
'Reclaim space',
'Cancel',
@ -115,9 +117,9 @@ class SystemStoragePage {
<a href="/admin/config/system" data-back>Admin · System</a>
</div>
<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>
<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>
Reclaim space
</button>
@ -131,7 +133,6 @@ class SystemStoragePage {
static segmentsFrom(d) {
return [
{ 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: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
];
@ -242,7 +243,6 @@ class SystemStoragePage {
</div>`;
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : [];
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>
@ -262,23 +262,6 @@ class SystemStoragePage {
</table>
</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
// 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
@ -315,7 +298,7 @@ class SystemStoragePage {
</div>
${appBody}`;
body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}${volumesTable}`;
body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}`;
}
}