feat: project-scoped Docker image prune, conflict fix, deploy toggle, access list picker

- Image prune only removes images matching project image refs, skips active instances
- Add ListImagesByRef and RemoveImage to Docker client
- Fix 409 conflict: use listProjects instead of duplicate POST
- Add "Deploy immediately" toggle to Quick Deploy (off by default)
- Replace raw access list ID with EntityPicker on project edit form
- Trigger proxy resync on access list change
- Fix stage form layout: single responsive row
- Fix empty port default on project creation
- Improve inspect error message for remote Docker
This commit is contained in:
2026-04-05 13:49:20 +03:00
parent a830378c5b
commit 5577851f22
9 changed files with 191 additions and 17 deletions
+89
View File
@@ -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),
})
}
+3
View File
@@ -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)
+39
View File
@@ -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, " ")