fix: quick deploy duplicate detection, logout UX, backup toggle, CSP, SSE guard, and migration
- Detect existing projects with same image on quick deploy; show conflict dialog with options - Move logout button to sidebar header as icon-only - Replace backup checkbox with ToggleSwitch component - Allow unsafe-inline in CSP script-src for SvelteKit hydration - Guard SSE connection behind isAuthenticated() check - Add notification_url ALTER TABLE migration for existing databases - Restore RegisterPersistentLogger on event bus
This commit is contained in:
@@ -215,6 +215,7 @@ export function quickDeploy(data: {
|
||||
tag?: string;
|
||||
registry?: string;
|
||||
port?: number;
|
||||
force?: boolean;
|
||||
}): Promise<{ project: Project; status: string }> {
|
||||
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
||||
}
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noImages": "No images found",
|
||||
"loadingImages": "Loading...",
|
||||
"imageLoadFailed": "Failed to load images",
|
||||
"lowercaseHint": "Lowercase with hyphens"
|
||||
"lowercaseHint": "Lowercase with hyphens",
|
||||
"imageAlreadyExists": "Image already deployed",
|
||||
"conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
|
||||
"openProject": "Open project \u2192",
|
||||
"createNewAnyway": "Create New Project"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -223,7 +223,11 @@
|
||||
"noImages": "Образы не найдены",
|
||||
"loadingImages": "Загрузка...",
|
||||
"imageLoadFailed": "Не удалось загрузить образы",
|
||||
"lowercaseHint": "Строчные буквы и дефисы"
|
||||
"lowercaseHint": "Строчные буквы и дефисы",
|
||||
"imageAlreadyExists": "Образ уже развёрнут",
|
||||
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
|
||||
"openProject": "Открыть проект \u2192",
|
||||
"createNewAnyway": "Создать новый проект"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX } from '$lib/components/icons';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken } from '$lib/auth';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
import { logout as apiLogout } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -68,14 +69,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
sseConnection = connectGlobalEvents({
|
||||
onInstanceStatus(payload) {
|
||||
instanceStatusStore.update(payload);
|
||||
},
|
||||
onDeployStatus(payload) {
|
||||
instanceStatusStore.notifyDeploy(payload);
|
||||
}
|
||||
});
|
||||
// Only connect SSE when authenticated (has a token).
|
||||
if (isAuthenticated()) {
|
||||
sseConnection = connectGlobalEvents({
|
||||
onInstanceStatus(payload) {
|
||||
instanceStatusStore.update(payload);
|
||||
},
|
||||
onDeployStatus(payload) {
|
||||
instanceStatusStore.notifyDeploy(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -112,9 +116,24 @@
|
||||
</div>
|
||||
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
|
||||
|
||||
<!-- Logout button -->
|
||||
<button
|
||||
type="button"
|
||||
title={$t('nav.logout')}
|
||||
aria-label={$t('nav.logout')}
|
||||
onclick={async () => {
|
||||
try { await apiLogout(); } catch { /* best effort */ }
|
||||
clearAuth();
|
||||
goto('/login');
|
||||
}}
|
||||
class="ml-auto rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
||||
>
|
||||
<IconLogout size={18} />
|
||||
</button>
|
||||
|
||||
<!-- Close sidebar (mobile) -->
|
||||
<button
|
||||
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
|
||||
class="rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
|
||||
onclick={() => { sidebarOpen = false; }}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api';
|
||||
import type { InspectResult, EntityPickerItem } from '$lib/types';
|
||||
import { inspectImage, quickDeploy, listRegistries, listRegistryImages, deployInstance } from '$lib/api';
|
||||
import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
||||
|
||||
let imageUrl = $state('');
|
||||
@@ -22,6 +24,10 @@
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
// Duplicate detection state
|
||||
let conflictProjects = $state<Project[]>([]);
|
||||
let showConflictDialog = $state(false);
|
||||
|
||||
// Image picker state
|
||||
let showImagePicker = $state(false);
|
||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||
@@ -130,28 +136,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
async function handleDeploy(force = false) {
|
||||
if (!validateAll()) return;
|
||||
deploying = true;
|
||||
try {
|
||||
await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) });
|
||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force });
|
||||
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
||||
imageUrl = '';
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
healthcheck = '';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
||||
toasts.error(message);
|
||||
// Redirect to the new project page.
|
||||
if (result.project?.id) {
|
||||
goto(`/projects/${result.project.id}`);
|
||||
} else {
|
||||
imageUrl = '';
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
healthcheck = '';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
toasts.error($t('quickDeploy.imageAlreadyExists'));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
||||
toasts.error(message);
|
||||
}
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeployToExisting(project: Project) {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
goto(`/projects/${project.id}`);
|
||||
}
|
||||
|
||||
async function handleForceNewProject() {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
await handleDeploy(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -253,7 +298,7 @@
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDeploy}
|
||||
onclick={() => handleDeploy()}
|
||||
disabled={deploying}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
@@ -276,3 +321,60 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conflict dialog: image already deployed -->
|
||||
{#if showConflictDialog}
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={() => { showConflictDialog = false; }}></div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="conflict-dialog-title"
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showConflictDialog = false; }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in"
|
||||
>
|
||||
<h3 id="conflict-dialog-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{$t('quickDeploy.imageAlreadyExists')}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{$t('quickDeploy.conflictDescription')}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each conflictProjects as project (project.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeployToExisting(project)}
|
||||
class="flex w-full items-center justify-between rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-3 text-left hover:border-[var(--color-brand-500)] transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{project.name}</span>
|
||||
<span class="ml-2 text-xs text-[var(--text-tertiary)]">{project.image}</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.openProject')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={() => { showConflictDialog = false; }}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
||||
onclick={handleForceNewProject}
|
||||
>
|
||||
{$t('quickDeploy.createNewAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { BackupInfo } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
|
||||
@@ -144,14 +145,13 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Auto backup toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={backupEnabled}
|
||||
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div class="flex items-center gap-3">
|
||||
<ToggleSwitch bind:checked={backupEnabled} label={$t('settingsBackup.autoBackup')} />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsBackup.autoBackup')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsBackup.autoBackupHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if backupEnabled}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7">
|
||||
|
||||
Reference in New Issue
Block a user