diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index bf76617..67682d5 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -1009,6 +1009,47 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); } line-height: 1.05; } .sys-storage-stat-recl .sys-storage-stat-v { color: var(--status-warning); } + +/* "Reclaim space" action — orange to match the Reclaimable stat it sits + under. Safe prune only (build cache + dangling images). */ +.sys-storage-reclaim { + align-self: flex-start; + margin-top: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + font-size: 0.85rem; + font-weight: 600; + color: var(--status-warning); + background: rgba(var(--status-warning-rgb), 0.12); + border: 1px solid rgba(var(--status-warning-rgb), 0.4); + border-radius: 10px; + cursor: pointer; + transition: background .15s ease, border-color .15s ease, transform .15s ease; +} +.sys-storage-reclaim:hover { + background: rgba(var(--status-warning-rgb), 0.2); + border-color: rgba(var(--status-warning-rgb), 0.65); + transform: translateY(-1px); +} +.sys-storage-reclaim:disabled, +.sys-storage-reclaim.is-running { + opacity: 0.6; + cursor: progress; + transform: none; +} +.sys-storage-reclaim svg { flex: 0 0 auto; } +.sys-storage-reclaim.is-running svg { animation: sysReclaimSpin 0.8s linear infinite; } +.sys-storage-reclaim-note { + margin-top: 6px; + font-size: 0.72rem; + color: rgba(var(--text-rgb), 0.5); +} +@keyframes sysReclaimSpin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .sys-storage-reclaim.is-running svg { animation: none; } +} .sys-storage-stat-sub { font-size: 0.78rem; color: rgba(var(--text-rgb), 0.55); diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js index 1d73c20..79fbfd8 100644 --- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js +++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js @@ -9,7 +9,8 @@ // - Top 10 volumes by size // // One backend call (GET /api/system/storage), cached server-side for 5s. -// No actions yet — purely informational. Prune controls deferred to a v2. +// A "Reclaim space" button runs the safe prune (build cache + dangling +// images, never volumes) via the system_reclaim task, then re-reads usage. class SystemStoragePage { constructor(rootId = 'config-section') { @@ -47,6 +48,45 @@ class SystemStoragePage { const back = e.target.closest('[data-back]'); if (back && window.navigateToRoute) { window.navigateToRoute('/admin/config/system'); + return; + } + const recl = e.target.closest('[data-storage-reclaim]'); + if (recl) { this._reclaim(recl); } + } + + // Confirm, then run the safe reclaim task and re-read usage as it lands. + _reclaim(btn) { + 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 Docker 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. Each _render rebuilds the button fresh. + setTimeout(() => this._load().then(() => this._render()), 3000); + setTimeout(() => this._load().then(() => this._render()), 8000); + } catch (err) { + window.notificationSystem?.show?.(`Reclaim failed: ${err.message || err}`, 'error'); + btn.disabled = false; + btn.classList.remove('is-running'); + } + }; + if (window.showConfirmation) { + window.showConfirmation( + 'Reclaim Docker space', + 'Remove the build cache and dangling (untagged) images? Volumes, app data, and images currently in use are left untouched.', + run, + 'Reclaim space', + 'Cancel', + 'warning' + ); + } else { + run(); } } @@ -165,6 +205,11 @@ class SystemStoragePage { ${fmt.bytes(recl)} ${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes & cache + + Frees build cache & dangling images · volumes and in-use images are kept `; diff --git a/containers/libreportal/frontend/js/components/task/task-actions.js b/containers/libreportal/frontend/js/components/task/task-actions.js index ff89376..c94e513 100755 --- a/containers/libreportal/frontend/js/components/task/task-actions.js +++ b/containers/libreportal/frontend/js/components/task/task-actions.js @@ -231,6 +231,17 @@ async configUpdate(changes) { } } + async systemReclaim() { + try { + // Full command passed verbatim (executeTask uses it as-is when it + // starts with 'libreportal'), so it routes to the CLI's system handler. + await this.executeTask('system_reclaim', 'system', 'libreportal system reclaim'); + return; + } catch (error) { + throw new Error(`Failed to reclaim space: ${error.message}`); + } + } + /** * Create a task object */ diff --git a/containers/libreportal/frontend/js/components/task/task-router.js b/containers/libreportal/frontend/js/components/task/task-router.js index a2ee193..e0be77b 100755 --- a/containers/libreportal/frontend/js/components/task/task-router.js +++ b/containers/libreportal/frontend/js/components/task/task-router.js @@ -60,6 +60,9 @@ class TaskRouter { case 'system_update': return await this.actions.systemUpdate(); + case 'system_reclaim': + return await this.actions.systemReclaim(); + case 'tool': return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel); diff --git a/scripts/cli/commands/system/cli_system_commands.sh b/scripts/cli/commands/system/cli_system_commands.sh index a0f48df..3e5ddfc 100755 --- a/scripts/cli/commands/system/cli_system_commands.sh +++ b/scripts/cli/commands/system/cli_system_commands.sh @@ -3,23 +3,48 @@ # System Commands Handler # Handles all system subcommands by calling core functions +# Reclaim Docker disk space — SAFE scope only: build cache + dangling +# (untagged) images. Never prunes volumes (app data) or tagged/in-use images, +# so nothing an app relies on is removed. runFileOp targets the correct daemon +# (rootless: as the install user with DOCKER_HOST set). +reclaimDockerSpace() +{ + isHeader "Reclaiming Docker Space" + isNotice "Safe scope: build cache + dangling images only (no volumes, no in-use images)." + + local cache_out image_out + cache_out=$(runFileOp docker builder prune -f 2>&1) + checkSuccess "Pruned build cache" + echo "$cache_out" | grep -i "Total:" | sed 's/^/ /' + + image_out=$(runFileOp docker image prune -f 2>&1) + checkSuccess "Pruned dangling images" + echo "$image_out" | grep -i "Total reclaimed space" | sed 's/^/ /' + + isSuccessful "Reclaim complete" +} + cliHandleSystemCommands() { local action="$initial_command2" - + case "$action" in "status") tagsValidateShowSystemStatus ;; - + "update") checkUpdates ;; - + "reset") runReinstall ;; - + + "reclaim") + reclaimDockerSpace + ;; + *) isNotice "Invalid system command: $action" cliShowSystemHelp diff --git a/scripts/cli/commands/system/cli_system_header.sh b/scripts/cli/commands/system/cli_system_header.sh index 0c4bc98..84efa6e 100755 --- a/scripts/cli/commands/system/cli_system_header.sh +++ b/scripts/cli/commands/system/cli_system_header.sh @@ -11,5 +11,6 @@ cliShowSystemHelp() echo " libreportal system status - Show overall system status" 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 "" }