feat(maintenance): add Docker build-cache prune action
Add an admin-only POST /api/docker/prune-build-cache endpoint plus a Settings > Maintenance danger-zone button to reclaim disk used by the Docker build cache (image + static-site builds), which previously grew unbounded with no UI lever. Prunes unused-only (all=false) so a warm cache is preserved for apps redeploying soon. Mirrors the existing prune-images vertical slice; full en/ru i18n parity.
This commit is contained in:
@@ -348,3 +348,32 @@ func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
||||
"space_reclaimed_mb": reclaimedBytes / (1024 * 1024),
|
||||
})
|
||||
}
|
||||
|
||||
// pruneBuildCache handles POST /api/docker/prune-build-cache. It removes
|
||||
// unused Docker build-cache records daemon-wide (all=false), so an app's next
|
||||
// rebuild still hits its warm cache. The build cache is regenerable by
|
||||
// definition — pruning only forces slower rebuilds, never data loss — and the
|
||||
// dockerfile/static deploy paths never reclaim it on teardown, so it grows
|
||||
// monotonically until pruned here.
|
||||
func (s *Server) pruneBuildCache(w http.ResponseWriter, r *http.Request) {
|
||||
if s.docker == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.docker.PruneBuildCache(r.Context(), false)
|
||||
if err != nil {
|
||||
slog.Error("prune: build cache", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("prune: build cache",
|
||||
"caches_deleted", result.CachesDeleted,
|
||||
"space_reclaimed_mb", result.SpaceReclaimed/(1024*1024))
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"caches_deleted": result.CachesDeleted,
|
||||
"space_reclaimed_mb": result.SpaceReclaimed / (1024 * 1024),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,6 +501,7 @@ func (s *Server) Router() chi.Router {
|
||||
|
||||
// Docker management.
|
||||
r.Post("/docker/prune-images", s.pruneImages)
|
||||
r.Post("/docker/prune-build-cache", s.pruneBuildCache)
|
||||
|
||||
// NPM connection test.
|
||||
r.Post("/settings/npm/test", s.testNpmConnection)
|
||||
|
||||
@@ -108,3 +108,29 @@ func (c *Client) GetSystemStats(ctx context.Context) (SystemStats, error) {
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// BuildCachePruneResult reports the outcome of a build-cache prune.
|
||||
type BuildCachePruneResult struct {
|
||||
CachesDeleted int `json:"caches_deleted"` // number of cache records removed
|
||||
SpaceReclaimed int64 `json:"space_reclaimed"` // bytes reclaimed
|
||||
}
|
||||
|
||||
// PruneBuildCache deletes unused Docker build-cache records and returns the
|
||||
// number of records removed and bytes reclaimed. Docker's build-cache API is
|
||||
// prune-by-filter only — there is no surgical per-record eviction — so this
|
||||
// is the daemon-wide "prune unused" operation.
|
||||
//
|
||||
// When all is false (the default), only build cache not currently in use is
|
||||
// removed, so an app's next rebuild still hits its warm cache. When all is
|
||||
// true, every build-cache record is removed regardless of use, forcing a cold
|
||||
// rebuild for every app.
|
||||
func (c *Client) PruneBuildCache(ctx context.Context, all bool) (BuildCachePruneResult, error) {
|
||||
res, err := c.api.BuildCachePrune(ctx, client.BuildCachePruneOptions{All: all})
|
||||
if err != nil {
|
||||
return BuildCachePruneResult{}, fmt.Errorf("prune build cache: %w", err)
|
||||
}
|
||||
return BuildCachePruneResult{
|
||||
CachesDeleted: len(res.Report.CachesDeleted),
|
||||
SpaceReclaimed: int64(res.Report.SpaceReclaimed),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -438,6 +438,10 @@ export function pruneImages(): Promise<{ images_removed: number; space_reclaimed
|
||||
return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images');
|
||||
}
|
||||
|
||||
export function pruneBuildCache(): Promise<{ caches_deleted: number; space_reclaimed_mb: number }> {
|
||||
return post<{ caches_deleted: number; space_reclaimed_mb: number }>('/api/docker/prune-build-cache');
|
||||
}
|
||||
|
||||
export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> {
|
||||
return post<{ status: string }>('/api/settings/npm/test', data);
|
||||
}
|
||||
|
||||
@@ -123,6 +123,12 @@
|
||||
"pruneResult": "Removed {count} images, reclaimed {mb} MB",
|
||||
"pruneConfirmMessage": "This will remove unused Docker images belonging to your projects. Images used by active instances will not be affected.",
|
||||
"pruneFailed": "Failed to prune images",
|
||||
"buildCacheCleanupHelp": "Reclaim disk used by Docker build cache from image and static-site builds. Only unused cache is removed — apps redeploying still hit any cache that is still in use.",
|
||||
"pruneBuildCache": "Prune Build Cache",
|
||||
"pruningCache": "Pruning...",
|
||||
"pruneCacheResult": "Removed {count} build-cache records, reclaimed {mb} MB",
|
||||
"pruneBuildCacheConfirmMessage": "This removes unused Docker build-cache layers daemon-wide. Layers still in use are kept, so apps that redeploy soon keep their warm cache; others rebuild from scratch on their next deploy.",
|
||||
"pruneCacheFailed": "Failed to prune build cache",
|
||||
"proxyProvider": "Proxy Provider",
|
||||
"proxyProviderHelp": "Select how reverse proxy routes are managed for deployed containers.",
|
||||
"proxyNone": "None",
|
||||
|
||||
@@ -123,6 +123,12 @@
|
||||
"pruneResult": "Удалено {count} образов, освобождено {mb} МБ",
|
||||
"pruneConfirmMessage": "Будут удалены неиспользуемые Docker-образы ваших проектов. Образы активных экземпляров не затрагиваются.",
|
||||
"pruneFailed": "Не удалось очистить образы",
|
||||
"buildCacheCleanupHelp": "Освобождение места на диске, занятого кэшем сборки Docker (сборки образов и статических сайтов). Удаляется только неиспользуемый кэш — при следующем развёртывании приложения используют кэш, который ещё задействован.",
|
||||
"pruneBuildCache": "Очистить кэш сборки",
|
||||
"pruningCache": "Очистка...",
|
||||
"pruneCacheResult": "Удалено записей кэша сборки: {count}, освобождено {mb} МБ",
|
||||
"pruneBuildCacheConfirmMessage": "Будут удалены неиспользуемые слои кэша сборки Docker во всём демоне. Используемые слои сохраняются, поэтому приложения, которые скоро переразвернутся, сохранят тёплый кэш; остальные пересоберутся с нуля при следующем развёртывании.",
|
||||
"pruneCacheFailed": "Не удалось очистить кэш сборки",
|
||||
"proxyProvider": "Провайдер прокси",
|
||||
"proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.",
|
||||
"proxyNone": "Нет",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
never within casual miss-click distance of general form fields.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, pruneImages } from '$lib/api';
|
||||
import { getSettings, updateSettings, pruneImages, pruneBuildCache } from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
@@ -18,6 +18,8 @@
|
||||
let saving = $state(false);
|
||||
let pruning = $state(false);
|
||||
let showPruneConfirm = $state(false);
|
||||
let pruningCache = $state(false);
|
||||
let showPruneCacheConfirm = $state(false);
|
||||
|
||||
let staleThresholdDays = $state('7');
|
||||
let imagePruneThresholdMb = $state('1024');
|
||||
@@ -77,6 +79,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePruneBuildCache() {
|
||||
pruningCache = true;
|
||||
try {
|
||||
const result = await pruneBuildCache();
|
||||
toasts.success($t('settings.pruneCacheResult', { count: String(result.caches_deleted), mb: String(result.space_reclaimed_mb) }));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settings.pruneCacheFailed'));
|
||||
} finally {
|
||||
pruningCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
@@ -165,6 +179,26 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-5 border-[var(--color-danger)]/20" />
|
||||
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('settings.buildCacheCleanupHelp')}</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPruneCacheConfirm = true; }}
|
||||
disabled={pruningCache}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if pruningCache}
|
||||
<IconLoader size={16} />
|
||||
{$t('settings.pruningCache')}
|
||||
{:else}
|
||||
{$t('settings.pruneBuildCache')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,3 +214,13 @@
|
||||
onconfirm={() => { showPruneConfirm = false; handlePruneImages(); }}
|
||||
oncancel={() => { showPruneConfirm = false; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showPruneCacheConfirm}
|
||||
title={$t('settings.pruneBuildCache')}
|
||||
message={$t('settings.pruneBuildCacheConfirmMessage')}
|
||||
confirmLabel={$t('settings.pruneBuildCache')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => { showPruneCacheConfirm = false; handlePruneBuildCache(); }}
|
||||
oncancel={() => { showPruneCacheConfirm = false; }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user