From 97f338fba34b4bd9d8e62624cbfba09d28873951 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 2 Jun 2026 13:34:05 +0300 Subject: [PATCH] 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. --- internal/api/docker.go | 29 ++++++++++++ internal/api/router.go | 1 + internal/docker/system.go | 26 +++++++++++ web/src/lib/api.ts | 4 ++ web/src/lib/i18n/en.json | 6 +++ web/src/lib/i18n/ru.json | 6 +++ .../routes/settings/maintenance/+page.svelte | 46 ++++++++++++++++++- 7 files changed, 117 insertions(+), 1 deletion(-) diff --git a/internal/api/docker.go b/internal/api/docker.go index 970a9b6..85b78f8 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -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), + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 21d2437..583259d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/docker/system.go b/internal/docker/system.go index e85af28..cf11862 100644 --- a/internal/docker/system.go +++ b/internal/docker/system.go @@ -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 +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 900a0df..63a89e7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 98450b1..47e6adb 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 44f88cc..ebf905d 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -123,6 +123,12 @@ "pruneResult": "Удалено {count} образов, освобождено {mb} МБ", "pruneConfirmMessage": "Будут удалены неиспользуемые Docker-образы ваших проектов. Образы активных экземпляров не затрагиваются.", "pruneFailed": "Не удалось очистить образы", + "buildCacheCleanupHelp": "Освобождение места на диске, занятого кэшем сборки Docker (сборки образов и статических сайтов). Удаляется только неиспользуемый кэш — при следующем развёртывании приложения используют кэш, который ещё задействован.", + "pruneBuildCache": "Очистить кэш сборки", + "pruningCache": "Очистка...", + "pruneCacheResult": "Удалено записей кэша сборки: {count}, освобождено {mb} МБ", + "pruneBuildCacheConfirmMessage": "Будут удалены неиспользуемые слои кэша сборки Docker во всём демоне. Используемые слои сохраняются, поэтому приложения, которые скоро переразвернутся, сохранят тёплый кэш; остальные пересоберутся с нуля при следующем развёртывании.", + "pruneCacheFailed": "Не удалось очистить кэш сборки", "proxyProvider": "Провайдер прокси", "proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.", "proxyNone": "Нет", diff --git a/web/src/routes/settings/maintenance/+page.svelte b/web/src/routes/settings/maintenance/+page.svelte index 545efad..c219551 100644 --- a/web/src/routes/settings/maintenance/+page.svelte +++ b/web/src/routes/settings/maintenance/+page.svelte @@ -6,7 +6,7 @@ never within casual miss-click distance of general form fields. --> @@ -165,6 +179,26 @@ {/if} + +
+ +

{$t('settings.buildCacheCleanupHelp')}

+ +
+ +
@@ -180,3 +214,13 @@ onconfirm={() => { showPruneConfirm = false; handlePruneImages(); }} oncancel={() => { showPruneConfirm = false; }} /> + + { showPruneCacheConfirm = false; handlePruneBuildCache(); }} + oncancel={() => { showPruneCacheConfirm = false; }} +/>