// Admin → System → Storage — where the disk is going. // // Mounted at /admin/system/storage. Two data sources: // // - Storage by app — on-disk size of each app's bind-mounted data, from the // generated /data/system/app_storage.json (du, not docker — see the // generator). The headline view for LibrePortal, whose data lives in bind // mounts, not named volumes. // - Docker engine `docker system df` — headline total + reclaimable, a // donut (images / build cache), per-category cards, and a Tasks-style list // of every image (removable individually or in bulk). The daemon's own // overhead, separate from the per-app data. // // One backend call (GET /api/system/storage), cached server-side for 5s. // Mutations go through the task system, never a direct API: "Reclaim space" // runs the safe prune via the system_reclaim task; image removal runs // `libreportal system image rm` via the system_image_rm task. Both re-read // usage when the task lands. class SystemStoragePage { constructor(rootId = 'config-section') { this.rootId = rootId; this.data = null; this._timer = null; this._expanded = new Set(); // app names whose folder breakdown is open this._selectedImageIds = new Set(); // images ticked for bulk delete this._deleting = false; // a remove-images task is in flight this._onClick = this._onClick.bind(this); this._onChange = this._onChange.bind(this); } root() { return document.getElementById(this.rootId); } async mount() { this._renderShell(); await this._load(); this._render(); const r = this.root(); if (r) { r.addEventListener('click', this._onClick); r.addEventListener('change', this._onChange); } // Refresh every 30s while mounted. Skipped while a delete task is in // flight so it doesn't fight the post-completion refresh or re-enable // buttons mid-operation. this._timer = setInterval(() => { if (!document.querySelector('.sys-storage-page')) { clearInterval(this._timer); this._timer = null; return; } if (this._deleting) return; this._load().then(() => this._render()); }, 30000); } dispose() { if (this._timer) { clearInterval(this._timer); this._timer = null; } const r = this.root(); if (r) { r.removeEventListener('click', this._onClick); r.removeEventListener('change', this._onChange); } } _onClick(e) { const back = e.target.closest('[data-back]'); if (back && window.navigateToRoute) { window.navigateToRoute('/admin/system'); return; } const recl = e.target.closest('[data-storage-reclaim]'); if (recl) { this._reclaim(recl); return; } // Remove a single image. const del = e.target.closest('[data-image-delete]'); if (del) { this._deleteImages([del.getAttribute('data-image-delete')]); return; } // Clear All / Delete Selected. const clr = e.target.closest('[data-images-clear]'); if (clr) { const ids = this._selectedImageIds.size ? [...this._selectedImageIds] : ((this.data && this.data.image_list) || []).map(im => im.id); this._deleteImages(ids); return; } // Expand/collapse an app's per-folder breakdown. Toggle the DOM directly // (no re-render) and remember the state so the 30s refresh re-applies it. const tog = e.target.closest('[data-app-toggle]'); if (tog) { const app = tog.getAttribute('data-app-toggle'); if (this._expanded.has(app)) this._expanded.delete(app); else this._expanded.add(app); const open = this._expanded.has(app); const detail = this.root()?.querySelector(`[data-app-detail="${CSS.escape(app)}"]`); if (detail) detail.classList.toggle('is-collapsed', !open); tog.querySelector('.sys-storage-caret')?.classList.toggle('is-open', open); } } _onChange(e) { const box = e.target.closest('[data-image-select]'); if (box) { this.toggleImageSelection(box.getAttribute('data-image-select'), box.checked); return; } const all = e.target.closest('[data-images-select-all]'); if (all) { this.toggleSelectAllImages(all.checked); return; } } // Confirm, then run the safe reclaim task and re-read usage as it lands. // The button lives in the header (not the body), so it survives the // _render() refreshes below — re-enable it explicitly when done. _reclaim(btn) { const done = () => { btn.disabled = false; btn.classList.remove('is-running'); }; const run = async () => { btn.disabled = true; btn.classList.add('is-running'); try { if (!window.tasksManager?.router?.routeAction) { throw new Error('Task system not ready'); } await window.tasksManager.router.routeAction('system_reclaim'); window.notificationSystem?.show?.('Reclaiming space…', 'info'); // The prune runs in the background task processor; re-read // usage a couple of times so the numbers settle without a // manual refresh. setTimeout(() => this._load().then(() => this._render()), 3000); setTimeout(() => { this._load().then(() => this._render()); done(); }, 8000); } catch (err) { window.notificationSystem?.show?.(`Reclaim failed: ${err.message || err}`, 'error'); done(); } }; if (window.showConfirmation) { window.showConfirmation( 'Reclaim space', 'Clear the build cache and dangling images? Images in use and your app data are left untouched.', run, 'Reclaim space', 'Cancel', 'warning' ); } else { run(); } } async _load() { // Live docker df + the periodically-generated per-app on-disk sizes. // The latter is a static generator artifact (du is too heavy for a live // call), so a missing/empty file just means "not measured yet". const [storage, app] = await Promise.all([ fetch('/api/system/storage').then(r => r.json()).catch(() => null), fetch(`/data/system/app_storage.json?t=${Date.now()}`).then(r => r.ok ? r.json() : null).catch(() => null), ]); this.data = storage; this.appStorage = app; } _renderShell() { const r = this.root(); if (!r) return; r.innerHTML = `
Reading Docker daemon…
`; } // 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: '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. Used on this page's donut, where the per-app // detail is the point. 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]; } // Summary breakdown for the System-page overview: all apps collapse into one // "Applications" slice (their total), then the Docker engine categories. The // per-app split lives on the full Storage page, not here. static summarySegments(appStorage, dockerData) { const appsTotal = (appStorage && Number(appStorage.total)) || 0; const seg = appsTotal > 0 ? [{ key: 'apps', label: 'Applications', color: 'accent', data: { size: appsTotal }, kind: 'app' }] : []; const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : []; return [...seg, ...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. static donutSvg(segments, total, sub = 'total in use') { 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 ` ${slices.map(s => s.len > 0 ? `` : '' ).join('')} ${fmt.bytes(total || 0)} ${fmt.escape(sub)} `; } _render() { const r = this.root(); if (!r) return; const body = r.querySelector('[data-storage-body]'); if (!body) return; const fmt = window.SystemFmt; const d = this.data; // Docker `system df` (secondary) const as = this.appStorage; // per-app on-disk usage (primary) const appRows = (as && Array.isArray(as.apps)) ? as.apps : []; if (!d && !appRows.length) { body.innerHTML = `
Couldn't read storage usage.
`; return; } 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). The legend lists them // all. This is the unified "where's my disk going" view. const appTotal = (as && as.total) || 0; const allSegs = SystemStoragePage.unifiedSegments(as, d); const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0); const legend = ` `; const headline = allSegs.length ? `
${SystemStoragePage.donutSvg(allSegs, grandTotal, 'in use')} ${legend}
App data on disk ${fmt.bytes(appTotal)} ${appRows.length} app${appRows.length === 1 ? '' : 's'}${as && as.total_external ? ` · ${fmt.bytes(as.total_external)} external` : ''}
${d ? `
Docker reclaimable ${fmt.bytes(d.reclaimable || 0)} of ${fmt.bytes(d.total || 0)} engine overhead
` : ''}
` : `
${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}
`; // ---- 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
${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 ? `` : ''}
    `; }).join('')}
    ` : ''; // ---- Docker engine (secondary): images + build cache the daemon keeps, // plus the largest images. The cleanup view, clearly separate from data. let dockerSection = ''; if (d) { const total = d.total || 0; const recl = d.reclaimable || 0; 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; return `

    ${s.label}

    ${v.count ?? 0}
    ${fmt.bytes(v.size || 0)}
    ${pct.toFixed(1)}% of engine ${v.reclaimable ? `${fmt.bytes(v.reclaimable)} reclaimable (${rPct.toFixed(0)}%)` : ''}
    `; }).join(''); const images = Array.isArray(d.image_list) ? d.image_list : []; dockerSection = `

    Docker engine

    ${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable — the daemon's own usage, separate from your app data
    ${cards}
    ${this._renderImages(images)}`; } body.innerHTML = `${headline}${appsSection}${dockerSection}`; // Re-apply selection state (button label + master checkbox) after the // innerHTML rebuild so it survives the 30s refresh. this._updateImageSelectionUI(); } // ---- Images list (Tasks-style: select, bulk-delete) --------------------- // Friendly image label: first repo tag, else a short id. tags mean a // dangling layer — show the short id instead. _imageName(im) { const tag = (im.repo_tags || []).find(t => t && !t.includes('')); if (tag) return tag; const id = (im.id || '').replace(/^sha256:/, ''); return id ? id.slice(0, 12) : '—'; } // {cls, label} for the status pill: in use / unused / dangling. Never red — // an unused or dangling image isn't "wrong", just reclaimable. _imagePill(im) { const dangling = !(im.repo_tags || []).some(t => t && !t.includes('')); if (im.containers > 0) return { cls: 'is-inuse', label: `in use · ${im.containers}` }; if (dangling) return { cls: 'is-dangling', label: 'dangling' }; 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 /core/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 = ``; 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

    `; const rows = images.map(im => { const id = fmt.escape(im.id || ''); const pill = this._imagePill(im); const selected = this._selectedImageIds.has(im.id); return `
    ${this._imageIconHtml(im)} ${fmt.escape(this._imageName(im))} ${fmt.escape(pill.label)} ${fmt.bytes(im.size || 0)} ${im.shared_size ? `${fmt.bytes(im.shared_size)} shared` : ''} ${im.created ? `${fmt.timeAgo(im.created)}` : ''}
    `; }).join(''); return `${head}
    ${rows}
    `; } // ---- selection (mirrors TasksManager) ----------------------------------- toggleImageSelection(id, checked) { if (checked) this._selectedImageIds.add(id); else this._selectedImageIds.delete(id); this._updateImageSelectionUI(); } toggleSelectAllImages(checked) { const boxes = this.root()?.querySelectorAll('#sys-images-list [data-image-select]') || []; if (checked) boxes.forEach(cb => { this._selectedImageIds.add(cb.getAttribute('data-image-select')); cb.checked = true; }); else { this._selectedImageIds.clear(); boxes.forEach(cb => { cb.checked = false; }); } this._updateImageSelectionUI(); } // Button label morph (Clear All ↔ Delete Selected (N)) + master tri-state. _updateImageSelectionUI() { const r = this.root(); if (!r) return; // Drop ids no longer present (e.g. after a refresh removed them). const present = new Set(((this.data && this.data.image_list) || []).map(im => im.id)); this._selectedImageIds = new Set([...this._selectedImageIds].filter(id => present.has(id))); const n = this._selectedImageIds.size; const label = r.querySelector('#sys-images-clear .clear-btn-label'); if (label) label.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All'; const master = r.querySelector('#sys-images-select-all'); const visible = r.querySelectorAll('#sys-images-list [data-image-select]'); if (master) { if (n === 0 || visible.length === 0) { master.checked = false; master.indeterminate = false; } else if (n >= visible.length) { master.checked = true; master.indeterminate = false; } else { master.checked = false; master.indeterminate = true; } } } // ---- delete flow (routes through the system_image_rm task) -------------- async _deleteImages(ids) { const list = (this.data && this.data.image_list) || []; const byId = new Map(list.map(im => [im.id, im])); const targetsAll = ids.map(id => byId.get(id)).filter(Boolean); if (!targetsAll.length) return; const inUse = targetsAll.filter(im => im.containers > 0); const decision = await this._showImageDeleteModal(targetsAll, inUse); if (!decision || !decision.confirmed) return; // Skip in-use images unless the user forced it. const targets = decision.force ? targetsAll : targetsAll.filter(im => im.containers === 0); const targetIds = targets.map(im => im.id); if (!targetIds.length) { window.notificationSystem?.show?.('Nothing removed — selected images are in use. Enable force to remove them.', 'info'); return; } if (!window.tasksManager?.router?.routeAction) { window.notificationSystem?.show?.('Task system not ready', 'error'); return; } this._deleting = true; const clearBtn = this.root()?.querySelector('#sys-images-clear'); if (clearBtn) clearBtn.disabled = true; this.root()?.querySelectorAll('[data-image-delete]').forEach(b => { b.disabled = true; }); const finish = () => { this._deleting = false; this._selectedImageIds.clear(); this._load().then(() => this._render()); }; try { const task = await window.tasksManager.router.routeAction('system_image_rm', { ids: targetIds, force: decision.force }); const taskId = task && task.id; if (taskId) { const onDone = (ev) => { if (ev?.detail?.taskId !== taskId) return; window.removeEventListener('taskCompleted', onDone); clearTimeout(fallback); const failed = ev?.detail?.task?.status === 'failed'; window.notificationSystem?.show?.( failed ? 'Some images could not be removed (see Tasks)' : 'Images removed', failed ? 'warning' : 'success'); finish(); }; // Safety net if the completion event is missed. const fallback = setTimeout(() => { window.removeEventListener('taskCompleted', onDone); finish(); }, 12000); window.addEventListener('taskCompleted', onDone); } else { setTimeout(finish, 4000); } } catch (err) { this._deleting = false; if (clearBtn) clearBtn.disabled = false; window.notificationSystem?.show?.(`Could not start image removal: ${err.message || err}`, 'error'); } } // Confirmation modal — same eo shell as the Tasks Clear-All modal. Shows a // force toggle only when in-use images are in the target set. Resolves // {confirmed, force}. _showImageDeleteModal(targets, inUse) { return new Promise((resolve) => { if (!window.openEoModal) { resolve({ confirmed: confirm(`Remove ${targets.length} image(s)?`), force: false }); return; } const fmt = window.SystemFmt; const total = targets.length; const inUseN = inUse.length; const freeN = total - inUseN; const totalSize = targets.reduce((a, im) => a + (im.size || 0), 0); const badges = window.eoBadgeRow ? window.eoBadgeRow([ { label: `Images: ${total}`, variant: 'info' }, { label: `Size: ${fmt.bytes(totalSize)}`, variant: 'info' }, ...(inUseN > 0 ? [{ label: `In use: ${inUseN}`, variant: 'warning' }] : []), ]) : ''; const body = `

    This cannot be undone

    Removed images must be pulled or rebuilt to use again.

    ${badges} ${inUseN > 0 ? ` ` : ''}`; let decided = false; const finish = (val, modal) => { if (decided) return; decided = true; if (modal) modal.close(); resolve(val); }; const m = window.openEoModal({ id: 'remove-images-modal', size: 'sm', eyebrow: 'Remove Images', title: total === 1 ? 'Remove this image?' : `Remove ${total} images?`, desc: 'Confirm to remove the selected image(s) from the Docker engine.', body, actions: [ { label: 'Remove', variant: 'danger', onClick: (modal) => { const cb = modal.bodyEl.querySelector('#img-force'); finish({ confirmed: true, force: !!(cb && cb.checked) }, modal); } }, { label: 'Cancel', variant: 'secondary', onClick: (modal) => finish({ confirmed: false }, modal) }, ], onClose: () => finish({ confirmed: false }, null), }); // Live-update the danger button label as the force toggle flips. const cb = m.bodyEl.querySelector('#img-force'); const btn = m.contentEl.querySelector('.btn-danger'); const sync = () => { if (!btn) return; const force = !!(cb && cb.checked); if (inUseN > 0 && !force) btn.textContent = freeN > 0 ? `Remove ${freeN} (skip ${inUseN} in use)` : `Nothing to remove`; else btn.textContent = total === 1 ? 'Remove Image' : `Remove ${total} Images`; }; if (cb) cb.addEventListener('change', sync); sync(); }); } } window.SystemStoragePage = SystemStoragePage;