+
+
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)