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:
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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, " ")
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Нет",
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user