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))}
-
- Storage
- Open breakdown →
-
-
`;
-
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}
+ Open storage breakdown →
+
`;
+ }
+ 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 + `
+ `;
+ }
+
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 = `