package api import ( "errors" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" ) // listProjectImages handles GET /api/projects/{id}/images. // Returns all local Docker images matching the project's image reference. func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") project, err := s.store.GetProjectByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } slog.Error("failed to get project", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } if s.docker == nil || project.Image == "" { respondJSON(w, http.StatusOK, []any{}) return } images, err := s.docker.ListImagesByRef(r.Context(), project.Image) if err != nil { slog.Warn("list project images", "project", project.Name, "error", err) respondJSON(w, http.StatusOK, []any{}) return } respondJSON(w, http.StatusOK, images) } // 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), }) }