ux(system): storage breakdown polish — dual disk gauge, app icons, list design

- Disk gauge (System page) gains an inner ring for the LibrePortal slice of
  the disk, so it shows total disk used AND how much of that is us.
- System-page storage summary now shows the full LibrePortal breakdown
  (apps + images + build cache), not just the Docker engine categories.
- Fix the chart colours: Images use the Reclaim orange, build cache the
  deeper red (warm = reclaimable overhead), apps a cool palette.
- Images list: dark container, Clear All / Select all moved into the section
  head (count text dropped), each image shows its app's icon.
- Storage by app restyled to the same Tasks-style list (app icons,
  expandable folders), minus the selection controls.

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 22:19:40 +01:00
parent f6e310998b
commit 3d51eda988
4 changed files with 172 additions and 76 deletions

View File

@ -1197,6 +1197,42 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
background: rgba(var(--status-warning-rgb), 0.18);
color: var(--status-warning);
}
/* Tasks-style list container (Images + Storage by app): a recessed dark panel
behind the rows. */
.sys-tasklist {
margin-top: 10px;
padding: 8px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(var(--text-rgb), 0.08);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* App / image icon inside a task row. */
.sys-task-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
object-fit: contain;
border-radius: 4px;
}
/* Clear All / Select all now live in the section head — no extra top margin. */
.sys-images-head .sys-images-toolbar { margin: 0; }
/* Donut-colour key dot in a Storage-by-app row. */
.task-info .sys-storage-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
/* Storage-by-app rows reuse .task-item; the folder list drops in below. */
.sys-storage-app-item.is-clickable .task-header { cursor: pointer; }
.sys-storage-app-item .sys-storage-folders { padding: 4px 12px 8px 30px; }
.sys-storage-app-item .task-actions .sys-storage-caret { margin-right: 0; }
.sys-storage-card-meta {
margin-top: 8px;
display: flex;

View File

@ -101,15 +101,16 @@ class AdminSystem {
}
async refresh() {
const [metrics, history, apps, appsHist, info, storage] = await Promise.all([
const [metrics, history, apps, appsHist, info, storage, appStorage] = await Promise.all([
this.fetchJson('/data/system/metrics.json'),
this.fetchJson(`/api/system/history?range=${this.range}`),
this.fetchJson('/data/system/metrics_apps.json'),
this.fetchJson('/data/system/metrics_apps_history.json'),
this.fetchJson('/data/system/system_info.json'),
this.fetchJson('/api/system/storage')
this.fetchJson('/api/system/storage'),
this.fetchJson('/data/system/app_storage.json')
]);
this.d = { metrics, history, apps, appsHist, info, storage };
this.d = { metrics, history, apps, appsHist, info, storage, appStorage };
this.render();
}
@ -217,10 +218,19 @@ class AdminSystem {
: loadRatio >= 1.0 ? 'status-warning'
: 'status-success';
// Disk gauge gets a second inner ring for the slice of the disk that's
// LibrePortal (app data on disk + Docker images/cache), so it shows both
// total disk used (outer) and how much of that is us (inner).
const lpBytes = ((this.d.appStorage && this.d.appStorage.total_local) || 0)
+ ((this.d.storage && this.d.storage.total) || 0);
const diskTotal = Number(rootDisk.total) || 0;
const lpPct = diskTotal > 0 ? (lpBytes / diskTotal) * 100 : 0;
const diskSub = lpBytes > 0 ? `LibrePortal ${this.bytes(lpBytes)}` : (rootDisk.mount || '/');
return `
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: diskSub, inner: { value: lpPct, color: 'accent' } }))}
${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? ''}/${cpu.load15 ?? ''}` }))}`;
}
@ -347,17 +357,20 @@ class AdminSystem {
</div>`;
}
const C = window.LPCharts;
const segments = SP.segmentsFrom(s);
const donut = SP.donutSvg(segments, s.total);
// Full LibrePortal breakdown: apps + Docker images/cache, not just the
// engine categories — same story the Storage page tells.
const segments = SP.unifiedSegments(this.d.appStorage, s);
const grandTotal = segments.reduce((t, seg) => t + ((seg.data && seg.data.size) || 0), 0);
const donut = SP.donutSvg(segments, grandTotal, 'in use');
const recl = s.reclaimable || 0;
const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0;
const rows = segments.map(seg => {
const sz = (seg.data && seg.data.size) || 0;
const pct = s.total ? (sz / s.total) * 100 : 0;
const pct = grandTotal ? (sz / grandTotal) * 100 : 0;
return `
<div class="sys-storage-srow">
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
<span class="sys-storage-srow-k">${seg.label}</span>
<span class="sys-storage-srow-k">${this.escape(seg.label)}</span>
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
</div>`;

View File

@ -91,12 +91,25 @@ const LPCharts = (() => {
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
}
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max }.
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max,
// inner }. inner = { value, color } draws a second, smaller ring inside the
// main one (e.g. "of the disk used, this much is LibrePortal").
function gauge(value, opts = {}) {
const max = opts.max || 100;
const pct = Math.max(0, Math.min(1, (value || 0) / max));
const color = palette(opts.color || pickColor(pct));
const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct);
let innerSvg = '';
if (opts.inner) {
const ip = Math.max(0, Math.min(1, (opts.inner.value || 0) / max));
const icolor = palette(opts.inner.color || 'accent');
const ir = 38, IC = 2 * Math.PI * ir, ioff = IC * (1 - ip);
innerSvg = `
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="rgba(var(--text-rgb),0.10)" stroke-width="7"/>
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="${icolor.line}" stroke-width="7"
stroke-linecap="round" stroke-dasharray="${IC.toFixed(1)}" stroke-dashoffset="${ioff.toFixed(1)}"
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>`;
}
return `
<div class="lp-gauge">
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
@ -104,6 +117,7 @@ const LPCharts = (() => {
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
${innerSvg}
</svg>
<div class="lp-gauge-center">
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>

View File

@ -171,13 +171,30 @@ class SystemStoragePage {
</div>`;
}
// Docker engine categories. Warm colours = reclaimable overhead: Images get
// the same orange as the Reclaim action, build cache the deeper red.
static segmentsFrom(d) {
return [
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
{ key: 'images', label: 'Images', color: 'status-warning', data: (d && d.images) || {} },
{ key: 'build_cache', label: 'Build cache', color: 'status-danger', data: (d && d.build_cache) || {} },
];
}
// Cool colours for apps (your data), so they read as distinct from the warm
// Docker engine slices.
static get APP_PALETTE() { return ['accent', 'status-info', 'status-success']; }
// The full LibrePortal storage breakdown: a slice per app followed by the
// Docker engine categories. Shared by this page's donut and the System
// page's storage summary so both tell the same story.
static unifiedSegments(appStorage, dockerData) {
const pal = SystemStoragePage.APP_PALETTE;
const apps = (appStorage && Array.isArray(appStorage.apps)) ? appStorage.apps : [];
const appSegs = apps.map((a, i) => ({ key: 'app:' + a.app, label: a.app, color: pal[i % pal.length], data: { size: a.bytes || 0 }, kind: 'app' }));
const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : [];
return [...appSegs, ...docker];
}
// 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.
@ -210,10 +227,6 @@ class SystemStoragePage {
</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;
@ -227,17 +240,14 @@ class SystemStoragePage {
body.innerHTML = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
return;
}
const palette = SystemStoragePage.PALETTE;
const colorFor = i => palette[i % palette.length];
const APP_PAL = SystemStoragePage.APP_PALETTE;
const appColorFor = i => APP_PAL[i % APP_PAL.length];
// ---- Headline: ONE donut covering everything — a slice per app, then
// Docker's own categories (images, build cache). Colours run continuously
// across the whole list so app and Docker slices stay distinct, and the
// legend lists them all. This is the unified "where's my disk going" view.
// Docker's own categories (images, build cache). The legend lists them
// all. This is the unified "where's my disk going" view.
const appTotal = (as && as.total) || 0;
const appSegs = appRows.map((a, i) => ({ label: a.app, color: colorFor(i), data: { size: a.bytes || 0 } }));
const dockerCats = d ? SystemStoragePage.segmentsFrom(d).map((s, j) => ({ ...s, color: colorFor(appSegs.length + j) })) : [];
const allSegs = [...appSegs, ...dockerCats];
const allSegs = SystemStoragePage.unifiedSegments(as, d);
const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0);
const legend = `
<ul class="sys-storage-legend">
@ -270,39 +280,38 @@ class SystemStoragePage {
</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;
// ---- Storage by app — same Tasks-style list as Images (no selection).
// A coloured dot keys the row to its donut slice; the app icon + name
// identify it; clicking a row expands its per-folder breakdown.
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 class="sys-tasklist sys-storage-app-list">
${appRows.map((a, i) => {
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 `
<div class="task-item sys-storage-app-item${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
<div class="task-header">
<div class="task-info">
<span class="sys-storage-swatch" style="background: var(--${appColorFor(i)})"></span>
${this._appIconHtml(a.app)}
<span class="task-title">${fmt.escape(a.app)}</span>
<span class="task-time">${fmt.bytes(a.bytes || 0)}</span>
${a.external_bytes ? `<span class="task-duration" title="on a separate disk">${fmt.bytes(a.external_bytes)} external</span>` : ''}
</div>
<div class="task-actions">
${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}
</div>
</div>
${mounts.length ? `<ul class="sys-storage-folders sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">${folders}</ul>` : ''}
</div>`;
}).join('')}
</div>` : '';
// ---- Docker engine (secondary): images + build cache the daemon keeps,
@ -311,7 +320,7 @@ class SystemStoragePage {
if (d) {
const total = d.total || 0;
const recl = d.reclaimable || 0;
const cards = dockerCats.map(s => {
const cards = SystemStoragePage.segmentsFrom(d).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;
@ -362,32 +371,56 @@ class SystemStoragePage {
return { cls: 'is-unused', label: 'unused' };
}
// App-icon <img>, falling back to the generic app icon when the slug has no
// bundled icon. Icons are served at /icons/apps/<slug>.svg.
_iconImg(slug) {
return `<img class="sys-task-icon" src="/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/icons/apps/default.svg'">`;
}
_appIconHtml(app) {
return this._iconImg(String(app || '').toLowerCase());
}
// The app an image belongs to, derived from its repo tag (registry +
// namespace stripped, tag/digest dropped). null for dangling/untagged layers.
_imageAppSlug(im) {
const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
if (!tag) return null;
const repo = tag.split('@')[0].split(':')[0];
const seg = (repo.split('/').pop() || '').toLowerCase();
return seg || null;
}
// App icon for an image, or the generic "picture" glyph for untagged layers.
_imageIconHtml(im) {
const slug = this._imageAppSlug(im);
if (slug) return this._iconImg(slug);
return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
}
_renderImages(images) {
const fmt = window.SystemFmt;
const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
const imgIcon = `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
const totalSize = images.reduce((a, im) => a + (im.size || 0), 0);
if (!images.length) {
return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`;
}
// Clear All / Select all live in the section head (where the count used
// to be), so the list itself is just the rows in a dark container.
const head = `
<div class="sys-section-head sys-images-head">
<h2>Images</h2>
<span class="sys-chart-meta">${images.length} image${images.length === 1 ? '' : 's'} · ${fmt.bytes(totalSize)}</span>
</div>`;
if (!images.length) {
return `${head}<div class="sys-storage-app-empty">No images on disk.</div>`;
}
const toolbar = `
<div class="sys-images-toolbar">
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
${trash}<span class="clear-btn-label">Clear All</span>
</button>
<label class="task-select-all" title="Select all images">
<input type="checkbox" data-images-select-all id="sys-images-select-all">
<span class="task-select-box" aria-hidden="true"></span>
<span class="task-select-all-label">Select all</span>
</label>
<div class="sys-images-toolbar">
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
${trash}<span class="clear-btn-label">Clear All</span>
</button>
<label class="task-select-all" title="Select all images">
<input type="checkbox" data-images-select-all id="sys-images-select-all">
<span class="task-select-box" aria-hidden="true"></span>
<span class="task-select-all-label">Select all</span>
</label>
</div>
</div>`;
const rows = images.map(im => {
@ -398,7 +431,7 @@ class SystemStoragePage {
<div class="task-item sys-image-item" data-image-id="${id}">
<div class="task-header">
<div class="task-info">
${imgIcon}
${this._imageIconHtml(im)}
<span class="task-title" title="${fmt.escape((im.repo_tags || []).join(', '))}">${fmt.escape(this._imageName(im))}</span>
<span class="task-status sys-img-pill ${pill.cls}">${fmt.escape(pill.label)}</span>
<span class="task-time">${fmt.bytes(im.size || 0)}</span>
@ -418,7 +451,7 @@ class SystemStoragePage {
</div>`;
}).join('');
return `${head}${toolbar}<div class="sys-images-list" id="sys-images-list">${rows}</div>`;
return `${head}<div class="sys-tasklist sys-images-list" id="sys-images-list">${rows}</div>`;
}
// ---- selection (mirrors TasksManager) -----------------------------------