From e3454dd10ef0329eeafb92e51d6a9e018a54aa9f Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 21:47:04 +0100 Subject: [PATCH] feat(dashboard): whole-disk donut with storage breakdown on the frontpage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the frontpage liquid-fill disk circle with a real donut split into Apps · Docker · Other · Free, keeping disk % used in the centre. Apps come from the per-app generator (root-device bytes), Docker from system df; both are clamped within "used" so skew can't overflow. Live disk ticks redraw it, and the card now clicks through to the full Storage breakdown. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/libreportal/frontend/css/style.css | 49 ++++++ .../frontend/html/dashboard-content.html | 11 +- .../frontend/js/utils/data-loader.js | 150 +++++++++++++----- 3 files changed, 160 insertions(+), 50 deletions(-) diff --git a/containers/libreportal/frontend/css/style.css b/containers/libreportal/frontend/css/style.css index cf183c7..c9dee56 100755 --- a/containers/libreportal/frontend/css/style.css +++ b/containers/libreportal/frontend/css/style.css @@ -2495,6 +2495,55 @@ html[data-theme="nebula"]::after { } } +/* Disk donut (frontpage) — apps / docker / other / free, % used in centre. */ +.disk-stat-card { + cursor: pointer; + gap: 4px; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} +.disk-stat-card:hover { + border-color: rgba(var(--accent-rgb), 0.4); + box-shadow: 0 6px 22px rgba(var(--accent-rgb), 0.10); +} +.disk-donut-wrap { + position: relative; + width: 104px; + height: 104px; + margin: 0 auto; +} +.disk-donut, .disk-donut-svg { + width: 104px; + height: 104px; + display: block; +} +.disk-stat-card .disk-percentage { font-size: 20px; } +.disk-legend { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 3px 8px; + align-items: center; + max-width: 200px; + margin: 12px auto 0; + text-align: left; + font-size: 0.78rem; +} +.disk-leg-row { display: contents; } +.disk-leg-dot { + width: 10px; + height: 10px; + border-radius: 3px; +} +.disk-leg-dot.apps { background: var(--accent); } +.disk-leg-dot.docker { background: var(--status-info); } +.disk-leg-dot.other { background: rgba(var(--text-rgb), 0.35); } +.disk-leg-dot.free { background: rgba(var(--text-rgb), 0.18); } +.disk-leg-k { color: rgba(var(--text-rgb), 0.7); } +.disk-leg-v { + color: var(--text-primary); + font-variant-numeric: tabular-nums; + text-align: right; +} + /* New disk circle chart styles */ .disk-chart { position: relative; diff --git a/containers/libreportal/frontend/html/dashboard-content.html b/containers/libreportal/frontend/html/dashboard-content.html index 5501f81..7900cd6 100755 --- a/containers/libreportal/frontend/html/dashboard-content.html +++ b/containers/libreportal/frontend/html/dashboard-content.html @@ -9,13 +9,12 @@
0
Installed Apps
-
-
-
-
-
0%
-
+
+
+
+
0%
+
Disk Used
diff --git a/containers/libreportal/frontend/js/utils/data-loader.js b/containers/libreportal/frontend/js/utils/data-loader.js index 7954541..c5a4b18 100755 --- a/containers/libreportal/frontend/js/utils/data-loader.js +++ b/containers/libreportal/frontend/js/utils/data-loader.js @@ -443,8 +443,20 @@ async function loadSystemInfo() { console.warn('⚠️ memory-info element not found'); } - // Update disk usage chart - updateDiskChart(diskChartData); + // Update disk usage chart. disk_usage.json reports df's 1K blocks, so + // normalise to bytes — the donut compares against byte-valued app/docker + // totals. (LiveSystem already emits bytes.) + updateDiskChart({ used: (diskChartData.used || 0) * 1024, total: (diskChartData.total || 0) * 1024 }); + loadStorageBreakdown(); + + // Make the disk card open the full Storage breakdown. onclick (not + // addEventListener) so a dashboard re-mount can't stack duplicate handlers. + const diskCardEl = document.getElementById('disk-stat-card'); + if (diskCardEl) { + const goStorage = () => window.navigateToRoute && window.navigateToRoute('/admin/config/system/storage'); + diskCardEl.onclick = goStorage; + diskCardEl.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goStorage(); } }; + } // Attach the 1 Hz live stream so the headline values tick like an // instrument. The static fetch above gave us a complete first paint; the @@ -482,7 +494,7 @@ function attachDashboardLive() { if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } _dashboardLiveUnsub = window.LiveSystem.subscribe((s) => { const memoryEl = document.getElementById('memory-info'); - const diskCard = document.getElementById('disk-circle-fill'); + const diskCard = document.getElementById('disk-donut'); if (!memoryEl && !diskCard) { // Dashboard isn't on screen anymore — release the sub. if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } @@ -508,14 +520,14 @@ function attachDashboardLive() { function waitForDashboardElements() { return new Promise((resolve) => { const checkElements = () => { - const circleFill = document.getElementById('disk-circle-fill'); + const donutEl = document.getElementById('disk-donut'); const percentText = document.getElementById('disk-percent'); const systemInfoEl = document.getElementById('system-info'); const uptimeEl = document.getElementById('uptime-info'); const memoryEl = document.getElementById('memory-info'); const installedCountEl = document.getElementById('installed-count'); - - if (circleFill && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) { + + if (donutEl && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) { resolve(); } else { // Check again after 100ms @@ -527,49 +539,99 @@ function waitForDashboardElements() { }); } -// Update disk usage circle chart +// Per-app + Docker storage totals (bytes) for the disk donut breakdown. Fetched +// separately from the live disk used/total because they come from different +// sources (the du-based generator + docker df) and change slowly. +let _diskBreakdown = { apps: 0, docker: 0 }; +let _lastDisk = null; // { used, total } in bytes, last value we drew + +function _fmtBytes(n) { + n = Number(n) || 0; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; +} + +// Hand-rolled SVG donut. Segments: [{ color, value }]; the ring fills +// proportionally and any remainder shows as the track. +function _diskDonutSvg(segments) { + const total = segments.reduce((t, s) => t + (s.value || 0), 0) || 1; + const r = 42, circ = 2 * Math.PI * r, sw = 13; + let acc = 0; + const arcs = segments.map((s) => { + const frac = (s.value || 0) / total; + const len = circ * frac; + const off = circ * (1 - acc); + acc += frac; + return len > 0 + ? `` + : ''; + }).join(''); + return ``; +} + +// Fetch the storage breakdown once and redraw the donut with it. +async function loadStorageBreakdown() { + try { + const [app, dock] = await Promise.all([ + fetch(`/data/system/app_storage.json?t=${Date.now()}`).then((r) => (r.ok ? r.json() : null)).catch(() => null), + fetch('/api/system/storage').then((r) => (r.ok ? r.json() : null)).catch(() => null), + ]); + _diskBreakdown = { + apps: (app && Number(app.total_local)) || 0, // on-disk app data (root device) + docker: (dock && Number(dock.total)) || 0, // images + build cache + }; + if (_lastDisk) updateDiskChart(_lastDisk); + } catch (_) { /* leave the donut as a plain used/free split */ } +} + +// Draw the disk donut: a slice for Apps, Docker, Other (the rest of used) and +// Free, with disk % used in the centre. used/total are bytes. function updateDiskChart(data) { - //console.log('updateDiskChart called with:', data); - - const circleFill = document.getElementById('disk-circle-fill'); + const donutEl = document.getElementById('disk-donut'); const percentText = document.getElementById('disk-percent'); - - if (!circleFill || !percentText) { - // Silently fail if elements don't exist yet - they may load later - return; - } - - // Extract used and total from data structure + if (!donutEl || !percentText) return; // dashboard not on screen yet + let used, total; - if (data.root) { - used = data.root.used; - total = data.root.total; - } else { - used = data.used; - total = data.total; - } - - // Calculate percentage + if (data && data.root) { used = data.root.used; total = data.root.total; } + else if (data) { used = data.used; total = data.total; } + used = Number(used) || 0; + total = Number(total) || 0; + if (total <= 0) return; + _lastDisk = { used, total }; + const percentage = Math.round((used / total) * 100); - //console.log(`Disk usage: ${used}/${total} = ${percentage}%`); - - // Update percentage text (only inside circle) - if (percentText) percentText.textContent = `${percentage}%`; - - // Set circle fill height based on percentage (fills from bottom) - circleFill.style.height = `${Math.max(percentage, 5)}%`; // Minimum 5% for visibility - - // Color based on usage - let fillColor; - if (percentage > 90) { - fillColor = '#dc3545'; // Red - } else if (percentage > 75) { - fillColor = '#ffc107'; // Yellow - } else { - fillColor = '#28a745'; // Green + percentText.textContent = `${percentage}%`; + percentText.style.color = percentage > 90 ? 'var(--status-danger)' + : percentage > 75 ? 'var(--status-warning)' : ''; + + // Apps and Docker are subsets of "used"; clamp so rounding/measurement skew + // can never make the slices exceed what's actually used. + const apps = Math.max(0, Math.min(_diskBreakdown.apps, used)); + const docker = Math.max(0, Math.min(_diskBreakdown.docker, used - apps)); + const other = Math.max(0, used - apps - docker); + const free = Math.max(0, total - used); + + donutEl.innerHTML = _diskDonutSvg([ + { color: 'var(--accent)', value: apps }, + { color: 'var(--status-info)', value: docker }, + { color: 'rgba(var(--text-rgb), 0.35)', value: other }, + { color: 'rgba(var(--text-rgb), 0.12)', value: free }, + ]); + + const legendEl = document.getElementById('disk-legend'); + if (legendEl) { + const row = (cls, label, val) => + `
${label}${_fmtBytes(val)}
`; + legendEl.innerHTML = row('apps', 'Apps', apps) + row('docker', 'Docker', docker) + + row('other', 'Other', other) + row('free', 'Free', free); } - - circleFill.style.background = `linear-gradient(to top, ${fillColor} 0%, ${fillColor} 100%)`; } // Minimal data loading (fallback)