diff --git a/internal/api/docker.go b/internal/api/docker.go index 27c504f..9e745d0 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -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) { diff --git a/internal/api/router.go b/internal/api/router.go index b92461f..0e38c49 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/settings.go b/internal/api/settings.go index a743ec3..71f147b 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -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 } diff --git a/internal/store/models.go b/internal/store/models.go index 70fee10..495a2ae 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -74,6 +74,7 @@ type Settings struct { TraefikCertResolver string `json:"traefik_cert_resolver"` TraefikNetwork string `json:"traefik_network"` TraefikAPIURL string `json:"traefik_api_url"` + ImagePruneThresholdMB int `json:"image_prune_threshold_mb"` BackupEnabled bool `json:"backup_enabled"` BackupIntervalHours int `json:"backup_interval_hours"` BackupRetentionCount int `json:"backup_retention_count"` diff --git a/internal/store/settings.go b/internal/store/settings.go index 0297047..d244755 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -16,6 +16,7 @@ func (s *Store) GetSettings() (Settings, error) { cloudflare_api_token, cloudflare_zone_id, npm_remote, npm_access_list_id, proxy_provider, traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url, + image_prune_threshold_mb, backup_enabled, backup_interval_hours, backup_retention_count, updated_at FROM settings WHERE id = 1`, @@ -26,6 +27,7 @@ func (s *Store) GetSettings() (Settings, error) { &st.CloudflareAPIToken, &st.CloudflareZoneID, &npmRemote, &st.NpmAccessListID, &st.ProxyProvider, &st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL, + &st.ImagePruneThresholdMB, &backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount, &st.UpdatedAt) if err != nil { @@ -61,6 +63,7 @@ func (s *Store) UpdateSettings(st Settings) error { cloudflare_api_token=?, cloudflare_zone_id=?, npm_remote=?, npm_access_list_id=?, proxy_provider=?, traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?, + image_prune_threshold_mb=?, backup_enabled=?, backup_interval_hours=?, backup_retention_count=?, updated_at=? WHERE id = 1`, @@ -71,6 +74,7 @@ func (s *Store) UpdateSettings(st Settings) error { st.CloudflareAPIToken, st.CloudflareZoneID, npmRemote, st.NpmAccessListID, st.ProxyProvider, st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL, + st.ImagePruneThresholdMB, backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount, st.UpdatedAt, ) diff --git a/internal/store/store.go b/internal/store/store.go index 8add91f..af38d83 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -121,6 +121,8 @@ func (s *Store) runMigrations() error { `ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`, // Separate public IP for DNS A records. `ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`, + // Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled. + `ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`, } for _, m := range migrations { @@ -225,6 +227,7 @@ CREATE TABLE IF NOT EXISTS settings ( base_volume_path TEXT NOT NULL DEFAULT '', ssl_certificate_id INTEGER NOT NULL DEFAULT 0, npm_remote INTEGER NOT NULL DEFAULT 0, + image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024, npm_access_list_id INTEGER NOT NULL DEFAULT 0, traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure', traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c6a382f..71a62ea 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -289,6 +289,12 @@ export function listProjectImages(projectId: string): Promise { return get(`/api/projects/${projectId}/images`); } +export function getUnusedImageStats(): Promise<{ + total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean; +}> { + return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images'); +} + export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> { return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images'); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 7484ec0..2b570dc 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -30,7 +30,9 @@ "noProjects": "No projects yet.", "addFirst": "Add your first project", "loadFailed": "Failed to load dashboard", - "staleContainers": "Stale Containers" + "staleContainers": "Stale Containers", + "unusedImagesWarning": "Unused Docker images are taking up disk space", + "unusedImages": "unused images" }, "projects": { "title": "Projects", @@ -257,6 +259,8 @@ "staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale.", "dockerCleanup": "Docker Image Cleanup", "dockerCleanupHelp": "Remove unused Docker images belonging to your projects. Only images not used by active instances are removed.", + "pruneThreshold": "Warning Threshold (MB)", + "pruneThresholdHelp": "Show dashboard warning when unused project images exceed this size. 0 = disabled.", "pruneImages": "Prune Unused Images", "pruning": "Pruning...", "pruneResult": "Removed {count} images, reclaimed {mb} MB", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index dd5e6ab..dd2f5b1 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -30,7 +30,9 @@ "noProjects": "Проектов пока нет.", "addFirst": "Добавьте первый проект", "loadFailed": "Не удалось загрузить панель", - "staleContainers": "Устаревшие контейнеры" + "staleContainers": "Устаревшие контейнеры", + "unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство", + "unusedImages": "неиспользуемых образов" }, "projects": { "title": "Проекты", @@ -257,6 +259,8 @@ "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.", "dockerCleanup": "Очистка Docker-образов", "dockerCleanupHelp": "Удаление неиспользуемых Docker-образов ваших проектов. Удаляются только образы, не используемые активными экземплярами.", + "pruneThreshold": "Порог предупреждения (МБ)", + "pruneThresholdHelp": "Показывать предупреждение на дашборде, когда неиспользуемые образы превышают этот размер. 0 = отключено.", "pruneImages": "Очистить неиспользуемые образы", "pruning": "Очистка...", "pruneResult": "Удалено {count} образов, освобождено {mb} МБ", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4500899..42c7e8f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -121,6 +121,7 @@ export interface Settings { dns_provider: string; has_cloudflare_api_token: boolean; cloudflare_zone_id: string; + image_prune_threshold_mb: number; proxy_provider: string; traefik_entrypoint: string; traefik_cert_resolver: string; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 61d9c45..e3b0465 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -11,6 +11,9 @@ let projects = $state([]); let instancesByProject = $state>({}); let staleContainers = $state([]); + let unusedImagesMB = $state(0); + let unusedImagesCount = $state(0); + let unusedImagesExceeded = $state(false); let loading = $state(true); let error = $state(''); @@ -43,6 +46,13 @@ } instancesByProject = mapped; staleContainers = staleResult; + + try { + const imgStats = await api.getUnusedImageStats(); + unusedImagesMB = imgStats.total_size_mb; + unusedImagesCount = imgStats.count; + unusedImagesExceeded = imgStats.exceeded; + } catch { /* non-critical */ } } catch (e) { error = e instanceof Error ? e.message : $t('dashboard.loadFailed'); } finally { @@ -125,6 +135,20 @@ + + {#if unusedImagesExceeded} + +
+ +
+
+

{$t('dashboard.unusedImagesWarning')}

+

{unusedImagesCount} {$t('dashboard.unusedImages')} · {unusedImagesMB >= 1024 ? (unusedImagesMB / 1024).toFixed(1) + ' GB' : unusedImagesMB + ' MB'}

+
+ {$t('settings.pruneImages')} → +
+ {/if} + diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 61c4ed8..93382c4 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -23,6 +23,7 @@ let baseVolumePath = $state(''); let notificationUrl = $state(''); let staleThresholdDays = $state('7'); + let imagePruneThresholdMb = $state('1024'); // Proxy provider state. @@ -128,6 +129,7 @@ baseVolumePath = settings.base_volume_path ?? ''; notificationUrl = settings.notification_url ?? ''; staleThresholdDays = String(settings.stale_threshold_days ?? 7); + imagePruneThresholdMb = String(settings.image_prune_threshold_mb ?? 1024); proxyProvider = settings.proxy_provider ?? 'npm'; wildcardDns = settings.wildcard_dns ?? true; dnsProvider = settings.dns_provider ?? ''; @@ -157,6 +159,7 @@ base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), proxy_provider: proxyProvider, stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7), + image_prune_threshold_mb: Math.max(0, parseInt(String(imagePruneThresholdMb), 10) || 0), wildcard_dns: wildcardDns, dns_provider: wildcardDns ? '' : dnsProvider, cloudflare_zone_id: cloudflareZoneId @@ -363,6 +366,16 @@

{$t('settings.dockerCleanup')}

{$t('settings.dockerCleanupHelp')}

+
+ +