Merge claude/1

This commit is contained in:
librelad 2026-05-28 16:47:17 +01:00
commit dca885738d
3 changed files with 188 additions and 61 deletions

View File

@ -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;
}

View File

@ -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 &amp; 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>`;
}

View File

@ -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">