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
+6
View File
@@ -276,6 +276,12 @@ export function listProxyRoutes(): Promise<ProxyRoute[]> {
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 }> {
return post<{ status: string }>('/api/settings/npm/test', data);
}
+6
View File
@@ -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",
+6
View File
@@ -250,6 +250,12 @@
"appearance": "Внешний вид",
"staleThreshold": "Порог устаревания (дни)",
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.",
"dockerCleanup": "Очистка Docker-образов",
"dockerCleanupHelp": "Удаление неиспользуемых Docker-образов ваших проектов. Удаляются только образы, не используемые активными экземплярами.",
"pruneImages": "Очистить неиспользуемые образы",
"pruning": "Очистка...",
"pruneResult": "Удалено {count} образов, освобождено {mb} МБ",
"pruneFailed": "Не удалось очистить образы",
"proxyProvider": "Провайдер прокси",
"proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.",
"proxyNone": "Нет",
+9 -15
View File
@@ -1,5 +1,5 @@
<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 FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -162,20 +162,14 @@
// Handle 409 Conflict — existing project with same image.
if (err instanceof Error && 'status' in err && (err as any).status === 409) {
try {
// The error message contains the JSON response from the server.
// Re-fetch existing projects for this image.
const res = await fetch(`/api/deploy/quick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
body: JSON.stringify({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) })
});
if (res.status === 409) {
const envelope = await res.json();
if (envelope.data?.existing_projects) {
conflictProjects = envelope.data.existing_projects;
showConflictDialog = true;
return;
}
// Find existing projects with the same image.
const allProjects = await listProjects();
const imageBase = imageUrl.trim().split(':')[0];
const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim());
if (matching.length > 0) {
conflictProjects = matching;
showConflictDialog = true;
return;
}
} catch { /* fall through */ }
toasts.error($t('quickDeploy.imageAlreadyExists'));
+31 -1
View File
@@ -1,5 +1,5 @@
<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 FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -38,8 +38,19 @@
let zoneName = $state('');
let testingDns = $state(false);
let pruning = $state(false);
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.
function parseDurationToSeconds(dur: string): string {
if (!dur) return '60';
@@ -343,6 +354,25 @@
</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 -->
<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>