Merge claude/2
This commit is contained in:
commit
6010727391
@ -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;
|
||||
|
||||
@ -9,13 +9,12 @@
|
||||
<div class="stat-number" id="installed-count">0</div>
|
||||
<div class="stat-label">Installed Apps</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="disk-chart">
|
||||
<div class="disk-circle-container">
|
||||
<div class="disk-circle-fill" id="disk-circle-fill"></div>
|
||||
<div class="disk-percentage" id="disk-percent">0%</div>
|
||||
</div>
|
||||
<div class="stat-card disk-stat-card" id="disk-stat-card" role="button" tabindex="0" title="View storage breakdown">
|
||||
<div class="disk-donut-wrap">
|
||||
<div class="disk-donut" id="disk-donut"></div>
|
||||
<div class="disk-percentage" id="disk-percent">0%</div>
|
||||
</div>
|
||||
<div class="disk-legend" id="disk-legend"></div>
|
||||
<div class="chart-label">
|
||||
<div class="chart-text">Disk Used</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
? `<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) {
|
||||
//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 (!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;
|
||||
if (data.root) {
|
||||
used = data.root.used;
|
||||
total = data.root.total;
|
||||
} else {
|
||||
used = data.used;
|
||||
total = data.total;
|
||||
}
|
||||
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 };
|
||||
|
||||
// Calculate percentage
|
||||
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)'
|
||||
: percentage > 75 ? 'var(--status-warning)' : '';
|
||||
|
||||
// Update percentage text (only inside circle)
|
||||
if (percentText) percentText.textContent = `${percentage}%`;
|
||||
// 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);
|
||||
|
||||
// Set circle fill height based on percentage (fills from bottom)
|
||||
circleFill.style.height = `${Math.max(percentage, 5)}%`; // Minimum 5% for visibility
|
||||
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 },
|
||||
]);
|
||||
|
||||
// Color based on usage
|
||||
let fillColor;
|
||||
if (percentage > 90) {
|
||||
fillColor = '#dc3545'; // Red
|
||||
} else if (percentage > 75) {
|
||||
fillColor = '#ffc107'; // Yellow
|
||||
} else {
|
||||
fillColor = '#28a745'; // Green
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user