97f338fba3
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.
137 lines
4.6 KiB
Go
137 lines
4.6 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/moby/moby/client"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// SystemStats is a host-level snapshot combining daemon capacity
|
|
// (NCPU, memory total) with container counts and disk usage broken down
|
|
// by category. Workload CPU/memory utilization is aggregated from
|
|
// per-container samples by the stats collector, not here.
|
|
type SystemStats struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
|
|
// Capacity from Docker daemon.
|
|
NCPU int `json:"ncpu"`
|
|
MemoryTotal int64 `json:"memory_total"`
|
|
|
|
// Container/image counts.
|
|
Containers int `json:"containers"`
|
|
Running int `json:"running"`
|
|
Paused int `json:"paused"`
|
|
Stopped int `json:"stopped"`
|
|
Images int `json:"images"`
|
|
|
|
// Disk usage by category (bytes).
|
|
DiskImagesBytes int64 `json:"disk_images_bytes"`
|
|
DiskContainersBytes int64 `json:"disk_containers_bytes"`
|
|
DiskVolumesBytes int64 `json:"disk_volumes_bytes"`
|
|
DiskBuildCacheBytes int64 `json:"disk_build_cache_bytes"`
|
|
|
|
// Reclaimable disk space by category (bytes).
|
|
DiskImagesReclaimable int64 `json:"disk_images_reclaimable"`
|
|
DiskContainersReclaimable int64 `json:"disk_containers_reclaimable"`
|
|
DiskVolumesReclaimable int64 `json:"disk_volumes_reclaimable"`
|
|
DiskBuildCacheReclaimable int64 `json:"disk_build_cache_reclaimable"`
|
|
|
|
// DiskTotalBytes is the sum of the category totals.
|
|
DiskTotalBytes int64 `json:"disk_total_bytes"`
|
|
}
|
|
|
|
// GetSystemStats returns a one-shot host-level snapshot. Info and DiskUsage
|
|
// are issued in parallel because DiskUsage walks every layer/volume and is
|
|
// often the slowest call on a busy host (1-3 s); Info typically completes in
|
|
// ~10 ms. Disk usage failures do not fail the whole call — the result
|
|
// degrades gracefully with zero disk fields and a warning log.
|
|
func (c *Client) GetSystemStats(ctx context.Context) (SystemStats, error) {
|
|
stats := SystemStats{Timestamp: time.Now().UTC()}
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
|
|
g.Go(func() error {
|
|
info, err := c.Info(gctx)
|
|
if err != nil {
|
|
return fmt.Errorf("system stats info: %w", err)
|
|
}
|
|
stats.NCPU = info.NCPU
|
|
stats.MemoryTotal = info.MemoryTotal
|
|
stats.Containers = info.Containers
|
|
stats.Running = info.Running
|
|
stats.Paused = info.Paused
|
|
stats.Stopped = info.Stopped
|
|
stats.Images = info.Images
|
|
return nil
|
|
})
|
|
|
|
var du *client.DiskUsageResult
|
|
g.Go(func() error {
|
|
usage, err := c.api.DiskUsage(gctx, client.DiskUsageOptions{
|
|
Containers: true,
|
|
Images: true,
|
|
Volumes: true,
|
|
BuildCache: true,
|
|
})
|
|
if err != nil {
|
|
// Disk usage is best-effort; swallow but log so the dashboard
|
|
// shows zeroed disk fields rather than failing entirely.
|
|
slog.Warn("system stats: disk usage failed", "error", err)
|
|
return nil
|
|
}
|
|
du = &usage
|
|
return nil
|
|
})
|
|
|
|
if err := g.Wait(); err != nil {
|
|
return SystemStats{}, err
|
|
}
|
|
|
|
if du != nil {
|
|
stats.DiskImagesBytes = du.Images.TotalSize
|
|
stats.DiskContainersBytes = du.Containers.TotalSize
|
|
stats.DiskVolumesBytes = du.Volumes.TotalSize
|
|
stats.DiskBuildCacheBytes = du.BuildCache.TotalSize
|
|
stats.DiskImagesReclaimable = du.Images.Reclaimable
|
|
stats.DiskContainersReclaimable = du.Containers.Reclaimable
|
|
stats.DiskVolumesReclaimable = du.Volumes.Reclaimable
|
|
stats.DiskBuildCacheReclaimable = du.BuildCache.Reclaimable
|
|
stats.DiskTotalBytes = stats.DiskImagesBytes +
|
|
stats.DiskContainersBytes +
|
|
stats.DiskVolumesBytes +
|
|
stats.DiskBuildCacheBytes
|
|
}
|
|
|
|
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
|
|
}
|