diff --git a/.claude/settings.json b/.claude/settings.json index 1ddf0a2..95f68d5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,7 +11,8 @@ "Bash(git checkout:*)", "Bash(git stash:*)", "Bash(echo \"EXIT: $?\")", - "Bash(./scripts/dev-server.sh)" + "Bash(./scripts/dev-server.sh)", + "Bash(go doc:*)" ], "additionalDirectories": [ "C:\\Users\\Alexei\\Documents\\docker-watcher\\internal", diff --git a/internal/api/docker.go b/internal/api/docker.go new file mode 100644 index 0000000..4f5fcda --- /dev/null +++ b/internal/api/docker.go @@ -0,0 +1,89 @@ +package api + +import ( + "log/slog" + "net/http" +) + +// 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) { + if s.docker == nil { + respondError(w, http.StatusServiceUnavailable, "Docker is not available") + return + } + + // Collect all image references from our projects. + projects, err := s.store.GetAllProjects() + if err != nil { + slog.Error("prune: failed to list projects", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + // Build a set of image refs used by active instances. + 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 + } + } + } + } + + // Collect all unique image bases from projects (without tags). + projectImages := make(map[string]bool) + for _, p := range projects { + if p.Image != "" { + projectImages[p.Image] = true + } + } + + if len(projectImages) == 0 { + respondJSON(w, http.StatusOK, map[string]any{ + "images_removed": 0, + "space_reclaimed_mb": 0, + "message": "No project images to clean up", + }) + return + } + + // List all local Docker images and find ones matching our projects but not actively used. + ctx := r.Context() + removed := 0 + var reclaimedBytes int64 + + for imageBase := range projectImages { + // List all tags for this image. + images, err := s.docker.ListImagesByRef(ctx, imageBase) + if err != nil { + slog.Warn("prune: list images", "image", imageBase, "error", err) + continue + } + + for _, img := range images { + // Skip images that are actively used by running instances. + if activeImages[img.Ref] { + continue + } + + // Remove unused image. + if err := s.docker.RemoveImage(ctx, img.ID); err != nil { + slog.Warn("prune: remove image", "image", img.Ref, "error", err) + continue + } + removed++ + reclaimedBytes += img.Size + slog.Info("prune: removed image", "ref", img.Ref, "size_mb", img.Size/(1024*1024)) + } + } + + respondJSON(w, http.StatusOK, map[string]any{ + "images_removed": removed, + "space_reclaimed_mb": reclaimedBytes / (1024 * 1024), + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 33dae15..698cc0d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -308,6 +308,9 @@ func (s *Server) Router() chi.Router { r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) + // Docker management. + r.Post("/docker/prune-images", s.pruneImages) + // NPM connection test. r.Post("/settings/npm/test", s.testNpmConnection) diff --git a/internal/docker/image.go b/internal/docker/image.go index da47584..bb5ece3 100644 --- a/internal/docker/image.go +++ b/internal/docker/image.go @@ -113,6 +113,45 @@ func EncodeRegistryAuth(username, password, serverAddress string) (string, error return base64.URLEncoding.EncodeToString(data), nil } +// LocalImage represents a Docker image on the local machine. +type LocalImage struct { + ID string `json:"id"` + Ref string `json:"ref"` // e.g., "registry/org/app:tag" + Size int64 `json:"size"` // bytes +} + +// ListImagesByRef returns all local images matching a given image reference prefix. +// For example, "registry.example.com/org/app" matches all tags of that image. +func (c *Client) ListImagesByRef(ctx context.Context, imageBase string) ([]LocalImage, error) { + result, err := c.api.ImageList(ctx, client.ImageListOptions{}) + if err != nil { + return nil, fmt.Errorf("list images: %w", err) + } + + var images []LocalImage + for _, img := range result.Items { + for _, tag := range img.RepoTags { + if strings.HasPrefix(tag, imageBase+":") || tag == imageBase { + images = append(images, LocalImage{ + ID: img.ID, + Ref: tag, + Size: img.Size, + }) + } + } + } + return images, nil +} + +// RemoveImage removes a single Docker image by reference (name:tag or ID). +func (c *Client) RemoveImage(ctx context.Context, imageRef string) error { + _, err := c.api.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{PruneChildren: true}) + if err != nil { + return fmt.Errorf("remove image %s: %w", imageRef, err) + } + return nil +} + // joinArgs joins string arguments with spaces. func joinArgs(args []string) string { return strings.Join(args, " ") diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5fee1a5..c86fcb0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -276,6 +276,12 @@ export function listProxyRoutes(): Promise { return get('/api/proxies'); } +// ── Docker Management ────────────────────────────────────────────── + +export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> { + return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images'); +} + 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 df539b2..d19cf54 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -250,6 +250,12 @@ "appearance": "Appearance", "staleThreshold": "Stale threshold (days)", "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.", + "pruneImages": "Prune Unused Images", + "pruning": "Pruning...", + "pruneResult": "Removed {count} images, reclaimed {mb} MB", + "pruneFailed": "Failed to prune images", "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 2cb0565..41876ef 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -250,6 +250,12 @@ "appearance": "Внешний вид", "staleThreshold": "Порог устаревания (дни)", "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.", + "dockerCleanup": "Очистка Docker-образов", + "dockerCleanupHelp": "Удаление неиспользуемых Docker-образов ваших проектов. Удаляются только образы, не используемые активными экземплярами.", + "pruneImages": "Очистить неиспользуемые образы", + "pruning": "Очистка...", + "pruneResult": "Удалено {count} образов, освобождено {mb} МБ", + "pruneFailed": "Не удалось очистить образы", "proxyProvider": "Провайдер прокси", "proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.", "proxyNone": "Нет", diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index 01517a7..76268da 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -1,5 +1,5 @@