From e5cbfba41723a2bf22f42d054ba673533ef93bc9 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 20:59:13 +0100 Subject: [PATCH] ux(system): make per-app usage the Storage page headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Storage page now leads with on-disk usage by app: the headline donut is split by app (each a coloured slice, total app data in the centre), and the "Storage by app" table is its legend — swatch + bar colours match the slices, rows expand to the per-folder breakdown. Docker's engine figures (images + build cache) drop to a secondary section below. This is the integration that was asked for; the donut is your data, not Docker's overhead. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/libreportal/frontend/css/admin.css | 5 + .../components/admin/system-storage-page.js | 201 ++++++++---------- 2 files changed, 97 insertions(+), 109 deletions(-) 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 = ` -
    - ${segments.map(s => ` -
  • - - ${s.label} - ${fmt.bytes(s.data.size || 0)} - ${s.data.reclaimable ? `${fmt.bytes(s.data.reclaimable)} reclaimable` : ''} -
  • `).join('')} -
`; - - const headline = ` -
-
- ${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
-
- - - - ${topImages.map(im => ` - - - - - - - `).join('')} - -
TagSizeSharedContainersCreated
${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) : '—'}
-
` : ''; - - // 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
+
${hasExternal ? '' : ''} - ${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 ` - + - + ${hasExternal ? `` : ''} ${mounts.length ? ` @@ -312,19 +256,58 @@ class SystemStoragePage { }).join('')}
    AppSizeExternal
    ${mounts.length ? `` : ''}${fmt.escape(a.app)}${mounts.length ? `` : ''}${fmt.escape(a.app)} ${fmt.bytes(a.bytes || 0)}${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}
    -
    ` - : `
    ${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
    +
    + + + + ${topImages.map(im => ` + + + + + + + `).join('')} + +
    TagSizeSharedContainersCreated
    ${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) : '—'}
    +
    ` : ''; + 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}`; } }