diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css
index bf2fffb..acac836 100644
--- a/containers/libreportal/frontend/css/admin.css
+++ b/containers/libreportal/frontend/css/admin.css
@@ -1133,6 +1133,11 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
font-size: 0.85rem;
}
/* Expandable per-app folder breakdown. */
+.sys-app-name .sys-storage-swatch {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 8px;
+}
.sys-storage-app-row.is-clickable { cursor: pointer; }
.sys-storage-caret,
.sys-storage-caret-spacer {
diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js
index e9e129d..f3a4e88 100644
--- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js
+++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js
@@ -153,7 +153,7 @@ class SystemStoragePage {
// Hand-rolled donut, full circle = total. Each slice's dashoffset is the
// running cumulative fraction (off), so a slice starts where the previous
// one ended and the ring fills proportionally.
- static donutSvg(segments, total) {
+ static donutSvg(segments, total, sub = 'total in use') {
const fmt = window.SystemFmt;
const denom = total || 1;
const r0 = 90, stroke = 28, C = 2 * Math.PI * r0;
@@ -178,122 +178,66 @@ class SystemStoragePage {
).join('')}
${fmt.bytes(total || 0)}
- total in use
+ ${fmt.escape(sub)}
`;
}
+ // Distinct slice colours for the per-app donut, cycled if there are more
+ // apps than colours.
+ static get PALETTE() { return ['accent', 'status-info', 'status-success', 'status-warning', 'status-danger']; }
+
_render() {
const r = this.root();
if (!r) return;
const body = r.querySelector('[data-storage-body]');
if (!body) return;
- const d = this.data;
- if (!d) {
- body.innerHTML = `
Couldn't read disk usage from the Docker daemon.
`;
+ const fmt = window.SystemFmt;
+ const d = this.data; // Docker `system df` (secondary)
+ const as = this.appStorage; // per-app on-disk usage (primary)
+ const appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
+ if (!d && !appRows.length) {
+ body.innerHTML = `Couldn't read storage usage.
`;
return;
}
- const fmt = window.SystemFmt;
- const segments = SystemStoragePage.segmentsFrom(d);
- const total = d.total || 0;
- const recl = d.reclaimable || 0;
- const reclPct = total ? (recl / total) * 100 : 0;
- const donut = SystemStoragePage.donutSvg(segments, total);
+ const palette = SystemStoragePage.PALETTE;
+ const colorFor = i => palette[i % palette.length];
- const legend = `
-
-
- ${donut}
- ${legend}
-
-
-
-
Total in use
-
${fmt.bytes(total)}
+ // ---- Headline: on-disk usage by app (the disk question that matters) ----
+ const appTotal = (as && as.total) || 0;
+ const appSegs = appRows.map((a, i) => ({ color: colorFor(i), data: { size: a.bytes || 0 } }));
+ const headline = appRows.length
+ ? `
+
${SystemStoragePage.donutSvg(appSegs, appTotal, 'app data')}
+
+
+ App data on disk
+ ${fmt.bytes(appTotal)}
+ ${appRows.length} app${appRows.length === 1 ? '' : 's'}${as && as.total_external ? ` · ${fmt.bytes(as.total_external)} on external drives` : ''}
+
+ ${d ? `
+ Docker engine
+ ${fmt.bytes(d.total || 0)}
+ ${fmt.bytes(d.reclaimable || 0)} reclaimable
+
` : ''}
-
- Reclaimable
- ${fmt.bytes(recl)}
- ${reclPct.toFixed(0)}% of total · build cache & dangling images
-
-
-
`;
+
`
+ : `
${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}
`;
- const catCards = `
-
Docker engine
images & build cache — the daemon's own usage, separate from your app data
-
- ${segments.map(s => {
- const v = s.data || {};
- const pct = v.size && total ? (v.size / total) * 100 : 0;
- const reclPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
- return `
-
-
-
${s.label}
- ${v.count ?? 0}
-
-
${fmt.bytes(v.size || 0)}
-
-
- ${pct.toFixed(1)}% of total
- ${v.reclaimable
- ? `${fmt.bytes(v.reclaimable)} reclaimable (${reclPct.toFixed(0)}%)`
- : ''}
-
-
`;
- }).join('')}
-
`;
-
- const topImages = Array.isArray(d.top_images) ? d.top_images : [];
-
- const imagesTable = topImages.length ? `
-
Largest images
top ${topImages.length} by size
-
-
- | Tag | Size | Shared | Containers | Created |
-
- ${topImages.map(im => `
-
- | ${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')} |
- ${fmt.bytes(im.size)} |
- ${fmt.bytes(im.shared_size || 0)} |
- ${im.containers}${im.containers === 0 ? ' unused' : ''} |
- ${im.created ? fmt.timeAgo(im.created) : '—'} |
-
`).join('')}
-
-
-
` : '';
-
- // 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
- // lives in bind mounts rather than named volumes.
- const as = this.appStorage;
- const appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
+ // ---- Storage by app — the donut's legend + per-folder drill-down.
+ // Swatch + bar colours match each app's donut slice.
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
const hasExternal = appRows.some(a => a.external_bytes > 0);
const cols = hasExternal ? 4 : 3;
- const appBody = appRows.length
- ? `
+ const appsSection = appRows.length ? `
+
Storage by app
on-disk data per app — click an app to see its folders
+
| App | Size | | ${hasExternal ? 'External | ' : ''}
- ${appRows.map(a => {
+ ${appRows.map((a, i) => {
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
const open = this._expanded.has(a.app);
- // Each app row expands into its folders (the bind mounts
- // it stores data in), labelled by the in-container path.
const folders = mounts.map(m => `
${fmt.escape(m.path || m.source || '—')}
@@ -301,9 +245,9 @@ class SystemStoragePage {
${fmt.bytes(m.bytes || 0)}
`).join('');
return `
- | ${mounts.length ? `▸` : ''}${fmt.escape(a.app)} |
+ ${mounts.length ? `▸` : ''}${fmt.escape(a.app)} |
${fmt.bytes(a.bytes || 0)} |
- |
+ |
${hasExternal ? `${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'} | ` : ''}
${mounts.length ? `
@@ -312,19 +256,58 @@ class SystemStoragePage {
}).join('')}
-
`
- : `
${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}
`;
- const metaBits = [];
- if (as && as.total) metaBits.push(`${fmt.bytes(as.total)} on disk`);
- if (as && as.total_external) metaBits.push(`${fmt.bytes(as.total_external)} on external drives`);
- const appsSection = `
-
-
Storage by app
- ${metaBits.length ? `${metaBits.join(' · ')}` : ''}
-
- ${appBody}`;
+
` : '';
- body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}`;
+ // ---- Docker engine (secondary): images + build cache the daemon keeps,
+ // plus the largest images. The cleanup view, clearly separate from data.
+ let dockerSection = '';
+ if (d) {
+ const segments = SystemStoragePage.segmentsFrom(d);
+ const total = d.total || 0;
+ const recl = d.reclaimable || 0;
+ const cards = segments.map(s => {
+ const v = s.data || {};
+ const pct = v.size && total ? (v.size / total) * 100 : 0;
+ const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
+ return `
+
+
+
${s.label}
+ ${v.count ?? 0}
+
+
${fmt.bytes(v.size || 0)}
+
+
+ ${pct.toFixed(1)}% of engine
+ ${v.reclaimable ? `${fmt.bytes(v.reclaimable)} reclaimable (${rPct.toFixed(0)}%)` : ''}
+
+
`;
+ }).join('');
+ const topImages = Array.isArray(d.top_images) ? d.top_images : [];
+ const imagesTable = topImages.length ? `
+
Largest images
top ${topImages.length} by size
+
+
+ | Tag | Size | Shared | Containers | Created |
+
+ ${topImages.map(im => `
+
+ | ${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')} |
+ ${fmt.bytes(im.size)} |
+ ${fmt.bytes(im.shared_size || 0)} |
+ ${im.containers}${im.containers === 0 ? ' unused' : ''} |
+ ${im.created ? fmt.timeAgo(im.created) : '—'} |
+
`).join('')}
+
+
+
` : '';
+ dockerSection = `
+
Docker engine
${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable — the daemon's own usage, separate from your app data
+
${cards}
+ ${imagesTable}`;
+ }
+
+ body.innerHTML = `${headline}${appsSection}${dockerSection}`;
}
}