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:
parent
327fda8cd9
commit
e5cbfba417
@ -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 {
|
||||
|
||||
@ -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 & dangling images</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const catCards = `
|
||||
<div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">images & 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user