feat(system): "Reclaim space" action on the Storage page

Adds a `libreportal system reclaim` CLI command and an orange "Reclaim
space" button on /admin/config/system/storage (the v2 prune control the
page always hinted at).

Scope is deliberately SAFE: build cache + dangling (untagged) images
only (docker builder prune -f + docker image prune -f via the
rootless-aware runFileOp). It never touches volumes (app data) or
tagged/in-use images, so nothing an app relies on is removed.

Wiring mirrors system_update: a systemReclaim() action + system_reclaim
route case run the command verbatim through the task processor. The
button confirms via showConfirmation, shows a spinner, and re-reads
storage usage as the prune lands. Button styled with --status-warning to
match the Reclaimable stat it sits under, with a note clarifying scope.

Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 18:50:27 +01:00
parent f6fecd023a
commit 3031c6cab9
6 changed files with 131 additions and 5 deletions

View File

@ -1009,6 +1009,47 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
line-height: 1.05; line-height: 1.05;
} }
.sys-storage-stat-recl .sys-storage-stat-v { color: var(--status-warning); } .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 { .sys-storage-stat-sub {
font-size: 0.78rem; font-size: 0.78rem;
color: rgba(var(--text-rgb), 0.55); color: rgba(var(--text-rgb), 0.55);

View File

@ -9,7 +9,8 @@
// - Top 10 volumes by size // - Top 10 volumes by size
// //
// One backend call (GET /api/system/storage), cached server-side for 5s. // 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 { class SystemStoragePage {
constructor(rootId = 'config-section') { constructor(rootId = 'config-section') {
@ -47,6 +48,45 @@ class SystemStoragePage {
const back = e.target.closest('[data-back]'); const back = e.target.closest('[data-back]');
if (back && window.navigateToRoute) { if (back && window.navigateToRoute) {
window.navigateToRoute('/admin/config/system'); 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 {
<strong class="sys-storage-stat-v">${fmt.bytes(recl)}</strong> <strong class="sys-storage-stat-v">${fmt.bytes(recl)}</strong>
<span class="sys-storage-stat-sub">${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes &amp; cache</span> <span class="sys-storage-stat-sub">${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes &amp; cache</span>
</div> </div>
<button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Remove build cache and dangling images (volumes and in-use images 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>
<span class="sys-storage-reclaim-note">Frees build cache &amp; dangling images · volumes and in-use images are kept</span>
</div> </div>
</div>`; </div>`;

View File

@ -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 * Create a task object
*/ */

View File

@ -60,6 +60,9 @@ class TaskRouter {
case 'system_update': case 'system_update':
return await this.actions.systemUpdate(); return await this.actions.systemUpdate();
case 'system_reclaim':
return await this.actions.systemReclaim();
case 'tool': case 'tool':
return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel); return await this.actions.runTool(params.appName, params.toolName, params.toolArgs, params.toolLabel);

View File

@ -3,23 +3,48 @@
# System Commands Handler # System Commands Handler
# Handles all system subcommands by calling core functions # 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() cliHandleSystemCommands()
{ {
local action="$initial_command2" local action="$initial_command2"
case "$action" in case "$action" in
"status") "status")
tagsValidateShowSystemStatus tagsValidateShowSystemStatus
;; ;;
"update") "update")
checkUpdates checkUpdates
;; ;;
"reset") "reset")
runReinstall runReinstall
;; ;;
"reclaim")
reclaimDockerSpace
;;
*) *)
isNotice "Invalid system command: $action" isNotice "Invalid system command: $action"
cliShowSystemHelp cliShowSystemHelp

View File

@ -11,5 +11,6 @@ cliShowSystemHelp()
echo " libreportal system status - Show overall system status" echo " libreportal system status - Show overall system status"
echo " libreportal system update - Update LibrePortal to latest version" echo " libreportal system update - Update LibrePortal to latest version"
echo " libreportal system reset - Reinstall LibrePortal install files" echo " libreportal system reset - Reinstall LibrePortal install files"
echo " libreportal system reclaim - Reclaim Docker space (build cache + dangling images)"
echo "" echo ""
} }