ux(system): storage breakdown polish — dual disk gauge, app icons, list design
- Disk gauge (System page) gains an inner ring for the LibrePortal slice of the disk, so it shows total disk used AND how much of that is us. - System-page storage summary now shows the full LibrePortal breakdown (apps + images + build cache), not just the Docker engine categories. - Fix the chart colours: Images use the Reclaim orange, build cache the deeper red (warm = reclaimable overhead), apps a cool palette. - Images list: dark container, Clear All / Select all moved into the section head (count text dropped), each image shows its app's icon. - Storage by app restyled to the same Tasks-style list (app icons, expandable folders), minus the selection controls. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
f6e310998b
commit
3d51eda988
@ -1197,6 +1197,42 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
|||||||
background: rgba(var(--status-warning-rgb), 0.18);
|
background: rgba(var(--status-warning-rgb), 0.18);
|
||||||
color: var(--status-warning);
|
color: var(--status-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tasks-style list container (Images + Storage by app): a recessed dark panel
|
||||||
|
behind the rows. */
|
||||||
|
.sys-tasklist {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border: 1px solid rgba(var(--text-rgb), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
/* App / image icon inside a task row. */
|
||||||
|
.sys-task-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
/* Clear All / Select all now live in the section head — no extra top margin. */
|
||||||
|
.sys-images-head .sys-images-toolbar { margin: 0; }
|
||||||
|
/* Donut-colour key dot in a Storage-by-app row. */
|
||||||
|
.task-info .sys-storage-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Storage-by-app rows reuse .task-item; the folder list drops in below. */
|
||||||
|
.sys-storage-app-item.is-clickable .task-header { cursor: pointer; }
|
||||||
|
.sys-storage-app-item .sys-storage-folders { padding: 4px 12px 8px 30px; }
|
||||||
|
.sys-storage-app-item .task-actions .sys-storage-caret { margin-right: 0; }
|
||||||
|
|
||||||
.sys-storage-card-meta {
|
.sys-storage-card-meta {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -101,15 +101,16 @@ class AdminSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
const [metrics, history, apps, appsHist, info, storage] = await Promise.all([
|
const [metrics, history, apps, appsHist, info, storage, appStorage] = await Promise.all([
|
||||||
this.fetchJson('/data/system/metrics.json'),
|
this.fetchJson('/data/system/metrics.json'),
|
||||||
this.fetchJson(`/api/system/history?range=${this.range}`),
|
this.fetchJson(`/api/system/history?range=${this.range}`),
|
||||||
this.fetchJson('/data/system/metrics_apps.json'),
|
this.fetchJson('/data/system/metrics_apps.json'),
|
||||||
this.fetchJson('/data/system/metrics_apps_history.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.fetchJson('/api/system/storage'),
|
||||||
|
this.fetchJson('/data/system/app_storage.json')
|
||||||
]);
|
]);
|
||||||
this.d = { metrics, history, apps, appsHist, info, storage };
|
this.d = { metrics, history, apps, appsHist, info, storage, appStorage };
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,10 +218,19 @@ class AdminSystem {
|
|||||||
: loadRatio >= 1.0 ? 'status-warning'
|
: loadRatio >= 1.0 ? 'status-warning'
|
||||||
: 'status-success';
|
: 'status-success';
|
||||||
|
|
||||||
|
// Disk gauge gets a second inner ring for the slice of the disk that's
|
||||||
|
// LibrePortal (app data on disk + Docker images/cache), so it shows both
|
||||||
|
// total disk used (outer) and how much of that is us (inner).
|
||||||
|
const lpBytes = ((this.d.appStorage && this.d.appStorage.total_local) || 0)
|
||||||
|
+ ((this.d.storage && this.d.storage.total) || 0);
|
||||||
|
const diskTotal = Number(rootDisk.total) || 0;
|
||||||
|
const lpPct = diskTotal > 0 ? (lpBytes / diskTotal) * 100 : 0;
|
||||||
|
const diskSub = lpBytes > 0 ? `LibrePortal ${this.bytes(lpBytes)}` : (rootDisk.mount || '/');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
|
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
|
||||||
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
|
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
|
||||||
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
|
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: diskSub, inner: { value: lpPct, color: 'accent' } }))}
|
||||||
${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`;
|
${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,17 +357,20 @@ class AdminSystem {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
const C = window.LPCharts;
|
const C = window.LPCharts;
|
||||||
const segments = SP.segmentsFrom(s);
|
// Full LibrePortal breakdown: apps + Docker images/cache, not just the
|
||||||
const donut = SP.donutSvg(segments, s.total);
|
// engine categories — same story the Storage page tells.
|
||||||
|
const segments = SP.unifiedSegments(this.d.appStorage, s);
|
||||||
|
const grandTotal = segments.reduce((t, seg) => t + ((seg.data && seg.data.size) || 0), 0);
|
||||||
|
const donut = SP.donutSvg(segments, grandTotal, 'in use');
|
||||||
const recl = s.reclaimable || 0;
|
const recl = s.reclaimable || 0;
|
||||||
const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0;
|
const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0;
|
||||||
const rows = segments.map(seg => {
|
const rows = segments.map(seg => {
|
||||||
const sz = (seg.data && seg.data.size) || 0;
|
const sz = (seg.data && seg.data.size) || 0;
|
||||||
const pct = s.total ? (sz / s.total) * 100 : 0;
|
const pct = grandTotal ? (sz / grandTotal) * 100 : 0;
|
||||||
return `
|
return `
|
||||||
<div class="sys-storage-srow">
|
<div class="sys-storage-srow">
|
||||||
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
|
<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-k">${this.escape(seg.label)}</span>
|
||||||
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
|
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
|
||||||
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
|
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@ -91,12 +91,25 @@ const LPCharts = (() => {
|
|||||||
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
|
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max }.
|
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max,
|
||||||
|
// inner }. inner = { value, color } draws a second, smaller ring inside the
|
||||||
|
// main one (e.g. "of the disk used, this much is LibrePortal").
|
||||||
function gauge(value, opts = {}) {
|
function gauge(value, opts = {}) {
|
||||||
const max = opts.max || 100;
|
const max = opts.max || 100;
|
||||||
const pct = Math.max(0, Math.min(1, (value || 0) / max));
|
const pct = Math.max(0, Math.min(1, (value || 0) / max));
|
||||||
const color = palette(opts.color || pickColor(pct));
|
const color = palette(opts.color || pickColor(pct));
|
||||||
const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct);
|
const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct);
|
||||||
|
let innerSvg = '';
|
||||||
|
if (opts.inner) {
|
||||||
|
const ip = Math.max(0, Math.min(1, (opts.inner.value || 0) / max));
|
||||||
|
const icolor = palette(opts.inner.color || 'accent');
|
||||||
|
const ir = 38, IC = 2 * Math.PI * ir, ioff = IC * (1 - ip);
|
||||||
|
innerSvg = `
|
||||||
|
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="rgba(var(--text-rgb),0.10)" stroke-width="7"/>
|
||||||
|
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="${icolor.line}" stroke-width="7"
|
||||||
|
stroke-linecap="round" stroke-dasharray="${IC.toFixed(1)}" stroke-dashoffset="${ioff.toFixed(1)}"
|
||||||
|
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>`;
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<div class="lp-gauge">
|
<div class="lp-gauge">
|
||||||
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
|
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
|
||||||
@ -104,6 +117,7 @@ const LPCharts = (() => {
|
|||||||
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
|
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
|
||||||
stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
|
stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
|
||||||
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
|
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
|
||||||
|
${innerSvg}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="lp-gauge-center">
|
<div class="lp-gauge-center">
|
||||||
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>
|
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>
|
||||||
|
|||||||
@ -171,13 +171,30 @@ class SystemStoragePage {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker engine categories. Warm colours = reclaimable overhead: Images get
|
||||||
|
// the same orange as the Reclaim action, build cache the deeper red.
|
||||||
static segmentsFrom(d) {
|
static segmentsFrom(d) {
|
||||||
return [
|
return [
|
||||||
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
|
{ key: 'images', label: 'Images', color: 'status-warning', data: (d && d.images) || {} },
|
||||||
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
|
{ key: 'build_cache', label: 'Build cache', color: 'status-danger', data: (d && d.build_cache) || {} },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cool colours for apps (your data), so they read as distinct from the warm
|
||||||
|
// Docker engine slices.
|
||||||
|
static get APP_PALETTE() { return ['accent', 'status-info', 'status-success']; }
|
||||||
|
|
||||||
|
// The full LibrePortal storage breakdown: a slice per app followed by the
|
||||||
|
// Docker engine categories. Shared by this page's donut and the System
|
||||||
|
// page's storage summary so both tell the same story.
|
||||||
|
static unifiedSegments(appStorage, dockerData) {
|
||||||
|
const pal = SystemStoragePage.APP_PALETTE;
|
||||||
|
const apps = (appStorage && Array.isArray(appStorage.apps)) ? appStorage.apps : [];
|
||||||
|
const appSegs = apps.map((a, i) => ({ key: 'app:' + a.app, label: a.app, color: pal[i % pal.length], data: { size: a.bytes || 0 }, kind: 'app' }));
|
||||||
|
const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : [];
|
||||||
|
return [...appSegs, ...docker];
|
||||||
|
}
|
||||||
|
|
||||||
// Hand-rolled donut, full circle = total. Each slice's dashoffset is the
|
// Hand-rolled donut, full circle = total. Each slice's dashoffset is the
|
||||||
// running cumulative fraction (off), so a slice starts where the previous
|
// running cumulative fraction (off), so a slice starts where the previous
|
||||||
// one ended and the ring fills proportionally.
|
// one ended and the ring fills proportionally.
|
||||||
@ -210,10 +227,6 @@ class SystemStoragePage {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct slice colours for the per-app donut, cycled if there are more
|
|
||||||
// apps than colours.
|
|
||||||
static get PALETTE() { return ['accent', 'status-info', 'status-success', 'status-warning', 'status-danger']; }
|
|
||||||
|
|
||||||
_render() {
|
_render() {
|
||||||
const r = this.root();
|
const r = this.root();
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
@ -227,17 +240,14 @@ class SystemStoragePage {
|
|||||||
body.innerHTML = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
|
body.innerHTML = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const palette = SystemStoragePage.PALETTE;
|
const APP_PAL = SystemStoragePage.APP_PALETTE;
|
||||||
const colorFor = i => palette[i % palette.length];
|
const appColorFor = i => APP_PAL[i % APP_PAL.length];
|
||||||
|
|
||||||
// ---- Headline: ONE donut covering everything — a slice per app, then
|
// ---- Headline: ONE donut covering everything — a slice per app, then
|
||||||
// Docker's own categories (images, build cache). Colours run continuously
|
// Docker's own categories (images, build cache). The legend lists them
|
||||||
// across the whole list so app and Docker slices stay distinct, and the
|
// all. This is the unified "where's my disk going" view.
|
||||||
// legend lists them all. This is the unified "where's my disk going" view.
|
|
||||||
const appTotal = (as && as.total) || 0;
|
const appTotal = (as && as.total) || 0;
|
||||||
const appSegs = appRows.map((a, i) => ({ label: a.app, color: colorFor(i), data: { size: a.bytes || 0 } }));
|
const allSegs = SystemStoragePage.unifiedSegments(as, d);
|
||||||
const dockerCats = d ? SystemStoragePage.segmentsFrom(d).map((s, j) => ({ ...s, color: colorFor(appSegs.length + j) })) : [];
|
|
||||||
const allSegs = [...appSegs, ...dockerCats];
|
|
||||||
const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0);
|
const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0);
|
||||||
const legend = `
|
const legend = `
|
||||||
<ul class="sys-storage-legend">
|
<ul class="sys-storage-legend">
|
||||||
@ -270,39 +280,38 @@ class SystemStoragePage {
|
|||||||
</div>`
|
</div>`
|
||||||
: `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
|
: `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
|
||||||
|
|
||||||
// ---- Storage by app — the donut's legend + per-folder drill-down.
|
// ---- Storage by app — same Tasks-style list as Images (no selection).
|
||||||
// Swatch + bar colours match each app's donut slice.
|
// A coloured dot keys the row to its donut slice; the app icon + name
|
||||||
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
|
// identify it; clicking a row expands its per-folder breakdown.
|
||||||
const hasExternal = appRows.some(a => a.external_bytes > 0);
|
|
||||||
const cols = hasExternal ? 4 : 3;
|
|
||||||
const appsSection = appRows.length ? `
|
const appsSection = appRows.length ? `
|
||||||
<div class="sys-section-head"><h2>Storage by app</h2><span class="sys-chart-meta">on-disk data per app — click an app to see its folders</span></div>
|
<div class="sys-section-head"><h2>Storage by app</h2><span class="sys-chart-meta">on-disk data per app — click an app to see its folders</span></div>
|
||||||
<div class="sys-apps-wrap">
|
<div class="sys-tasklist sys-storage-app-list">
|
||||||
<table class="sys-apps">
|
${appRows.map((a, i) => {
|
||||||
<thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead>
|
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
|
||||||
<tbody>
|
const open = this._expanded.has(a.app);
|
||||||
${appRows.map((a, i) => {
|
const folders = mounts.map(m => `
|
||||||
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
|
<li>
|
||||||
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
|
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
|
||||||
const open = this._expanded.has(a.app);
|
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
|
||||||
const folders = mounts.map(m => `
|
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
|
||||||
<li>
|
</li>`).join('');
|
||||||
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
|
return `
|
||||||
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
|
<div class="task-item sys-storage-app-item${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
|
||||||
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
|
<div class="task-header">
|
||||||
</li>`).join('');
|
<div class="task-info">
|
||||||
return `<tr class="sys-storage-app-row${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
|
<span class="sys-storage-swatch" style="background: var(--${appColorFor(i)})"></span>
|
||||||
<td class="sys-app-name"><span class="sys-storage-swatch" style="background: var(--${colorFor(i)})"></span>${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}${fmt.escape(a.app)}</td>
|
${this._appIconHtml(a.app)}
|
||||||
<td>${fmt.bytes(a.bytes || 0)}</td>
|
<span class="task-title">${fmt.escape(a.app)}</span>
|
||||||
<td class="sys-storage-app-barcol"><span class="sys-storage-app-bar"><span style="width:${pct.toFixed(1)}%; background: var(--${colorFor(i)})"></span></span></td>
|
<span class="task-time">${fmt.bytes(a.bytes || 0)}</span>
|
||||||
${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''}
|
${a.external_bytes ? `<span class="task-duration" title="on a separate disk">${fmt.bytes(a.external_bytes)} external</span>` : ''}
|
||||||
</tr>${mounts.length ? `
|
</div>
|
||||||
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
|
<div class="task-actions">
|
||||||
<td colspan="${cols}"><ul class="sys-storage-folders">${folders}</ul></td>
|
${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}
|
||||||
</tr>` : ''}`;
|
</div>
|
||||||
}).join('')}
|
</div>
|
||||||
</tbody>
|
${mounts.length ? `<ul class="sys-storage-folders sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">${folders}</ul>` : ''}
|
||||||
</table>
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
// ---- Docker engine (secondary): images + build cache the daemon keeps,
|
// ---- Docker engine (secondary): images + build cache the daemon keeps,
|
||||||
@ -311,7 +320,7 @@ class SystemStoragePage {
|
|||||||
if (d) {
|
if (d) {
|
||||||
const total = d.total || 0;
|
const total = d.total || 0;
|
||||||
const recl = d.reclaimable || 0;
|
const recl = d.reclaimable || 0;
|
||||||
const cards = dockerCats.map(s => {
|
const cards = SystemStoragePage.segmentsFrom(d).map(s => {
|
||||||
const v = s.data || {};
|
const v = s.data || {};
|
||||||
const pct = v.size && total ? (v.size / total) * 100 : 0;
|
const pct = v.size && total ? (v.size / total) * 100 : 0;
|
||||||
const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
|
const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
|
||||||
@ -362,32 +371,56 @@ class SystemStoragePage {
|
|||||||
return { cls: 'is-unused', label: 'unused' };
|
return { cls: 'is-unused', label: 'unused' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-icon <img>, falling back to the generic app icon when the slug has no
|
||||||
|
// bundled icon. Icons are served at /icons/apps/<slug>.svg.
|
||||||
|
_iconImg(slug) {
|
||||||
|
return `<img class="sys-task-icon" src="/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/icons/apps/default.svg'">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_appIconHtml(app) {
|
||||||
|
return this._iconImg(String(app || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app an image belongs to, derived from its repo tag (registry +
|
||||||
|
// namespace stripped, tag/digest dropped). null for dangling/untagged layers.
|
||||||
|
_imageAppSlug(im) {
|
||||||
|
const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
|
||||||
|
if (!tag) return null;
|
||||||
|
const repo = tag.split('@')[0].split(':')[0];
|
||||||
|
const seg = (repo.split('/').pop() || '').toLowerCase();
|
||||||
|
return seg || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App icon for an image, or the generic "picture" glyph for untagged layers.
|
||||||
|
_imageIconHtml(im) {
|
||||||
|
const slug = this._imageAppSlug(im);
|
||||||
|
if (slug) return this._iconImg(slug);
|
||||||
|
return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderImages(images) {
|
_renderImages(images) {
|
||||||
const fmt = window.SystemFmt;
|
const fmt = window.SystemFmt;
|
||||||
const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
||||||
const imgIcon = `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
|
|
||||||
const totalSize = images.reduce((a, im) => a + (im.size || 0), 0);
|
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear All / Select all live in the section head (where the count used
|
||||||
|
// to be), so the list itself is just the rows in a dark container.
|
||||||
const head = `
|
const head = `
|
||||||
<div class="sys-section-head sys-images-head">
|
<div class="sys-section-head sys-images-head">
|
||||||
<h2>Images</h2>
|
<h2>Images</h2>
|
||||||
<span class="sys-chart-meta">${images.length} image${images.length === 1 ? '' : 's'} · ${fmt.bytes(totalSize)}</span>
|
<div class="sys-images-toolbar">
|
||||||
</div>`;
|
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
|
||||||
|
${trash}<span class="clear-btn-label">Clear All</span>
|
||||||
if (!images.length) {
|
</button>
|
||||||
return `${head}<div class="sys-storage-app-empty">No images on disk.</div>`;
|
<label class="task-select-all" title="Select all images">
|
||||||
}
|
<input type="checkbox" data-images-select-all id="sys-images-select-all">
|
||||||
|
<span class="task-select-box" aria-hidden="true"></span>
|
||||||
const toolbar = `
|
<span class="task-select-all-label">Select all</span>
|
||||||
<div class="sys-images-toolbar">
|
</label>
|
||||||
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
|
</div>
|
||||||
${trash}<span class="clear-btn-label">Clear All</span>
|
|
||||||
</button>
|
|
||||||
<label class="task-select-all" title="Select all images">
|
|
||||||
<input type="checkbox" data-images-select-all id="sys-images-select-all">
|
|
||||||
<span class="task-select-box" aria-hidden="true"></span>
|
|
||||||
<span class="task-select-all-label">Select all</span>
|
|
||||||
</label>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const rows = images.map(im => {
|
const rows = images.map(im => {
|
||||||
@ -398,7 +431,7 @@ class SystemStoragePage {
|
|||||||
<div class="task-item sys-image-item" data-image-id="${id}">
|
<div class="task-item sys-image-item" data-image-id="${id}">
|
||||||
<div class="task-header">
|
<div class="task-header">
|
||||||
<div class="task-info">
|
<div class="task-info">
|
||||||
${imgIcon}
|
${this._imageIconHtml(im)}
|
||||||
<span class="task-title" title="${fmt.escape((im.repo_tags || []).join(', '))}">${fmt.escape(this._imageName(im))}</span>
|
<span class="task-title" title="${fmt.escape((im.repo_tags || []).join(', '))}">${fmt.escape(this._imageName(im))}</span>
|
||||||
<span class="task-status sys-img-pill ${pill.cls}">${fmt.escape(pill.label)}</span>
|
<span class="task-status sys-img-pill ${pill.cls}">${fmt.escape(pill.label)}</span>
|
||||||
<span class="task-time">${fmt.bytes(im.size || 0)}</span>
|
<span class="task-time">${fmt.bytes(im.size || 0)}</span>
|
||||||
@ -418,7 +451,7 @@ class SystemStoragePage {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `${head}${toolbar}<div class="sys-images-list" id="sys-images-list">${rows}</div>`;
|
return `${head}<div class="sys-tasklist sys-images-list" id="sys-images-list">${rows}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- selection (mirrors TasksManager) -----------------------------------
|
// ---- selection (mirrors TasksManager) -----------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user