librelad eaafd1bb38 refactor(webui): relocate admin area into features/admin/ + shared extractions
- features/admin/: the 10 admin-owned config controllers, the 5 admin pages
  (overview/system/charts/metric/storage), ssh-page.js, peers-page.js, plus
  admin.css/ip-whitelist.css/ssh.css (eager). config-manager.js kept last in
  the load order (it news the sub-managers).
- shared/js/: config-shared.js + config-options.js (ConfigShared/ConfigOptions
  globals consumed cross-feature by backup/apps/tasks).
- shared/css/: forms.css + config.css (generic form + config-form primitives
  borrowed by apps/backup/admin).
- Updated all path strings in system-loader.js (config component) and
  config-manager.js (lazyLoad of admin/ssh/peers controllers); index.html CSS
  hrefs. No /js/components/{config,admin,ssh,peers}/ refs remain.

js/components/ now holds only shared UI (topbar, notifications, eo-modal,
update-notifier, mobile-menu, confirmation-dialog).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 02:10:09 +01:00

640 lines
34 KiB
JavaScript

// 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 = `
<div class="admin-page sys-storage-page">
<div class="page-header config-page-header">
<div class="page-header-title">
<div class="admin-breadcrumb">
<a href="/admin/system" data-back>Admin · System</a>
</div>
<h1>Storage</h1>
<p class="sys-storage-sub">On-disk space by app, plus Docker's own engine usage.</p>
</div>
<button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (images in use and app data are kept)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reclaim space
</button>
</div>
<div class="sys-storage-body" data-storage-body>
<div class="sys-storage-loading">Reading Docker daemon…</div>
</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) {
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 `
<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">${fmt.escape(sub)}</text>
</svg>`;
}
_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 = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
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 = `
<ul class="sys-storage-legend">
${allSegs.map(s => `
<li>
<span class="sys-storage-swatch" style="background: var(--${s.color})"></span>
<span class="sys-storage-leg-k">${fmt.escape(s.label)}</span>
<span></span>
<span class="sys-storage-leg-v">${fmt.bytes((s.data && s.data.size) || 0)}</span>
</li>`).join('')}
</ul>`;
const headline = allSegs.length
? `<div class="sys-storage-headline">
<div class="sys-storage-head-card">
${SystemStoragePage.donutSvg(allSegs, grandTotal, 'in use')}
${legend}
</div>
<div class="sys-storage-head-stats">
<div class="sys-storage-stat">
<span class="sys-storage-stat-k">App data on disk</span>
<strong class="sys-storage-stat-v">${fmt.bytes(appTotal)}</strong>
<span class="sys-storage-stat-sub">${appRows.length} app${appRows.length === 1 ? '' : 's'}${as && as.total_external ? ` · ${fmt.bytes(as.total_external)} external` : ''}</span>
</div>
${d ? `<div class="sys-storage-stat sys-storage-stat-recl">
<span class="sys-storage-stat-k">Docker reclaimable</span>
<strong class="sys-storage-stat-v">${fmt.bytes(d.reclaimable || 0)}</strong>
<span class="sys-storage-stat-sub">of ${fmt.bytes(d.total || 0)} engine overhead</span>
</div>` : ''}
</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>`;
// ---- 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 ? `
<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-tasklist sys-storage-app-list">
${appRows.map((a, i) => {
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
const open = this._expanded.has(a.app);
const folders = mounts.map(m => `
<li>
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
</li>`).join('');
return `
<div class="task-item sys-storage-app-item${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
<div class="task-header">
<div class="task-info">
<span class="sys-storage-swatch" style="background: var(--${appColorFor(i)})"></span>
${this._appIconHtml(a.app)}
<span class="task-title">${fmt.escape(a.app)}</span>
<span class="task-time">${fmt.bytes(a.bytes || 0)}</span>
${a.external_bytes ? `<span class="task-duration" title="on a separate disk">${fmt.bytes(a.external_bytes)} external</span>` : ''}
</div>
<div class="task-actions">
${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}
</div>
</div>
${mounts.length ? `<ul class="sys-storage-folders sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">${folders}</ul>` : ''}
</div>`;
}).join('')}
</div>` : '';
// ---- 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 `
<div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
<div class="sys-storage-card-head">
<h3>${s.label}</h3>
<span class="sys-storage-card-count">${v.count ?? 0}</span>
</div>
<div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
<div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
<div class="sys-storage-card-meta">
<span>${pct.toFixed(1)}% of engine</span>
${v.reclaimable ? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${rPct.toFixed(0)}%)</span>` : ''}
</div>
</div>`;
}).join('');
const images = Array.isArray(d.image_list) ? d.image_list : [];
dockerSection = `
<div class="sys-section-head"><h2>Docker engine</h2><span class="sys-chart-meta">${fmt.bytes(total)} · ${fmt.bytes(recl)} reclaimable — the daemon's own usage, separate from your app data</span></div>
<div class="sys-storage-cards">${cards}</div>
${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. <none> tags mean a
// dangling layer — show the short id instead.
_imageName(im) {
const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
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('<none>'));
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 <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) {
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>`;
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 = `
<div class="sys-section-head sys-images-head">
<h2>Images</h2>
<div class="sys-images-toolbar">
<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>
</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 id = fmt.escape(im.id || '');
const pill = this._imagePill(im);
const selected = this._selectedImageIds.has(im.id);
return `
<div class="task-item sys-image-item" data-image-id="${id}">
<div class="task-header">
<div class="task-info">
${this._imageIconHtml(im)}
<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-time">${fmt.bytes(im.size || 0)}</span>
${im.shared_size ? `<span class="task-duration" title="shared layers">${fmt.bytes(im.shared_size)} shared</span>` : ''}
${im.created ? `<span class="task-duration">${fmt.timeAgo(im.created)}</span>` : ''}
</div>
<div class="task-actions">
<button type="button" class="task-btn delete" data-image-delete="${id}" title="Remove image">
${trash}<span class="task-btn-label">Delete</span>
</button>
<label class="task-select" title="Select for bulk delete">
<input type="checkbox" data-image-select="${id}" ${selected ? 'checked' : ''}>
<span class="task-select-box" aria-hidden="true"></span>
</label>
</div>
</div>
</div>`;
}).join('');
return `${head}<div class="sys-tasklist sys-images-list" id="sys-images-list">${rows}</div>`;
}
// ---- 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 = `
<div class="eo-empty-state danger" role="status">
<div class="eo-empty-state-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</div>
<div class="eo-empty-state-body">
<p class="eo-empty-state-title">This cannot be undone</p>
<p class="eo-empty-state-text">Removed images must be pulled or rebuilt to use again.</p>
</div>
</div>
${badges}
${inUseN > 0 ? `
<label class="eo-toggle eo-toggle-card" data-eo-toggle-row>
<input type="checkbox" id="img-force">
<span class="eo-toggle-track"></span>
<span class="eo-toggle-text">
<span class="eo-toggle-text-title">Force-remove in-use images</span>
<span class="eo-toggle-text-help">Off: skip the ${inUseN} in-use image${inUseN === 1 ? '' : 's'} (remove the other ${freeN}). On: force-remove them — this can break a running app until it's re-pulled.</span>
</span>
</label>` : ''}`;
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;