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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -250,6 +250,12 @@
|
||||
"appearance": "Внешний вид",
|
||||
"staleThreshold": "Порог устаревания (дни)",
|
||||
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.",
|
||||
"dockerCleanup": "Очистка Docker-образов",
|
||||
"dockerCleanupHelp": "Удаление неиспользуемых Docker-образов ваших проектов. Удаляются только образы, не используемые активными экземплярами.",
|
||||
"pruneImages": "Очистить неиспользуемые образы",
|
||||
"pruning": "Очистка...",
|
||||
"pruneResult": "Удалено {count} образов, освобождено {mb} МБ",
|
||||
"pruneFailed": "Не удалось очистить образы",
|
||||
"proxyProvider": "Провайдер прокси",
|
||||
"proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.",
|
||||
"proxyNone": "Нет",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user