feat(system): full, deletable images list on the Storage page

Replaces the read-only "Largest images" top-10 table with a Tasks-style list of
ALL Docker images, with select-one / select-multiple / clear-all removal that
mirrors the Tasks page UX (row checkboxes, master select-all, a button that
morphs Clear All ↔ Delete Selected (N), an eo confirm modal).

Deletion routes through the task system, NOT a new web API: a new
`libreportal system image rm [--force] <ids>` CLI subcommand (validates each
ref, loops runFileOp docker image rm, reports a tally) is invoked via the
system_image_rm task action — same pattern as Reclaim. The web backend change
is read-only (uncap the existing /storage image list). In-use images are
skipped by default with an opt-in "force-remove" toggle (warned). The page
stays put, toasts, and refreshes on the task's completion event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 21:32:29 +01:00
parent beed825778
commit 9ca5cc6c7c
9 changed files with 408 additions and 32 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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.
@ -283,31 +311,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();
});
}
}

View File

@ -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
*/

View File

@ -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);

View File

@ -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' }

View File

@ -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

View File

@ -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 ""
}

View File

@ -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 "$@"; }