feat(system): surface the Docker storage breakdown on the System page
Promote a compact Storage summary (breakdown donut + per-category legend + reclaimable) onto the System index, replacing the thin Docker strip and its easily-missed "Open breakdown" link; it links through to the full breakdown page. Drop the Disk usage trend chart, which duplicated the Disk gauge's root-mount %. Extract the donut + segment builders onto SystemStoragePage so the index summary and the full page share one renderer. This also fixes a donut stacking bug: the SVG used the final cumulative fraction for every slice's dashoffset instead of each slice's own running offset, so the ring only partially filled. It now fills proportionally. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
324240dd90
commit
8e6691b7d3
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
|
||||
|
||||
@ -264,7 +263,6 @@ class AdminSystem {
|
||||
<div class="sys-charts">
|
||||
${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' }]) +
|
||||
`<div class="sys-net-legend"><span><i class="dot ok"></i>↓ ${this.rate(lastRx)}</span><span><i class="dot accent"></i>↑ ${this.rate(lastTx)}</span></div>`,
|
||||
@ -283,20 +281,6 @@ class AdminSystem {
|
||||
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
|
||||
</div>`;
|
||||
|
||||
// Docker strip — now a navigational tile too. "Storage" leads to the
|
||||
// dedicated breakdown page; the rest are display-only.
|
||||
const dockerStrip = `
|
||||
<div class="sys-section-head"><h2>Docker</h2></div>
|
||||
<div class="sys-strip">
|
||||
${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))}
|
||||
<button type="button" class="sys-stat sys-stat-link" data-sys-storage aria-label="Open storage breakdown">
|
||||
<span class="sys-stat-label">Storage</span>
|
||||
<strong class="sys-stat-value">Open breakdown →</strong>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
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 {
|
||||
</div>
|
||||
${gauges}
|
||||
${charts}
|
||||
${this._storageSection()}
|
||||
${infoStrip}
|
||||
${dockerStrip}
|
||||
${appsTable}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div class="sys-section-head">
|
||||
<h2>Storage</h2>
|
||||
<span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images · ${dk.volumes ?? 0} volumes</span>
|
||||
</div>`;
|
||||
if (!s || !s.total || !SP) {
|
||||
const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.';
|
||||
return head + `
|
||||
<div class="sys-storage-summary sys-storage-summary-empty">
|
||||
<span>${msg}</span>
|
||||
<button type="button" class="sys-storage-more" data-sys-storage>Open storage breakdown →</button>
|
||||
</div>`;
|
||||
}
|
||||
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 `
|
||||
<div class="sys-storage-srow">
|
||||
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
|
||||
<span class="sys-storage-srow-k">${seg.label}</span>
|
||||
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
|
||||
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return head + `
|
||||
<div class="sys-storage-summary">
|
||||
<button type="button" class="sys-storage-summary-donut" data-sys-storage aria-label="Open storage breakdown">${donut}</button>
|
||||
<div class="sys-storage-summary-main">
|
||||
<div class="sys-storage-srows">${rows}</div>
|
||||
<div class="sys-storage-summary-foot">
|
||||
<span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span>
|
||||
<button type="button" class="sys-storage-more" data-sys-storage>View largest images & volumes →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
stat(label, value) {
|
||||
return `<div class="sys-stat"><span class="sys-stat-label">${this.escape(label)}</span><strong class="sys-stat-value">${this.escape(value)}</strong></div>`;
|
||||
}
|
||||
|
||||
@ -80,6 +80,47 @@ class SystemStoragePage {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<svg viewBox="0 0 240 240" class="sys-storage-donut" role="img" aria-label="Storage breakdown">
|
||||
<g transform="translate(120 120) rotate(-90)">
|
||||
<circle cx="0" cy="0" r="${r0}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${stroke}"/>
|
||||
${slices.map(s => s.len > 0
|
||||
? `<circle cx="0" cy="0" r="${r0}" fill="none"
|
||||
stroke="var(--${s.color})" stroke-width="${stroke}"
|
||||
stroke-dasharray="${s.len.toFixed(1)} ${(C - s.len).toFixed(1)}"
|
||||
stroke-dashoffset="${s.off.toFixed(1)}"
|
||||
style="transition:stroke-dasharray .4s ease"/>`
|
||||
: ''
|
||||
).join('')}
|
||||
</g>
|
||||
<text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total || 0)}</text>
|
||||
<text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">total in use</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
_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 = `
|
||||
<svg viewBox="0 0 240 240" class="sys-storage-donut" role="img" aria-label="Storage breakdown">
|
||||
<g transform="translate(120 120) rotate(-90)">
|
||||
<circle cx="0" cy="0" r="${r0}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${stroke}"/>
|
||||
${slices.map(s => s.len > 0
|
||||
? `<circle cx="0" cy="0" r="${r0}" fill="none"
|
||||
stroke="var(--${s.color})" stroke-width="${stroke}"
|
||||
stroke-dasharray="${s.len.toFixed(1)} ${(C - s.len).toFixed(1)}"
|
||||
stroke-dashoffset="${(-((acc - s.frac) * C - 0)).toFixed(1)}"
|
||||
style="transition:stroke-dasharray .4s ease"/>`
|
||||
: ''
|
||||
).join('')}
|
||||
</g>
|
||||
<text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total)}</text>
|
||||
<text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">total in use</text>
|
||||
</svg>`;
|
||||
const donut = SystemStoragePage.donutSvg(segments, total);
|
||||
|
||||
const legend = `
|
||||
<ul class="sys-storage-legend">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user