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
+2 -1
View File
@@ -11,7 +11,8 @@
"Bash(git checkout:*)", "Bash(git checkout:*)",
"Bash(git stash:*)", "Bash(git stash:*)",
"Bash(echo \"EXIT: $?\")", "Bash(echo \"EXIT: $?\")",
"Bash(./scripts/dev-server.sh)" "Bash(./scripts/dev-server.sh)",
"Bash(go doc:*)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"C:\\Users\\Alexei\\Documents\\docker-watcher\\internal", "C:\\Users\\Alexei\\Documents\\docker-watcher\\internal",
+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.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
// Docker management.
r.Post("/docker/prune-images", s.pruneImages)
// NPM connection test. // NPM connection test.
r.Post("/settings/npm/test", s.testNpmConnection) 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 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. // joinArgs joins string arguments with spaces.
func joinArgs(args []string) string { func joinArgs(args []string) string {
return strings.Join(args, " ") return strings.Join(args, " ")
+6
View File
@@ -276,6 +276,12 @@ export function listProxyRoutes(): Promise<ProxyRoute[]> {
return get<ProxyRoute[]>('/api/proxies'); return get<ProxyRoute[]>('/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 }> { 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); return post<{ status: string }>('/api/settings/npm/test', data);
} }
+6
View File
@@ -250,6 +250,12 @@
"appearance": "Appearance", "appearance": "Appearance",
"staleThreshold": "Stale threshold (days)", "staleThreshold": "Stale threshold (days)",
"staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale.", "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", "proxyProvider": "Proxy Provider",
"proxyProviderHelp": "Select how reverse proxy routes are managed for deployed containers.", "proxyProviderHelp": "Select how reverse proxy routes are managed for deployed containers.",
"proxyNone": "None", "proxyNone": "None",
+6
View File
@@ -250,6 +250,12 @@
"appearance": "Внешний вид", "appearance": "Внешний вид",
"staleThreshold": "Порог устаревания (дни)", "staleThreshold": "Порог устаревания (дни)",
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.", "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.",
"dockerCleanup": "Очистка Docker-образов",
"dockerCleanupHelp": "Удаление неиспользуемых Docker-образов ваших проектов. Удаляются только образы, не используемые активными экземплярами.",
"pruneImages": "Очистить неиспользуемые образы",
"pruning": "Очистка...",
"pruneResult": "Удалено {count} образов, освобождено {mb} МБ",
"pruneFailed": "Не удалось очистить образы",
"proxyProvider": "Провайдер прокси", "proxyProvider": "Провайдер прокси",
"proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.", "proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.",
"proxyNone": "Нет", "proxyNone": "Нет",
+9 -15
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { inspectImage, quickDeploy, listRegistries, listRegistryImages, deployInstance } from '$lib/api'; import { inspectImage, quickDeploy, listProjects, listRegistries, listRegistryImages } from '$lib/api';
import type { InspectResult, EntityPickerItem, Project } from '$lib/types'; import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -162,20 +162,14 @@
// Handle 409 Conflict — existing project with same image. // Handle 409 Conflict — existing project with same image.
if (err instanceof Error && 'status' in err && (err as any).status === 409) { if (err instanceof Error && 'status' in err && (err as any).status === 409) {
try { try {
// The error message contains the JSON response from the server. // Find existing projects with the same image.
// Re-fetch existing projects for this image. const allProjects = await listProjects();
const res = await fetch(`/api/deploy/quick`, { const imageBase = imageUrl.trim().split(':')[0];
method: 'POST', const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim());
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, if (matching.length > 0) {
body: JSON.stringify({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) }) conflictProjects = matching;
}); showConflictDialog = true;
if (res.status === 409) { return;
const envelope = await res.json();
if (envelope.data?.existing_projects) {
conflictProjects = envelope.data.existing_projects;
showConflictDialog = true;
return;
}
} }
} catch { /* fall through */ } } catch { /* fall through */ }
toasts.error($t('quickDeploy.imageAlreadyExists')); toasts.error($t('quickDeploy.imageAlreadyExists'));
+31 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, testDnsConnection, listDnsZones } from '$lib/api'; import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, testDnsConnection, listDnsZones, pruneImages } from '$lib/api';
import type { EntityPickerItem } from '$lib/types'; import type { EntityPickerItem } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -38,8 +38,19 @@
let zoneName = $state(''); let zoneName = $state('');
let testingDns = $state(false); let testingDns = $state(false);
let pruning = $state(false);
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
async function handlePruneImages() {
pruning = true;
try {
const result = await pruneImages();
toasts.success($t('settings.pruneResult', { count: String(result.images_removed), mb: String(result.space_reclaimed_mb) }));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settings.pruneFailed'));
} finally { pruning = false; }
}
// Convert Go duration string (e.g., "5m", "300s", "1h") to seconds string. // Convert Go duration string (e.g., "5m", "300s", "1h") to seconds string.
function parseDurationToSeconds(dur: string): string { function parseDurationToSeconds(dur: string): string {
if (!dur) return '60'; if (!dur) return '60';
@@ -343,6 +354,25 @@
</div> </div>
</div> </div>
<!-- Docker Image Cleanup -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h3 class="mb-1 text-sm font-semibold text-[var(--text-primary)]">{$t('settings.dockerCleanup')}</h3>
<p class="mb-3 text-xs text-[var(--text-tertiary)]">{$t('settings.dockerCleanupHelp')}</p>
<button
type="button"
onclick={handlePruneImages}
disabled={pruning}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
>
{#if pruning}
<IconLoader size={16} />
{$t('settings.pruning')}
{:else}
{$t('settings.pruneImages')}
{/if}
</button>
</div>
<!-- DNS Configuration --> <!-- DNS Configuration -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4"> <div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.dnsConfig')}</h3> <h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.dnsConfig')}</h3>