// 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 = `
${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
${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 = `