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;
|
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 {
|
||||||
|
|||||||
@ -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 & 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 & 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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user