From 8e6691b7d344e4ccf0834fbdbdad87b78444a038 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 16:47:17 +0100 Subject: [PATCH] feat(system): surface the Docker storage breakdown on the System page Promote a compact Storage summary (breakdown donut + per-category legend + reclaimable) onto the System index, replacing the thin Docker strip and its easily-missed "Open breakdown" link; it links through to the full breakdown page. Drop the Disk usage trend chart, which duplicated the Disk gauge's root-mount %. Extract the donut + segment builders onto SystemStoragePage so the index summary and the full page share one renderer. This also fixes a donut stacking bug: the SVG used the final cumulative fraction for every slice's dashoffset instead of each slice's own running offset, so the ring only partially filled. It now fills proportionally. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/libreportal/frontend/css/admin.css | 89 +++++++++++++++++++ .../js/components/admin/admin-system.js | 77 +++++++++++----- .../components/admin/system-storage-page.js | 83 +++++++++-------- 3 files changed, 188 insertions(+), 61 deletions(-) diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index 0a2d96c..bf76617 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -1085,3 +1085,92 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); } text-transform: uppercase; letter-spacing: 0.04em; } + +/* System index — inline Storage summary (donut + legend + reclaimable), + links through to the full breakdown page. */ +.sys-storage-summary { + margin-top: 10px; + display: flex; + align-items: center; + gap: 24px; + background: var(--card-bg); + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 14px; + padding: 18px 22px; +} +@media (max-width: 700px) { + .sys-storage-summary { flex-direction: column; align-items: stretch; gap: 16px; } +} +.sys-storage-summary-donut { + all: unset; + display: block; + flex-shrink: 0; + cursor: pointer; + border-radius: 50%; + transition: transform .15s ease, filter .15s ease; +} +.sys-storage-summary-donut:hover { + transform: scale(1.03); + filter: drop-shadow(0 4px 14px rgba(var(--accent-rgb), 0.25)); +} +.sys-storage-summary-donut:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} +.sys-storage-summary-donut .sys-storage-donut { width: 148px; height: 148px; } +.sys-storage-summary-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.sys-storage-srows { display: flex; flex-direction: column; gap: 9px; } +.sys-storage-srow { + display: grid; + grid-template-columns: 12px auto minmax(60px, 1fr) auto; + align-items: center; + gap: 10px; + font-size: 0.85rem; +} +.sys-storage-srow-k { color: var(--text-primary); font-weight: 600; } +.sys-storage-srow-bar { min-width: 0; } +.sys-storage-srow-bar .lp-bar { width: 100%; max-width: none; } +.sys-storage-srow-v { + color: rgba(var(--text-rgb), 0.65); + font-variant-numeric: tabular-nums; + text-align: right; +} +.sys-storage-summary-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + border-top: 1px solid rgba(var(--text-rgb), 0.08); + padding-top: 13px; +} +.sys-storage-recl-pill { + font-size: 0.78rem; + font-weight: 600; + color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.28); + padding: 4px 11px; + border-radius: 999px; +} +.sys-storage-more { + all: unset; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: var(--accent); + transition: opacity .15s ease; +} +.sys-storage-more:hover { opacity: 0.78; text-decoration: underline; } +.sys-storage-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; } +.sys-storage-summary-empty { + justify-content: space-between; + color: rgba(var(--text-rgb), 0.55); + font-size: 0.9rem; +} diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js index 46b05c7..b5d6ab9 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-system.js +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -101,14 +101,15 @@ class AdminSystem { } async refresh() { - const [metrics, history, apps, appsHist, info] = await Promise.all([ + const [metrics, history, apps, appsHist, info, storage] = 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('/data/system/system_info.json'), + this.fetchJson('/api/system/storage') ]); - this.d = { metrics, history, apps, appsHist, info }; + this.d = { metrics, history, apps, appsHist, info, storage }; this.render(); } @@ -246,10 +247,8 @@ class AdminSystem { if (!root) return; const C = window.LPCharts; const m = this.d.metrics || {}; - const cpu = m.cpu || {}, mem = m.memory || {}, dk = m.docker || {}; + const cpu = m.cpu || {}, mem = m.memory || {}; const info = this.d.info || {}; - const disks = Array.isArray(m.disks) ? m.disks : []; - const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {}; const gauges = `
${this._gaugesHtml()}
`; @@ -264,7 +263,6 @@ class AdminSystem {
${this.chartCard('CPU usage', C.areaChart(this.series('cpu'), { color: 'accent', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'cpu')} ${this.chartCard('Memory usage', C.areaChart(this.series('mem'), { color: 'status-info', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'mem')} - ${this.chartCard('Disk usage', C.areaChart(this.series('disk'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), rootDisk.mount || '/', 'disk')} ${this.chartCard('Network', C.multiLine([{ values: rx, color: 'status-success' }, { values: tx, color: 'accent' }]) + `
↓ ${this.rate(lastRx)}↑ ${this.rate(lastTx)}
`, @@ -283,20 +281,6 @@ class AdminSystem { ${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
`; - // Docker strip — now a navigational tile too. "Storage" leads to the - // dedicated breakdown page; the rest are display-only. - const dockerStrip = ` -

