ux(system): make per-app usage the Storage page headline

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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 20:59:13 +01:00
parent 327fda8cd9
commit e5cbfba417
2 changed files with 97 additions and 109 deletions

View File

@ -1133,6 +1133,11 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Expandable per-app folder breakdown. */ /* 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-app-row.is-clickable { cursor: pointer; }
.sys-storage-caret, .sys-storage-caret,
.sys-storage-caret-spacer { .sys-storage-caret-spacer {

View File

@ -153,7 +153,7 @@ class SystemStoragePage {
// Hand-rolled donut, full circle = total. Each slice's dashoffset is the // Hand-rolled donut, full circle = total. Each slice's dashoffset is the
// running cumulative fraction (off), so a slice starts where the previous // running cumulative fraction (off), so a slice starts where the previous
// one ended and the ring fills proportionally. // one ended and the ring fills proportionally.
static donutSvg(segments, total) { static donutSvg(segments, total, sub = 'total in use') {
const fmt = window.SystemFmt; const fmt = window.SystemFmt;
const denom = total || 1; const denom = total || 1;
const r0 = 90, stroke = 28, C = 2 * Math.PI * r0; const r0 = 90, stroke = 28, C = 2 * Math.PI * r0;
@ -178,122 +178,66 @@ class SystemStoragePage {
).join('')} ).join('')}
</g> </g>
<text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total || 0)}</text> <text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total || 0)}</text>
<text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">total in use</text> <text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">${fmt.escape(sub)}</text>
</svg>`; </svg>`;
} }
// 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() { _render() {
const r = this.root(); const r = this.root();
if (!r) return; if (!r) return;
const body = r.querySelector('[data-storage-body]'); const body = r.querySelector('[data-storage-body]');
if (!body) return; if (!body) return;
const d = this.data; const fmt = window.SystemFmt;
if (!d) { const d = this.data; // Docker `system df` (secondary)
body.innerHTML = `<div class="sys-storage-err">Couldn't read disk usage from the Docker daemon.</div>`; 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 = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
return; return;
} }
const fmt = window.SystemFmt; const palette = SystemStoragePage.PALETTE;
const segments = SystemStoragePage.segmentsFrom(d); const colorFor = i => palette[i % palette.length];
const total = d.total || 0;
const recl = d.reclaimable || 0;
const reclPct = total ? (recl / total) * 100 : 0;
const donut = SystemStoragePage.donutSvg(segments, total);
const legend = ` // ---- Headline: on-disk usage by app (the disk question that matters) ----
<ul class="sys-storage-legend"> const appTotal = (as && as.total) || 0;
${segments.map(s => ` const appSegs = appRows.map((a, i) => ({ color: colorFor(i), data: { size: a.bytes || 0 } }));
<li> const headline = appRows.length
<span class="sys-storage-swatch" style="background: var(--${s.color})"></span> ? `<div class="sys-storage-headline">
<span class="sys-storage-leg-k">${s.label}</span> <div class="sys-storage-head-card">${SystemStoragePage.donutSvg(appSegs, appTotal, 'app data')}</div>
<span class="sys-storage-leg-v">${fmt.bytes(s.data.size || 0)}</span> <div class="sys-storage-head-stats">
${s.data.reclaimable ? `<span class="sys-storage-leg-r">${fmt.bytes(s.data.reclaimable)} reclaimable</span>` : ''} <div class="sys-storage-stat">
</li>`).join('')} <span class="sys-storage-stat-k">App data on disk</span>
</ul>`; <strong class="sys-storage-stat-v">${fmt.bytes(appTotal)}</strong>
<span class="sys-storage-stat-sub">${appRows.length} app${appRows.length === 1 ? '' : 's'}${as && as.total_external ? ` · ${fmt.bytes(as.total_external)} on external drives` : ''}</span>
const headline = ` </div>
<div class="sys-storage-headline"> ${d ? `<div class="sys-storage-stat">
<div class="sys-storage-head-card"> <span class="sys-storage-stat-k">Docker engine</span>
${donut} <strong class="sys-storage-stat-v">${fmt.bytes(d.total || 0)}</strong>
${legend} <span class="sys-storage-stat-sub">${fmt.bytes(d.reclaimable || 0)} reclaimable</span>
</div> </div>` : ''}
<div class="sys-storage-head-stats">
<div class="sys-storage-stat">
<span class="sys-storage-stat-k">Total in use</span>
<strong class="sys-storage-stat-v">${fmt.bytes(total)}</strong>
</div> </div>
<div class="sys-storage-stat sys-storage-stat-recl"> </div>`
<span class="sys-storage-stat-k">Reclaimable</span> : `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
<strong class="sys-storage-stat-v">${fmt.bytes(recl)}</strong>
<span class="sys-storage-stat-sub">${reclPct.toFixed(0)}% of total · build cache &amp; dangling images</span>
</div>
</div>
</div>`;
const catCards = ` // ---- Storage by app — the donut's legend + per-folder drill-down.
<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> // Swatch + bar colours match each app's donut slice.
<div class="sys-storage-cards">
${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 `
<div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
<div class="sys-storage-card-head">
<h3>${s.label}</h3>
<span class="sys-storage-card-count">${v.count ?? 0}</span>
</div>
<div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
<div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
<div class="sys-storage-card-meta">
<span>${pct.toFixed(1)}% of total</span>
${v.reclaimable
? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${reclPct.toFixed(0)}%)</span>`
: ''}
</div>
</div>`;
}).join('')}
</div>`;
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
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-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Tag</th><th>Size</th><th>Shared</th><th>Containers</th><th>Created</th></tr></thead>
<tbody>
${topImages.map(im => `
<tr>
<td class="sys-app-name">${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}</td>
<td>${fmt.bytes(im.size)}</td>
<td>${fmt.bytes(im.shared_size || 0)}</td>
<td>${im.containers}${im.containers === 0 ? ' <span class="sys-storage-orphan">unused</span>' : ''}</td>
<td>${im.created ? fmt.timeAgo(im.created) : '—'}</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
// lives in bind mounts rather than named volumes.
const as = this.appStorage;
const appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0); const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
const hasExternal = appRows.some(a => a.external_bytes > 0); const hasExternal = appRows.some(a => a.external_bytes > 0);
const cols = hasExternal ? 4 : 3; const cols = hasExternal ? 4 : 3;
const appBody = appRows.length const appsSection = appRows.length ? `
? `<div class="sys-apps-wrap"> <div class="sys-section-head"><h2>Storage by app</h2><span class="sys-chart-meta">on-disk data per app click an app to see its folders</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps"> <table class="sys-apps">
<thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead> <thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead>
<tbody> <tbody>
${appRows.map(a => { ${appRows.map((a, i) => {
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0; const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
const mounts = Array.isArray(a.mounts) ? a.mounts : []; const mounts = Array.isArray(a.mounts) ? a.mounts : [];
const open = this._expanded.has(a.app); 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 => ` const folders = mounts.map(m => `
<li> <li>
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span> <span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
@ -301,9 +245,9 @@ class SystemStoragePage {
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span> <span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
</li>`).join(''); </li>`).join('');
return `<tr class="sys-storage-app-row${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}> return `<tr class="sys-storage-app-row${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
<td class="sys-app-name">${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : '<span class="sys-storage-caret-spacer"></span>'}${fmt.escape(a.app)}</td> <td class="sys-app-name"><span class="sys-storage-swatch" style="background: var(--${colorFor(i)})"></span>${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}"></span>` : ''}${fmt.escape(a.app)}</td>
<td>${fmt.bytes(a.bytes || 0)}</td> <td>${fmt.bytes(a.bytes || 0)}</td>
<td class="sys-storage-app-barcol"><span class="sys-storage-app-bar"><span style="width:${pct.toFixed(1)}%"></span></span></td> <td class="sys-storage-app-barcol"><span class="sys-storage-app-bar"><span style="width:${pct.toFixed(1)}%; background: var(--${colorFor(i)})"></span></span></td>
${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''} ${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''}
</tr>${mounts.length ? ` </tr>${mounts.length ? `
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}"> <tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
@ -312,19 +256,58 @@ class SystemStoragePage {
}).join('')} }).join('')}
</tbody> </tbody>
</table> </table>
</div>` </div>` : '';
: `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
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 = `
<div class="sys-section-head">
<h2>Storage by app</h2>
${metaBits.length ? `<span class="sys-chart-meta">${metaBits.join(' · ')}</span>` : ''}
</div>
${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 `
<div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
<div class="sys-storage-card-head">
<h3>${s.label}</h3>
<span class="sys-storage-card-count">${v.count ?? 0}</span>
</div>
<div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
<div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
<div class="sys-storage-card-meta">
<span>${pct.toFixed(1)}% of engine</span>
${v.reclaimable ? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${rPct.toFixed(0)}%)</span>` : ''}
</div>
</div>`;
}).join('');
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
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-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Tag</th><th>Size</th><th>Shared</th><th>Containers</th><th>Created</th></tr></thead>
<tbody>
${topImages.map(im => `
<tr>
<td class="sys-app-name">${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}</td>
<td>${fmt.bytes(im.size)}</td>
<td>${fmt.bytes(im.shared_size || 0)}</td>
<td>${im.containers}${im.containers === 0 ? ' <span class="sys-storage-orphan">unused</span>' : ''}</td>
<td>${im.created ? fmt.timeAgo(im.created) : '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '';
dockerSection = `
<div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable the daemon's own usage, separate from your app data</span></div>
<div class="sys-storage-cards">${cards}</div>
${imagesTable}`;
}
body.innerHTML = `${headline}${appsSection}${dockerSection}`;
} }
} }