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;
}
/* 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 {

View File

@ -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,64 +178,97 @@ class SystemStoragePage {
).join('')}
</g>
<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>`;
}
// 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 = `<div class="sys-storage-err">Couldn't read disk usage from the Docker daemon.</div>`;
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 = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
return;
}
const fmt = window.SystemFmt;
const palette = SystemStoragePage.PALETTE;
const colorFor = i => palette[i % palette.length];
// ---- 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
? `<div class="sys-storage-headline">
<div class="sys-storage-head-card">${SystemStoragePage.donutSvg(appSegs, appTotal, 'app data')}</div>
<div class="sys-storage-head-stats">
<div class="sys-storage-stat">
<span class="sys-storage-stat-k">App data on disk</span>
<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>
</div>
${d ? `<div class="sys-storage-stat">
<span class="sys-storage-stat-k">Docker engine</span>
<strong class="sys-storage-stat-v">${fmt.bytes(d.total || 0)}</strong>
<span class="sys-storage-stat-sub">${fmt.bytes(d.reclaimable || 0)} reclaimable</span>
</div>` : ''}
</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>`;
// ---- 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 appsSection = appRows.length ? `
<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">
<thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead>
<tbody>
${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);
const folders = mounts.map(m => `
<li>
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
</li>`).join('');
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"><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 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>` : ''}
</tr>${mounts.length ? `
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
<td colspan="${cols}"><ul class="sys-storage-folders">${folders}</ul></td>
</tr>` : ''}`;
}).join('')}
</tbody>
</table>
</div>` : '';
// ---- 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 reclPct = total ? (recl / total) * 100 : 0;
const donut = SystemStoragePage.donutSvg(segments, total);
const legend = `
<ul class="sys-storage-legend">
${segments.map(s => `
<li>
<span class="sys-storage-swatch" style="background: var(--${s.color})"></span>
<span class="sys-storage-leg-k">${s.label}</span>
<span class="sys-storage-leg-v">${fmt.bytes(s.data.size || 0)}</span>
${s.data.reclaimable ? `<span class="sys-storage-leg-r">${fmt.bytes(s.data.reclaimable)} reclaimable</span>` : ''}
</li>`).join('')}
</ul>`;
const headline = `
<div class="sys-storage-headline">
<div class="sys-storage-head-card">
${donut}
${legend}
</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 class="sys-storage-stat sys-storage-stat-recl">
<span class="sys-storage-stat-k">Reclaimable</span>
<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 = `
<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 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;
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">
@ -245,17 +278,12 @@ class SystemStoragePage {
<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>`
: ''}
<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('')}
</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">
@ -273,58 +301,13 @@ class SystemStoragePage {
</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}`;
}
// 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 hasExternal = appRows.some(a => a.external_bytes > 0);
const cols = hasExternal ? 4 : 3;
const appBody = appRows.length
? `<div class="sys-apps-wrap">
<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>
<tbody>
${appRows.map(a => {
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 => `
<li>
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
</li>`).join('');
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>${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>
${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''}
</tr>${mounts.length ? `
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
<td colspan="${cols}"><ul class="sys-storage-folders">${folders}</ul></td>
</tr>` : ''}`;
}).join('')}
</tbody>
</table>
</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}`;
body.innerHTML = `${headline}${appsSection}${dockerSection}`;
}
}