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