Merge claude/2

This commit is contained in:
librelad 2026-05-28 21:47:04 +01:00
commit 6010727391
3 changed files with 160 additions and 50 deletions

View File

@ -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 */ /* New disk circle chart styles */
.disk-chart { .disk-chart {
position: relative; position: relative;

View File

@ -9,13 +9,12 @@
<div class="stat-number" id="installed-count">0</div> <div class="stat-number" id="installed-count">0</div>
<div class="stat-label">Installed Apps</div> <div class="stat-label">Installed Apps</div>
</div> </div>
<div class="stat-card"> <div class="stat-card disk-stat-card" id="disk-stat-card" role="button" tabindex="0" title="View storage breakdown">
<div class="disk-chart"> <div class="disk-donut-wrap">
<div class="disk-circle-container"> <div class="disk-donut" id="disk-donut"></div>
<div class="disk-circle-fill" id="disk-circle-fill"></div> <div class="disk-percentage" id="disk-percent">0%</div>
<div class="disk-percentage" id="disk-percent">0%</div>
</div>
</div> </div>
<div class="disk-legend" id="disk-legend"></div>
<div class="chart-label"> <div class="chart-label">
<div class="chart-text">Disk Used</div> <div class="chart-text">Disk Used</div>
</div> </div>

View File

@ -443,8 +443,20 @@ async function loadSystemInfo() {
console.warn('⚠️ memory-info element not found'); console.warn('⚠️ memory-info element not found');
} }
// Update disk usage chart // Update disk usage chart. disk_usage.json reports df's 1K blocks, so
updateDiskChart(diskChartData); // 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 // 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 // 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; } if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; }
_dashboardLiveUnsub = window.LiveSystem.subscribe((s) => { _dashboardLiveUnsub = window.LiveSystem.subscribe((s) => {
const memoryEl = document.getElementById('memory-info'); const memoryEl = document.getElementById('memory-info');
const diskCard = document.getElementById('disk-circle-fill'); const diskCard = document.getElementById('disk-donut');
if (!memoryEl && !diskCard) { if (!memoryEl && !diskCard) {
// Dashboard isn't on screen anymore — release the sub. // Dashboard isn't on screen anymore — release the sub.
if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; } if (_dashboardLiveUnsub) { try { _dashboardLiveUnsub(); } catch (_) {} _dashboardLiveUnsub = null; }
@ -508,14 +520,14 @@ function attachDashboardLive() {
function waitForDashboardElements() { function waitForDashboardElements() {
return new Promise((resolve) => { return new Promise((resolve) => {
const checkElements = () => { const checkElements = () => {
const circleFill = document.getElementById('disk-circle-fill'); const donutEl = document.getElementById('disk-donut');
const percentText = document.getElementById('disk-percent'); const percentText = document.getElementById('disk-percent');
const systemInfoEl = document.getElementById('system-info'); const systemInfoEl = document.getElementById('system-info');
const uptimeEl = document.getElementById('uptime-info'); const uptimeEl = document.getElementById('uptime-info');
const memoryEl = document.getElementById('memory-info'); const memoryEl = document.getElementById('memory-info');
const installedCountEl = document.getElementById('installed-count'); const installedCountEl = document.getElementById('installed-count');
if (circleFill && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) { if (donutEl && percentText && systemInfoEl && uptimeEl && memoryEl && installedCountEl) {
resolve(); resolve();
} else { } else {
// Check again after 100ms // 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
? `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${s.color}" stroke-width="${sw}" stroke-dasharray="${len.toFixed(2)} ${(circ - len).toFixed(2)}" stroke-dashoffset="${off.toFixed(2)}" style="transition:stroke-dasharray .4s ease"/>`
: '';
}).join('');
return `<svg viewBox="0 0 120 120" class="disk-donut-svg" aria-hidden="true">
<g transform="rotate(-90 60 60)">
<circle cx="60" cy="60" r="${r}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${sw}"/>
${arcs}
</g>
</svg>`;
}
// 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) { function updateDiskChart(data) {
//console.log('updateDiskChart called with:', data); const donutEl = document.getElementById('disk-donut');
const circleFill = document.getElementById('disk-circle-fill');
const percentText = document.getElementById('disk-percent'); const percentText = document.getElementById('disk-percent');
if (!donutEl || !percentText) return; // dashboard not on screen yet
if (!circleFill || !percentText) {
// Silently fail if elements don't exist yet - they may load later
return;
}
// Extract used and total from data structure
let used, total; let used, total;
if (data.root) { if (data && data.root) { used = data.root.used; total = data.root.total; }
used = data.root.used; else if (data) { used = data.used; total = data.total; }
total = data.root.total; used = Number(used) || 0;
} else { total = Number(total) || 0;
used = data.used; if (total <= 0) return;
total = data.total; _lastDisk = { used, total };
}
// Calculate percentage
const percentage = Math.round((used / total) * 100); const percentage = Math.round((used / total) * 100);
//console.log(`Disk usage: ${used}/${total} = ${percentage}%`); percentText.textContent = `${percentage}%`;
percentText.style.color = percentage > 90 ? 'var(--status-danger)'
// Update percentage text (only inside circle) : percentage > 75 ? 'var(--status-warning)' : '';
if (percentText) percentText.textContent = `${percentage}%`;
// Apps and Docker are subsets of "used"; clamp so rounding/measurement skew
// Set circle fill height based on percentage (fills from bottom) // can never make the slices exceed what's actually used.
circleFill.style.height = `${Math.max(percentage, 5)}%`; // Minimum 5% for visibility const apps = Math.max(0, Math.min(_diskBreakdown.apps, used));
const docker = Math.max(0, Math.min(_diskBreakdown.docker, used - apps));
// Color based on usage const other = Math.max(0, used - apps - docker);
let fillColor; const free = Math.max(0, total - used);
if (percentage > 90) {
fillColor = '#dc3545'; // Red donutEl.innerHTML = _diskDonutSvg([
} else if (percentage > 75) { { color: 'var(--accent)', value: apps },
fillColor = '#ffc107'; // Yellow { color: 'var(--status-info)', value: docker },
} else { { color: 'rgba(var(--text-rgb), 0.35)', value: other },
fillColor = '#28a745'; // Green { color: 'rgba(var(--text-rgb), 0.12)', value: free },
]);
const legendEl = document.getElementById('disk-legend');
if (legendEl) {
const row = (cls, label, val) =>
`<div class="disk-leg-row"><span class="disk-leg-dot ${cls}"></span><span class="disk-leg-k">${label}</span><span class="disk-leg-v">${_fmtBytes(val)}</span></div>`;
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) // Minimal data loading (fallback)