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"`
|
||||
Registry string `json:"registry"`
|
||||
Port int `json:"port"`
|
||||
Force bool `json:"force"` // skip duplicate check
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
|
||||
// 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.
|
||||
project, err := s.store.CreateProject(store.Project{
|
||||
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-Frame-Options", "DENY")
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"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.
|
||||
func MarshalEvent(evt Event) (string, error) {
|
||||
data, err := json.Marshal(evt)
|
||||
|
||||
@@ -63,6 +63,28 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
||||
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.
|
||||
func (s *Store) UpdateProject(p Project) error {
|
||||
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_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
||||
`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 {
|
||||
|
||||
@@ -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