ux(system): drop container writable-layer from the Storage view

Like named volumes, a container's writable layer is a near-zero scratch
number for LibrePortal (app data lives in bind mounts, shown per-app), so
sitting it next to per-app storage just confused things. Remove the
"Containers" slice/card and its backend summation, and reframe the Docker
breakdown as "Docker engine" overhead (images + build cache) — clearly
separate from your app data.

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:51:06 +01:00
parent a31d77b751
commit 5f91d2717e
2 changed files with 21 additions and 28 deletions

View File

@ -21,12 +21,12 @@
// Last N lines of combined stdout/stderr, multiplex-decoded.
//
// GET /api/system/storage
// `docker system df` — total + reclaimable per category (images,
// 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).
// `docker system df` — total + reclaimable for the engine overhead worth
// acting on (images, build cache). Cached for STORAGE_TTL_MS because this is
// one of the more expensive calls on a busy daemon. Named volumes and
// container writable layers are omitted: LibrePortal apps keep data in bind
// mounts, so both read ~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,12 +336,15 @@ router.get('/storage', async (req, res) => {
try {
const df = await storageCache.get('df', () => dockerRequest('GET', '/system/df'));
if (!df) return res.status(500).json({ error: 'no_data' });
// 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 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.
// Roll the verbose response up into headline numbers per category. We
// surface only the engine overhead worth acting on — images and build
// cache — and skip container writable layers: for LibrePortal that's a
// near-zero scratch number (app data lives in bind mounts, shown per-app
// elsewhere) that just confuses the picture.
// "Reclaimable" reflects exactly what the Reclaim button frees: dangling
// images + the whole build cache. Tagged-but-unused images are
// deliberately NOT counted — the safe prune leaves them alone — so the
// headline 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>'));
@ -356,14 +359,6 @@ router.get('/storage', async (req, res) => {
},
{ count: 0, size: 0, shared: 0, reclaimable: 0 }
);
const sumContainers = (df.Containers || []).reduce(
(a, c) => {
a.count++;
a.size += c.SizeRw || 0;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
const sumBuild = (df.BuildCache || []).reduce(
(a, b) => {
a.count++;
@ -387,13 +382,12 @@ router.get('/storage', async (req, res) => {
containers: im.Containers || 0,
created: im.Created,
}));
const total = sumImages.size + sumContainers.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumBuild.reclaimable;
const total = sumImages.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumBuild.reclaimable;
res.set('Cache-Control', 'no-store');
res.json({
total, reclaimable,
images: sumImages,
containers: sumContainers,
build_cache: sumBuild,
top_images: topImages,
updated: new Date().toISOString(),

View File

@ -7,8 +7,8 @@
// 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.
// donut (images / build cache), per-category cards, and the top images by
// size. The daemon's own overhead, separate from the per-app data.
//
// One backend call (GET /api/system/storage), cached server-side for 5s.
// A "Reclaim space" button runs the safe prune (build cache + dangling
@ -130,7 +130,7 @@ class SystemStoragePage {
<a href="/admin/config/system" data-back>Admin · System</a>
</div>
<h1>Storage</h1>
<p class="sys-storage-sub">On-disk space by app, plus Docker's images, containers, and build cache.</p>
<p class="sys-storage-sub">On-disk space by app, plus Docker's own engine usage.</p>
</div>
<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>
@ -146,7 +146,6 @@ class SystemStoragePage {
static segmentsFrom(d) {
return [
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
{ 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) || {} },
];
}
@ -231,7 +230,7 @@ class SystemStoragePage {
</div>`;
const catCards = `
<div class="sys-section-head"><h2>Categories</h2></div>
<div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">images &amp; build cache the daemon's own usage, separate from your app data</span></div>
<div class="sys-storage-cards">
${segments.map(s => {
const v = s.data || {};