feat: configurable unused images threshold with dashboard warning

- Add image_prune_threshold_mb setting (default 1024 MB)
- Add GET /api/docker/unused-images endpoint returning unused image count, size, and threshold status
- Dashboard shows amber warning banner when unused project images exceed threshold
- Banner links to settings page for pruning, shows count and human-readable size (MB/GB)
- Threshold configurable in Docker Image Cleanup section of settings
- DB migration + schema for image_prune_threshold_mb
This commit is contained in:
2026-04-05 14:34:48 +03:00
parent 0ddad87a9a
commit b0816502bf
12 changed files with 137 additions and 2 deletions
+69
View File
@@ -150,6 +150,75 @@ func sanitizeDockerLogLine(line string) string {
return line
}
// unusedImageStats handles GET /api/docker/unused-images.
// Returns the total size of unused project images and whether the threshold is exceeded.
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
if s.docker == nil {
respondJSON(w, http.StatusOK, map[string]any{
"total_size_mb": 0, "count": 0, "threshold_mb": 0, "exceeded": false,
})
return
}
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("unused images: get settings", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
projects, err := s.store.GetAllProjects()
if err != nil {
slog.Error("unused images: list projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Build set of active image refs.
activeImages := make(map[string]bool)
for _, p := range projects {
stages, _ := s.store.GetStagesByProjectID(p.ID)
for _, st := range stages {
instances, _ := s.store.GetInstancesByStageID(st.ID)
for _, inst := range instances {
if inst.ImageTag != "" {
activeImages[p.Image+":"+inst.ImageTag] = true
}
}
}
}
// Sum unused image sizes.
ctx := r.Context()
var totalSize int64
var count int
for _, p := range projects {
if p.Image == "" {
continue
}
images, err := s.docker.ListImagesByRef(ctx, p.Image)
if err != nil {
continue
}
for _, img := range images {
if !activeImages[img.Ref] {
totalSize += img.Size
count++
}
}
}
totalMB := totalSize / (1024 * 1024)
exceeded := settings.ImagePruneThresholdMB > 0 && int(totalMB) >= settings.ImagePruneThresholdMB
respondJSON(w, http.StatusOK, map[string]any{
"total_size_mb": totalMB,
"count": count,
"threshold_mb": settings.ImagePruneThresholdMB,
"exceeded": exceeded,
})
}
// pruneImages handles POST /api/docker/prune-images.
// Only removes images that belong to Docker Watcher projects (not all system images).
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -198,6 +198,7 @@ func (s *Server) Router() chi.Router {
r.Get("/auth/me", s.currentUser)
r.Post("/auth/logout", s.logout)
r.Get("/proxies", s.listProxyRoutes)
r.Get("/docker/unused-images", s.unusedImageStats)
r.Get("/projects", s.listProjects)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
+5
View File
@@ -37,6 +37,7 @@ type settingsRequest struct {
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
ImagePruneThresholdMB *int `json:"image_prune_threshold_mb,omitempty"`
NpmRemote *bool `json:"npm_remote,omitempty"`
ProxyProvider *string `json:"proxy_provider,omitempty"`
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
@@ -68,6 +69,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"npm_remote": settings.NpmRemote,
"image_prune_threshold_mb": settings.ImagePruneThresholdMB,
"npm_access_list_id": settings.NpmAccessListID,
"polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID,
@@ -195,6 +197,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
}
updated.ProxyProvider = prov
}
if req.ImagePruneThresholdMB != nil {
updated.ImagePruneThresholdMB = *req.ImagePruneThresholdMB
}
if req.NpmRemote != nil {
updated.NpmRemote = *req.NpmRemote
}