Merge claude/1
This commit is contained in:
commit
eef24582b2
@ -368,12 +368,12 @@ router.get('/storage', async (req, res) => {
|
||||
},
|
||||
{ count: 0, size: 0, reclaimable: 0 }
|
||||
);
|
||||
// Largest images for "what's eating disk" surface; cap to 10 to
|
||||
// keep the payload tight.
|
||||
const topImages = (df.Images || [])
|
||||
// Every image, largest first — the Storage page lists them all so the
|
||||
// user can remove specific ones (deletion runs through the task/CLI,
|
||||
// not from here). Dangling <none> images are included on purpose.
|
||||
const images = (df.Images || [])
|
||||
.slice()
|
||||
.sort((a, b) => (b.Size || 0) - (a.Size || 0))
|
||||
.slice(0, 10)
|
||||
.map(im => ({
|
||||
id: im.Id,
|
||||
repo_tags: im.RepoTags || [],
|
||||
@ -389,7 +389,7 @@ router.get('/storage', async (req, res) => {
|
||||
total, reclaimable,
|
||||
images: sumImages,
|
||||
build_cache: sumBuild,
|
||||
top_images: topImages,
|
||||
image_list: images,
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@ -61,6 +61,22 @@
|
||||
}
|
||||
.admin-integrity .admin-status-dot { width: 8px; height: 8px; }
|
||||
|
||||
/* Storage → Images list: reuses the Tasks list look (.task-item etc., loaded
|
||||
globally), with a small toolbar + image-specific status pills. */
|
||||
.sys-images-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
.sys-images-list { margin-top: 4px; }
|
||||
.sys-image-item .task-header { cursor: default; }
|
||||
.sys-img-icon { color: rgba(var(--text-rgb), 0.6); flex-shrink: 0; }
|
||||
.sys-img-pill.is-inuse { background: rgba(var(--accent-rgb), 0.16); color: var(--accent); }
|
||||
.sys-img-pill.is-unused { background: rgba(var(--text-rgb), 0.10); color: rgba(var(--text-rgb), 0.6); }
|
||||
.sys-img-pill.is-dangling { background: rgba(251, 189, 35, 0.16); color: #fbbd23; }
|
||||
|
||||
.admin-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -7,12 +7,15 @@
|
||||
// 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 the top images by
|
||||
// size. The daemon's own overhead, separate from the per-app data.
|
||||
// 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.
|
||||
// A "Reclaim space" button runs the safe prune (build cache + dangling
|
||||
// images, never app data) via the system_reclaim task, then re-reads usage.
|
||||
// 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') {
|
||||
@ -20,7 +23,10 @@ class SystemStoragePage {
|
||||
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); }
|
||||
@ -30,13 +36,16 @@ class SystemStoragePage {
|
||||
await this._load();
|
||||
this._render();
|
||||
const r = this.root();
|
||||
if (r) r.addEventListener('click', this._onClick);
|
||||
// Refresh every 30s while mounted.
|
||||
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);
|
||||
}
|
||||
@ -44,7 +53,7 @@ class SystemStoragePage {
|
||||
dispose() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
const r = this.root();
|
||||
if (r) r.removeEventListener('click', this._onClick);
|
||||
if (r) { r.removeEventListener('click', this._onClick); r.removeEventListener('change', this._onChange); }
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
@ -55,6 +64,18 @@ class SystemStoragePage {
|
||||
}
|
||||
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]');
|
||||
@ -69,6 +90,13 @@ class SystemStoragePage {
|
||||
}
|
||||
}
|
||||
|
||||
_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.
|
||||
@ -301,31 +329,265 @@ class SystemStoragePage {
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
|
||||
const imagesTable = topImages.length ? `
|
||||
<div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div>
|
||||
<div class="sys-apps-wrap">
|
||||
<table class="sys-apps">
|
||||
<thead><tr><th>Tag</th><th>Size</th><th>Shared</th><th>Containers</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
${topImages.map(im => `
|
||||
<tr>
|
||||
<td class="sys-app-name">${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}</td>
|
||||
<td>${fmt.bytes(im.size)}</td>
|
||||
<td>${fmt.bytes(im.shared_size || 0)}</td>
|
||||
<td>${im.containers}${im.containers === 0 ? ' <span class="sys-storage-orphan">unused</span>' : ''}</td>
|
||||
<td>${im.created ? fmt.timeAgo(im.created) : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
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>
|
||||
${imagesTable}`;
|
||||
${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' };
|
||||
}
|
||||
|
||||
_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>`;
|
||||
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);
|
||||
|
||||
const head = `
|
||||
<div class="sys-section-head sys-images-head">
|
||||
<h2>Images</h2>
|
||||
<span class="sys-chart-meta">${images.length} image${images.length === 1 ? '' : 's'} · ${fmt.bytes(totalSize)}</span>
|
||||
</div>`;
|
||||
|
||||
if (!images.length) {
|
||||
return `${head}<div class="sys-storage-app-empty">No images on disk.</div>`;
|
||||
}
|
||||
|
||||
const toolbar = `
|
||||
<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>`;
|
||||
|
||||
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">
|
||||
${imgIcon}
|
||||
<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}${toolbar}<div class="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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -242,6 +242,24 @@ async configUpdate(changes) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Docker images by id. ids: array of image refs (the Storage page
|
||||
// sends full sha256:… ids). Routes through the CLI's `system image rm` as a
|
||||
// task — no direct API. Returns the created task so the caller can await its
|
||||
// completion. force=true passes --force (in-use images).
|
||||
async removeImages(ids, force = false) {
|
||||
try {
|
||||
const list = Array.isArray(ids) ? ids.filter(Boolean) : [];
|
||||
if (!list.length) throw new Error('No images selected');
|
||||
const flag = force ? ' --force' : '';
|
||||
// Ids joined with commas so they stay a single CLI token.
|
||||
const command = `libreportal system image rm${flag} ${list.join(',')}`;
|
||||
const label = list.length === 1 ? 'Remove Image' : `Remove ${list.length} Images`;
|
||||
return await this.executeTask('system_image_rm', 'system', command, label);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to remove images: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task object
|
||||
*/
|
||||
|
||||
@ -63,6 +63,9 @@ class TaskRouter {
|
||||
case 'system_reclaim':
|
||||
return await this.actions.systemReclaim();
|
||||
|
||||
case 'system_image_rm':
|
||||
return await this.actions.removeImages(params.ids, params.force);
|
||||
|
||||
case 'tool':
|
||||
return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel);
|
||||
|
||||
|
||||
@ -74,7 +74,8 @@ class TasksManager {
|
||||
'backup': 'Backup', 'restore': 'Restore',
|
||||
'delete': 'Delete Backup', 'delete_all': 'Delete All Backups',
|
||||
'config_update': 'Update Config', 'update_config': 'Update Config',
|
||||
'system_update': 'Update System',
|
||||
'system_update': 'Update System', 'system_reclaim': 'Reclaim Space',
|
||||
'system_image_rm': 'Remove Images',
|
||||
'setup-config': 'Apply Configuration',
|
||||
'setup-finalize': 'Finalize Setup'
|
||||
};
|
||||
@ -988,6 +989,10 @@ class TasksManager {
|
||||
// -- Regen -------------------------------------------------------------
|
||||
{ match: /^libreportal regen\b/, title: 'LibrePortal - Regenerate WebUI Data' },
|
||||
|
||||
// -- System maintenance ------------------------------------------------
|
||||
{ match: /^libreportal system reclaim\b/, title: 'LibrePortal - Reclaim Space' },
|
||||
{ match: /^libreportal system image rm\b/, title: 'LibrePortal - Remove Images' },
|
||||
|
||||
// -- Backup: per-app (these capture the app slug) ----------------------
|
||||
{ match: /^libreportal backup app create (\w+)/, title: (m) => `${displayName(m[1])} - Create Backup` },
|
||||
{ match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` },
|
||||
@ -1081,6 +1086,7 @@ class TasksManager {
|
||||
'restore': { icon: '📦', class: 'restore' },
|
||||
'delete': { icon: '🗑️', class: 'delete' },
|
||||
'delete_all': { icon: '🗑️', class: 'delete' },
|
||||
'system_image_rm': { icon: '🗑️', class: 'delete' },
|
||||
'setup-config': { icon: '🛠️', class: 'setup' },
|
||||
'setup-finalize': { icon: '🎉', class: 'setup' },
|
||||
'custom': { icon: '⚙️', class: 'custom' }
|
||||
|
||||
@ -19,6 +19,51 @@ reclaimDockerSpace()
|
||||
isSuccessful "Done"
|
||||
}
|
||||
|
||||
# Remove specific Docker images by id/ref. Args:
|
||||
# $1 force flag — "-f" to force-remove (e.g. in-use images), else empty
|
||||
# $2 comma-separated list of image refs (the WebUI sends full sha256:… ids)
|
||||
# The WebUI Storage page calls this through the task system (see the
|
||||
# system_image_rm action) — never a direct API. Each ref is validated before it
|
||||
# reaches docker; removal continues past per-image failures and reports a tally.
|
||||
removeDockerImages()
|
||||
{
|
||||
local force_flag="$1" ids_csv="$2"
|
||||
|
||||
isHeader "Removing Images"
|
||||
if [[ -z "$ids_csv" ]]; then
|
||||
isError "No images specified."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local removed=0 failed=0 id
|
||||
local IFS=','
|
||||
local -a ids=($ids_csv)
|
||||
unset IFS
|
||||
|
||||
for id in "${ids[@]}"; do
|
||||
id="${id//[[:space:]]/}"
|
||||
[[ -n "$id" ]] || continue
|
||||
# Accept only a sha256 digest or a conservative repo[:tag] ref — no shell
|
||||
# metacharacters can reach docker even though this arrives via the task
|
||||
# command string.
|
||||
if [[ ! "$id" =~ ^sha256:[a-f0-9]{12,64}$ && ! "$id" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]]; then
|
||||
isError "Skipping invalid image ref: $id"
|
||||
failed=$((failed + 1)); continue
|
||||
fi
|
||||
# $force_flag is intentionally unquoted: it's either empty or "-f".
|
||||
if runFileOp docker image rm $force_flag "$id" >/dev/null 2>&1; then
|
||||
isSuccessful "Removed $id"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
isError "Could not remove $id (in use, or has dependent child images)"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
isNotice "Done — removed ${removed}, failed/skipped ${failed}."
|
||||
[[ "$failed" -eq 0 ]]
|
||||
}
|
||||
|
||||
cliHandleSystemCommands()
|
||||
{
|
||||
local action="$initial_command2"
|
||||
@ -40,6 +85,25 @@ cliHandleSystemCommands()
|
||||
reclaimDockerSpace
|
||||
;;
|
||||
|
||||
"image")
|
||||
# libreportal system image rm [--force] <comma-separated ids>
|
||||
case "$initial_command3" in
|
||||
"rm"|"remove")
|
||||
local img_force="" img_ids=""
|
||||
if [[ "$initial_command4" == "--force" || "$initial_command4" == "-f" ]]; then
|
||||
img_force="-f"; img_ids="$initial_command5"
|
||||
else
|
||||
img_ids="$initial_command4"
|
||||
fi
|
||||
removeDockerImages "$img_force" "$img_ids"
|
||||
;;
|
||||
*)
|
||||
isNotice "Invalid image command: $initial_command3"
|
||||
cliShowSystemHelp
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
*)
|
||||
isNotice "Invalid system command: $action"
|
||||
cliShowSystemHelp
|
||||
|
||||
@ -12,5 +12,6 @@ cliShowSystemHelp()
|
||||
echo " libreportal system update - Update LibrePortal to latest version"
|
||||
echo " libreportal system reset - Reinstall LibrePortal install files"
|
||||
echo " libreportal system reclaim - Reclaim Docker space (build cache + dangling images)"
|
||||
echo " libreportal system image rm [--force] <ids> - Remove specific images (comma-separated ids)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@ -676,6 +676,7 @@ declare -gA LP_FN_MAP=(
|
||||
[_reconcileSplitValueComment]="config/core/variables/config_scan_variables.sh"
|
||||
[reconcileWebuiDirOwnership]="function/permission/libreportal_folders.sh"
|
||||
[recoverOrphans]="crontab/task/crontab_task_processor.sh"
|
||||
[removeDockerImages]="cli/commands/system/cli_system_commands.sh"
|
||||
[removeEmptyLineAtFileEnd]="function/file/empty_line/remove_line.sh"
|
||||
[repairDirectoryStructure]="crontab/task/crontab_check_processor.sh"
|
||||
[repairFileSystem]="crontab/task/crontab_check_processor.sh"
|
||||
@ -884,6 +885,7 @@ declare -gA LP_FN_MAP=(
|
||||
[webuiSyncAppIcon]="webui/data/utils/webui_app_icons.sh"
|
||||
[webuiSyncAppIcons]="webui/data/utils/webui_app_icons.sh"
|
||||
[webuiSystemApps]="webui/data/generators/system/webui_system_metrics.sh"
|
||||
[webuiSystemAppStorage]="webui/data/generators/system/webui_system_metrics.sh"
|
||||
[webuiSystemDisk]="webui/data/generators/system/webui_system_disk.sh"
|
||||
[webuiSystemInfo]="webui/data/generators/system/webui_system_info.sh"
|
||||
[webuiSystemMemory]="webui/data/generators/system/webui_system_memory.sh"
|
||||
@ -1572,6 +1574,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[_reconcileSplitValueComment]="scripts"
|
||||
[reconcileWebuiDirOwnership]="scripts"
|
||||
[recoverOrphans]="scripts"
|
||||
[removeDockerImages]="scripts"
|
||||
[removeEmptyLineAtFileEnd]="scripts"
|
||||
[repairDirectoryStructure]="scripts"
|
||||
[repairFileSystem]="scripts"
|
||||
@ -1780,6 +1783,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[webuiSyncAppIcon]="scripts"
|
||||
[webuiSyncAppIcons]="scripts"
|
||||
[webuiSystemApps]="scripts"
|
||||
[webuiSystemAppStorage]="scripts"
|
||||
[webuiSystemDisk]="scripts"
|
||||
[webuiSystemInfo]="scripts"
|
||||
[webuiSystemMemory]="scripts"
|
||||
@ -2488,6 +2492,7 @@ reconcileDockerOwnership() { source "${install_scripts_dir}function/permission/l
|
||||
_reconcileSplitValueComment() { source "${install_scripts_dir}config/core/variables/config_scan_variables.sh"; _reconcileSplitValueComment "$@"; }
|
||||
reconcileWebuiDirOwnership() { source "${install_scripts_dir}function/permission/libreportal_folders.sh"; reconcileWebuiDirOwnership "$@"; }
|
||||
recoverOrphans() { source "${install_scripts_dir}crontab/task/crontab_task_processor.sh"; recoverOrphans "$@"; }
|
||||
removeDockerImages() { source "${install_scripts_dir}cli/commands/system/cli_system_commands.sh"; removeDockerImages "$@"; }
|
||||
removeEmptyLineAtFileEnd() { source "${install_scripts_dir}function/file/empty_line/remove_line.sh"; removeEmptyLineAtFileEnd "$@"; }
|
||||
repairDirectoryStructure() { source "${install_scripts_dir}crontab/task/crontab_check_processor.sh"; repairDirectoryStructure "$@"; }
|
||||
repairFileSystem() { source "${install_scripts_dir}crontab/task/crontab_check_processor.sh"; repairFileSystem "$@"; }
|
||||
@ -2696,6 +2701,7 @@ webuiSetConfigOptions() { source "${install_scripts_dir}webui/data/generators/co
|
||||
webuiSyncAppIcon() { source "${install_scripts_dir}webui/data/utils/webui_app_icons.sh"; webuiSyncAppIcon "$@"; }
|
||||
webuiSyncAppIcons() { source "${install_scripts_dir}webui/data/utils/webui_app_icons.sh"; webuiSyncAppIcons "$@"; }
|
||||
webuiSystemApps() { source "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemApps "$@"; }
|
||||
webuiSystemAppStorage() { source "${install_scripts_dir}webui/data/generators/system/webui_system_metrics.sh"; webuiSystemAppStorage "$@"; }
|
||||
webuiSystemDisk() { source "${install_scripts_dir}webui/data/generators/system/webui_system_disk.sh"; webuiSystemDisk "$@"; }
|
||||
webuiSystemInfo() { source "${install_scripts_dir}webui/data/generators/system/webui_system_info.sh"; webuiSystemInfo "$@"; }
|
||||
webuiSystemMemory() { source "${install_scripts_dir}webui/data/generators/system/webui_system_memory.sh"; webuiSystemMemory "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user