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:
@@ -101,6 +101,7 @@ type quickDeployRequest struct {
|
|||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Registry string `json:"registry"`
|
Registry string `json:"registry"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
Force bool `json:"force"` // skip duplicate check
|
||||||
}
|
}
|
||||||
|
|
||||||
// quickDeploy handles POST /api/deploy/quick.
|
// quickDeploy handles POST /api/deploy/quick.
|
||||||
@@ -124,6 +125,23 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Name = parts[len(parts)-1]
|
req.Name = parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for existing projects with the same image.
|
||||||
|
if !req.Force {
|
||||||
|
existing, err := s.store.GetProjectsByImage(req.Image)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to check existing projects", "error", err)
|
||||||
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
respondJSON(w, http.StatusConflict, map[string]any{
|
||||||
|
"message": "A project with this image already exists",
|
||||||
|
"existing_projects": existing,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create project.
|
// Create project.
|
||||||
project, err := s.store.CreateProject(store.Project{
|
project, err := s.store.CreateProject(store.Project{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func securityHeaders(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'")
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package events
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,6 +125,58 @@ func (b *Bus) Publish(evt Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PersistFunc is a callback that persists an event log entry.
|
||||||
|
// It receives source, severity, message, and metadata (JSON string).
|
||||||
|
// It returns the persisted entry's ID and created_at timestamp.
|
||||||
|
type PersistFunc func(source, severity, message, metadata string) (int64, string, error)
|
||||||
|
|
||||||
|
// RegisterPersistentLogger subscribes to the bus and auto-persists warn/error
|
||||||
|
// events by calling the provided persist function. It also re-publishes the
|
||||||
|
// persisted event as an EventLog so SSE clients receive it in real-time.
|
||||||
|
// Call the returned function to unsubscribe.
|
||||||
|
func (b *Bus) RegisterPersistentLogger(persist PersistFunc) func() {
|
||||||
|
sub := b.Subscribe(func(evt Event) bool {
|
||||||
|
if evt.Type != EventDeployLog {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p, ok := evt.Payload.(DeployLogPayload)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return p.Level == "warn" || p.Level == "error"
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for evt := range sub {
|
||||||
|
p, ok := evt.Payload.(DeployLogPayload)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metaBytes, _ := json.Marshal(map[string]string{"deploy_id": p.DeployID})
|
||||||
|
metadata := string(metaBytes)
|
||||||
|
id, createdAt, err := persist("deploy", p.Level, p.Message, metadata)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to persist event log", "source", "deploy", "level", p.Level, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Publish(Event{
|
||||||
|
Type: EventLog,
|
||||||
|
Payload: EventLogPayload{
|
||||||
|
ID: id,
|
||||||
|
Source: "deploy",
|
||||||
|
Severity: p.Level,
|
||||||
|
Message: p.Message,
|
||||||
|
Metadata: metadata,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() { b.Unsubscribe(sub) }
|
||||||
|
}
|
||||||
|
|
||||||
// MarshalEvent serializes an event to a JSON string suitable for SSE data lines.
|
// MarshalEvent serializes an event to a JSON string suitable for SSE data lines.
|
||||||
func MarshalEvent(evt Event) (string, error) {
|
func MarshalEvent(evt Event) (string, error) {
|
||||||
data, err := json.Marshal(evt)
|
data, err := json.Marshal(evt)
|
||||||
|
|||||||
@@ -63,6 +63,28 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
|||||||
return projects, rows.Err()
|
return projects, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProjectsByImage returns all projects using the given image, newest first.
|
||||||
|
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at
|
||||||
|
FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query projects by image: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
projects := []Project{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Project
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan project: %w", err)
|
||||||
|
}
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
return projects, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProject updates an existing project's mutable fields.
|
// UpdateProject updates an existing project's mutable fields.
|
||||||
func (s *Store) UpdateProject(p Project) error {
|
func (s *Store) UpdateProject(p Project) error {
|
||||||
p.UpdatedAt = Now()
|
p.UpdatedAt = Now()
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ func (s *Store) runMigrations() error {
|
|||||||
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
||||||
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
|
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
|
||||||
|
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export function quickDeploy(data: {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
registry?: string;
|
registry?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
|
force?: boolean;
|
||||||
}): Promise<{ project: Project; status: string }> {
|
}): Promise<{ project: Project; status: string }> {
|
||||||
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,11 @@
|
|||||||
"noImages": "No images found",
|
"noImages": "No images found",
|
||||||
"loadingImages": "Loading...",
|
"loadingImages": "Loading...",
|
||||||
"imageLoadFailed": "Failed to load images",
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -223,7 +223,11 @@
|
|||||||
"noImages": "Образы не найдены",
|
"noImages": "Образы не найдены",
|
||||||
"loadingImages": "Загрузка...",
|
"loadingImages": "Загрузка...",
|
||||||
"imageLoadFailed": "Не удалось загрузить образы",
|
"imageLoadFailed": "Не удалось загрузить образы",
|
||||||
"lowercaseHint": "Строчные буквы и дефисы"
|
"lowercaseHint": "Строчные буквы и дефисы",
|
||||||
|
"imageAlreadyExists": "Образ уже развёрнут",
|
||||||
|
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
|
||||||
|
"openProject": "Открыть проект \u2192",
|
||||||
|
"createNewAnyway": "Создать новый проект"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.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 { goto } from '$app/navigation';
|
||||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
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';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -68,14 +69,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sseConnection = connectGlobalEvents({
|
// Only connect SSE when authenticated (has a token).
|
||||||
onInstanceStatus(payload) {
|
if (isAuthenticated()) {
|
||||||
instanceStatusStore.update(payload);
|
sseConnection = connectGlobalEvents({
|
||||||
},
|
onInstanceStatus(payload) {
|
||||||
onDeployStatus(payload) {
|
instanceStatusStore.update(payload);
|
||||||
instanceStatusStore.notifyDeploy(payload);
|
},
|
||||||
}
|
onDeployStatus(payload) {
|
||||||
});
|
instanceStatusStore.notifyDeploy(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -112,9 +116,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
|
<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) -->
|
<!-- Close sidebar (mobile) -->
|
||||||
<button
|
<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; }}
|
onclick={() => { sidebarOpen = false; }}
|
||||||
aria-label="Close sidebar"
|
aria-label="Close sidebar"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api';
|
import { inspectImage, quickDeploy, listRegistries, listRegistryImages, deployInstance } from '$lib/api';
|
||||||
import type { InspectResult, EntityPickerItem } 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 EntityPicker from '$lib/components/EntityPicker.svelte';
|
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
||||||
|
|
||||||
let imageUrl = $state('');
|
let imageUrl = $state('');
|
||||||
@@ -22,6 +24,10 @@
|
|||||||
|
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Duplicate detection state
|
||||||
|
let conflictProjects = $state<Project[]>([]);
|
||||||
|
let showConflictDialog = $state(false);
|
||||||
|
|
||||||
// Image picker state
|
// Image picker state
|
||||||
let showImagePicker = $state(false);
|
let showImagePicker = $state(false);
|
||||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||||
@@ -130,28 +136,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeploy() {
|
async function handleDeploy(force = false) {
|
||||||
if (!validateAll()) return;
|
if (!validateAll()) return;
|
||||||
deploying = true;
|
deploying = true;
|
||||||
try {
|
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 }));
|
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
||||||
imageUrl = '';
|
// Redirect to the new project page.
|
||||||
inspected = false;
|
if (result.project?.id) {
|
||||||
inspectResult = null;
|
goto(`/projects/${result.project.id}`);
|
||||||
projectName = '';
|
} else {
|
||||||
port = '';
|
imageUrl = '';
|
||||||
healthcheck = '';
|
inspected = false;
|
||||||
stage = 'dev';
|
inspectResult = null;
|
||||||
subdomain = '';
|
projectName = '';
|
||||||
envVars = '';
|
port = '';
|
||||||
} catch (err) {
|
healthcheck = '';
|
||||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
stage = 'dev';
|
||||||
toasts.error(message);
|
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 {
|
} finally {
|
||||||
deploying = false;
|
deploying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeployToExisting(project: Project) {
|
||||||
|
showConflictDialog = false;
|
||||||
|
conflictProjects = [];
|
||||||
|
goto(`/projects/${project.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForceNewProject() {
|
||||||
|
showConflictDialog = false;
|
||||||
|
conflictProjects = [];
|
||||||
|
await handleDeploy(true);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -253,7 +298,7 @@
|
|||||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick={handleDeploy}
|
onclick={() => handleDeploy()}
|
||||||
disabled={deploying}
|
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"
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 type { BackupInfo } from '$lib/types';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
|
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
|
||||||
@@ -144,14 +145,13 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Auto backup toggle -->
|
<!-- Auto backup toggle -->
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" bind:checked={backupEnabled}
|
<ToggleSwitch bind:checked={backupEnabled} label={$t('settingsBackup.autoBackup')} />
|
||||||
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsBackup.autoBackup')}</span>
|
<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>
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsBackup.autoBackupHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
{#if backupEnabled}
|
{#if backupEnabled}
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7">
|
||||||
|
|||||||
Reference in New Issue
Block a user