Docker

-
- ${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)} - ${this.stat('Images', String(dk.images ?? 0))} - ${this.stat('Volumes', String(dk.volumes ?? 0))} - -
`; - const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : []; const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {}; const appsBody = apps.length ? apps.map(a => { @@ -335,12 +319,61 @@ class AdminSystem { ${gauges} ${charts} + ${this._storageSection()} ${infoStrip} - ${dockerStrip} ${appsTable} `; } + // Docker storage summary: the breakdown donut + per-category legend + + // reclaimable, promoted onto the index so it's discoverable. Donut/segments + // are shared with the full breakdown page; this links through to it. + _storageSection() { + const dk = (this.d.metrics && this.d.metrics.docker) || {}; + const s = this.d.storage; + const SP = window.SystemStoragePage; + const head = ` +
+

Storage

+ ${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images · ${dk.volumes ?? 0} volumes +
`; + if (!s || !s.total || !SP) { + const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.'; + return head + ` +
+ ${msg} + +
`; + } + const C = window.LPCharts; + const segments = SP.segmentsFrom(s); + const donut = SP.donutSvg(segments, s.total); + 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; + return ` +
+ + ${seg.label} + ${C.bar(pct, { color: seg.color })} + ${this.bytes(sz)} +
`; + }).join(''); + return head + ` +
+ +
+
${rows}
+
+ ${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''} + +
+
+
`; + } + stat(label, value) { return `
${this.escape(label)}${this.escape(value)}
`; } diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js index a86193c..1d73c20 100644 --- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js +++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js @@ -80,6 +80,47 @@ class SystemStoragePage { `; } + static segmentsFrom(d) { + return [ + { key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} }, + { key: 'volumes', label: 'Volumes', color: 'status-success', data: (d && d.volumes) || {} }, + { key: 'containers', label: 'Containers', color: 'status-info', data: (d && d.containers) || {} }, + { key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} }, + ]; + } + + // 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) { + const fmt = window.SystemFmt; + const denom = total || 1; + const r0 = 90, stroke = 28, C = 2 * Math.PI * r0; + let acc = 0; + const slices = segments.map(s => { + const v = (s.data && s.data.size) || 0; + const off = C * (1 - acc); + acc += v / denom; + return { color: s.color, len: C * (v / denom), off }; + }); + return ` + + + + ${slices.map(s => s.len > 0 + ? `` + : '' + ).join('')} + + ${fmt.bytes(total || 0)} + total in use + `; + } + _render() { const r = this.root(); if (!r) return; @@ -91,47 +132,11 @@ class SystemStoragePage { return; } const fmt = window.SystemFmt; - const segments = [ - { key: 'images', label: 'Images', color: 'accent', data: d.images || {} }, - { key: 'volumes', label: 'Volumes', color: 'status-success', data: d.volumes || {} }, - { key: 'containers', label: 'Containers', color: 'status-info', data: d.containers || {} }, - { key: 'build_cache', label: 'Build cache', color: 'status-warning', data: d.build_cache || {} }, - ]; - const total = d.total || 1; + const segments = SystemStoragePage.segmentsFrom(d); + const total = d.total || 0; const recl = d.reclaimable || 0; const reclPct = total ? (recl / total) * 100 : 0; - - // Donut: cumulative segment arcs, full circle = total. Hand-rolled - // SVG. Stroke-dasharray + stroke-dashoffset for each slice. - const r0 = 90; // ring radius - const stroke = 28; - const C = 2 * Math.PI * r0; - let acc = 0; - const slices = segments.map(s => { - const v = s.data.size || 0; - const frac = total > 0 ? v / total : 0; - const len = C * frac; - const off = C * (1 - acc); // rotated offset start - acc += frac; - return { ...s, frac, len, off }; - }); - - const donut = ` - - - - ${slices.map(s => s.len > 0 - ? `` - : '' - ).join('')} - - ${fmt.bytes(total)} - total in use - `; + const donut = SystemStoragePage.donutSvg(segments, total); const legend = `