From 3d51eda98806985bdb82d411dab91d2ae35646c3 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 28 May 2026 22:19:40 +0100 Subject: [PATCH] =?UTF-8?q?ux(system):=20storage=20breakdown=20polish=20?= =?UTF-8?q?=E2=80=94=20dual=20disk=20gauge,=20app=20icons,=20list=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Signed-off-by: librelad --- containers/libreportal/frontend/css/admin.css | 36 ++++ .../js/components/admin/admin-system.js | 29 ++- .../frontend/js/components/admin/charts.js | 16 +- .../components/admin/system-storage-page.js | 167 +++++++++++------- 4 files changed, 172 insertions(+), 76 deletions(-) diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index 120e358..77dd4b4 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -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); 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 { margin-top: 8px; display: flex; diff --git a/containers/libreportal/frontend/js/components/admin/admin-system.js b/containers/libreportal/frontend/js/components/admin/admin-system.js index 3a774df..82ef868 100644 --- a/containers/libreportal/frontend/js/components/admin/admin-system.js +++ b/containers/libreportal/frontend/js/components/admin/admin-system.js @@ -101,15 +101,16 @@ class AdminSystem { } 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(`/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('/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(); } @@ -217,10 +218,19 @@ class AdminSystem { : loadRatio >= 1.0 ? 'status-warning' : '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 ` ${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('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 ?? '–'}` }))}`; } @@ -347,17 +357,20 @@ class AdminSystem { `; } const C = window.LPCharts; - const segments = SP.segmentsFrom(s); - const donut = SP.donutSvg(segments, s.total); + // Full LibrePortal breakdown: apps + Docker images/cache, not just the + // 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 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; + const pct = grandTotal ? (sz / grandTotal) * 100 : 0; return `
- ${seg.label} + ${this.escape(seg.label)} ${C.bar(pct, { color: seg.color })} ${this.bytes(sz)}
`; diff --git a/containers/libreportal/frontend/js/components/admin/charts.js b/containers/libreportal/frontend/js/components/admin/charts.js index 02cad83..49bb0ae 100644 --- a/containers/libreportal/frontend/js/components/admin/charts.js +++ b/containers/libreportal/frontend/js/components/admin/charts.js @@ -91,12 +91,25 @@ const LPCharts = (() => { vector-effect="non-scaling-stroke" stroke-linejoin="round"/>`; } - // 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 = {}) { const max = opts.max || 100; const pct = Math.max(0, Math.min(1, (value || 0) / max)); const color = palette(opts.color || pickColor(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 = ` + + `; + } return `
@@ -104,6 +117,7 @@ const LPCharts = (() => { + ${innerSvg}
${opts.display !== undefined ? opts.display : Math.round(value)}${opts.suffix || '%'}
diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js index 27db34d..8896d35 100644 --- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js +++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js @@ -171,13 +171,30 @@ class SystemStoragePage {
`; } + // Docker engine categories. Warm colours = reclaimable overhead: Images get + // the same orange as the Reclaim action, build cache the deeper red. static segmentsFrom(d) { return [ - { key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} }, - { key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} }, + { key: 'images', label: 'Images', color: 'status-warning', data: (d && d.images) || {} }, + { 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 // running cumulative fraction (off), so a slice starts where the previous // one ended and the ring fills proportionally. @@ -210,10 +227,6 @@ class SystemStoragePage { `; } - // 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() { const r = this.root(); if (!r) return; @@ -227,17 +240,14 @@ class SystemStoragePage { body.innerHTML = `
Couldn't read storage usage.
`; return; } - const palette = SystemStoragePage.PALETTE; - const colorFor = i => palette[i % palette.length]; + const APP_PAL = SystemStoragePage.APP_PALETTE; + const appColorFor = i => APP_PAL[i % APP_PAL.length]; // ---- Headline: ONE donut covering everything — a slice per app, then - // Docker's own categories (images, build cache). Colours run continuously - // across the whole list so app and Docker slices stay distinct, and the - // legend lists them all. This is the unified "where's my disk going" view. + // Docker's own categories (images, build cache). The legend lists them + // all. This is the unified "where's my disk going" view. const appTotal = (as && as.total) || 0; - const appSegs = appRows.map((a, i) => ({ label: a.app, color: colorFor(i), data: { size: a.bytes || 0 } })); - const dockerCats = d ? SystemStoragePage.segmentsFrom(d).map((s, j) => ({ ...s, color: colorFor(appSegs.length + j) })) : []; - const allSegs = [...appSegs, ...dockerCats]; + const allSegs = SystemStoragePage.unifiedSegments(as, d); const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0); const legend = `
    @@ -270,39 +280,38 @@ class SystemStoragePage {
` : `
${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}
`; - // ---- Storage by app — the donut's legend + per-folder drill-down. - // Swatch + bar colours match each app's donut slice. - const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0); - const hasExternal = appRows.some(a => a.external_bytes > 0); - const cols = hasExternal ? 4 : 3; + // ---- Storage by app — same Tasks-style list as Images (no selection). + // A coloured dot keys the row to its donut slice; the app icon + name + // identify it; clicking a row expands its per-folder breakdown. const appsSection = appRows.length ? `

Storage by app

on-disk data per app — click an app to see its folders
-
- - ${hasExternal ? '' : ''} - - ${appRows.map((a, i) => { - const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0; - const mounts = Array.isArray(a.mounts) ? a.mounts : []; - const open = this._expanded.has(a.app); - const folders = mounts.map(m => ` -
  • - ${fmt.escape(m.path || m.source || '—')} - ${m.external ? 'external' : ''} - ${fmt.bytes(m.bytes || 0)} -
  • `).join(''); - return ` - - - - ${hasExternal ? `` : ''} - ${mounts.length ? ` - - - ` : ''}`; - }).join('')} - -
    AppSizeExternal
    ${mounts.length ? `` : ''}${fmt.escape(a.app)}${fmt.bytes(a.bytes || 0)}${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}
      ${folders}
    +
    + ${appRows.map((a, i) => { + const mounts = Array.isArray(a.mounts) ? a.mounts : []; + const open = this._expanded.has(a.app); + const folders = mounts.map(m => ` +
  • + ${fmt.escape(m.path || m.source || '—')} + ${m.external ? 'external' : ''} + ${fmt.bytes(m.bytes || 0)} +
  • `).join(''); + return ` +
    +
    +
    + + ${this._appIconHtml(a.app)} + ${fmt.escape(a.app)} + ${fmt.bytes(a.bytes || 0)} + ${a.external_bytes ? `${fmt.bytes(a.external_bytes)} external` : ''} +
    +
    + ${mounts.length ? `` : ''} +
    +
    + ${mounts.length ? `
      ${folders}
    ` : ''} +
    `; + }).join('')}
    ` : ''; // ---- Docker engine (secondary): images + build cache the daemon keeps, @@ -311,7 +320,7 @@ class SystemStoragePage { if (d) { const total = d.total || 0; const recl = d.reclaimable || 0; - const cards = dockerCats.map(s => { + const cards = SystemStoragePage.segmentsFrom(d).map(s => { const v = s.data || {}; const pct = v.size && total ? (v.size / total) * 100 : 0; const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0; @@ -362,32 +371,56 @@ class SystemStoragePage { return { cls: 'is-unused', label: 'unused' }; } + // App-icon , falling back to the generic app icon when the slug has no + // bundled icon. Icons are served at /icons/apps/.svg. + _iconImg(slug) { + return ``; + } + + _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('')); + 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 ``; + } + _renderImages(images) { const fmt = window.SystemFmt; const trash = ``; - const imgIcon = ``; - const totalSize = images.reduce((a, im) => a + (im.size || 0), 0); + if (!images.length) { + return `

    Images

    No images on disk.
    `; + } + + // 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 = `

    Images

    - ${images.length} image${images.length === 1 ? '' : 's'} · ${fmt.bytes(totalSize)} -
    `; - - if (!images.length) { - return `${head}
    No images on disk.
    `; - } - - const toolbar = ` -
    - - +
    + + +
    `; const rows = images.map(im => { @@ -398,7 +431,7 @@ class SystemStoragePage {
    - ${imgIcon} + ${this._imageIconHtml(im)} ${fmt.escape(this._imageName(im))} ${fmt.escape(pill.label)} ${fmt.bytes(im.size || 0)} @@ -418,7 +451,7 @@ class SystemStoragePage {
    `; }).join(''); - return `${head}${toolbar}
    ${rows}
    `; + return `${head}
    ${rows}
    `; } // ---- selection (mirrors TasksManager) -----------------------------------