feat(docker-watcher): phase 14 - frontend polish & modern UI

Design system with CSS custom properties (light/dark themes).
38 Lucide SVG icon components. Dark mode with system preference.
EN/RU localization with i18n store. Skeleton loaders, empty states,
toggle switches, micro-interactions. Responsive sidebar with
mobile hamburger menu. All pages polished with consistent styling.
This commit is contained in:
2026-03-27 23:53:09 +03:00
parent d4659146fc
commit a3aa5912d9
74 changed files with 2954 additions and 1750 deletions
+107 -36
View File
@@ -4,8 +4,13 @@
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
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 { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { t } from '$lib/i18n';
interface Props {
children: Snippet;
@@ -14,10 +19,10 @@
const { children }: Props = $props();
const navItems = [
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
{ href: '/projects', label: 'Projects', icon: 'projects' },
{ href: '/deploy', label: 'Deploy', icon: 'deploy' },
{ href: '/settings', label: 'Settings', icon: 'settings' }
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
] as const;
function isActive(href: string, pathname: string): boolean {
@@ -26,6 +31,27 @@
}
let sseConnection: SSEConnection | null = null;
let sidebarOpen = $state(false);
// Apply theme reactively.
$effect(() => {
applyTheme($resolvedTheme);
});
// Listen for system theme changes when in "system" mode.
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme($resolvedTheme);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
// Close sidebar on route change (mobile).
$effect(() => {
void $page.url.pathname;
sidebarOpen = false;
});
onMount(() => {
sseConnection = connectGlobalEvents({
@@ -44,59 +70,104 @@
});
</script>
<div class="flex h-screen bg-gray-50">
<div class="flex h-screen overflow-hidden bg-[var(--surface-page)]">
<!-- Mobile overlay -->
{#if sidebarOpen}
<div
class="fixed inset-0 z-40 bg-[var(--surface-overlay)] lg:hidden animate-fade-in"
role="presentation"
onclick={() => { sidebarOpen = false; }}
></div>
{/if}
<!-- Sidebar -->
<aside class="flex w-64 flex-col border-r border-gray-200 bg-white">
<div class="flex h-16 items-center gap-2 border-b border-gray-200 px-6">
<svg class="h-7 w-7 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
<span class="text-lg font-bold text-gray-900">Docker Watcher</span>
<aside
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-[var(--border-primary)] bg-[var(--surface-sidebar)] transition-transform duration-300 lg:static lg:translate-x-0
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"
>
<!-- Logo -->
<div class="flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
<svg class="h-4.5 w-4.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
</div>
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<!-- Close sidebar (mobile) -->
<button
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar"
>
<IconX size={20} />
</button>
</div>
<nav class="flex-1 space-y-1 px-3 py-4">
<!-- Navigation -->
<nav class="flex-1 space-y-0.5 px-3 py-3">
{#each navItems as item}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition {active
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}"
class="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
{active
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
>
{#if item.icon === 'dashboard'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'projects'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'deploy'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
</svg>
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'settings'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{/if}
{$t(item.labelKey)}
{#if active}
<div class="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--color-brand-600)]"></div>
{/if}
{item.label}
</a>
{/each}
</nav>
<div class="border-t border-gray-200 px-6 py-3">
<p class="text-xs text-gray-400">Docker Watcher v0.1</p>
<!-- Footer controls -->
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
<div class="flex items-center justify-between">
<ThemeToggle />
<LocaleSwitcher />
</div>
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-6 py-8">
{@render children()}
</div>
</main>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar (mobile) -->
<header class="flex h-14 items-center gap-3 border-b border-[var(--border-primary)] bg-[var(--surface-sidebar)] px-4 lg:hidden">
<button
class="rounded-md p-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
onclick={() => { sidebarOpen = true; }}
aria-label="Open sidebar"
>
<IconMenu size={22} />
</button>
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
<svg class="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
</div>
<span class="text-sm font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
{@render children()}
</div>
</main>
</div>
</div>
<Toast />
+75 -54
View File
@@ -2,6 +2,10 @@
import type { Project, Instance } from '$lib/types';
import * as api from '$lib/api';
import ProjectCard from '$lib/components/ProjectCard.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { IconDeploy, IconBox, IconServer, IconAlert } from '$lib/components/icons';
import { t } from '$lib/i18n';
let projects = $state<Project[]>([]);
let instancesByProject = $state<Record<string, Instance[]>>({});
@@ -14,11 +18,9 @@
try {
projects = await api.listProjects();
// Fetch instances for each project by loading the project detail.
const detailPromises = projects.map(async (p) => {
try {
const detail = await api.getProject(p.id);
// Fetch instances for each stage.
const stageInstances = await Promise.all(
detail.stages.map((s) => api.listInstances(p.id, s.id))
);
@@ -35,7 +37,7 @@
}
instancesByProject = mapped;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load dashboard';
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
} finally {
loading = false;
}
@@ -59,71 +61,90 @@
</script>
<svelte:head>
<title>Dashboard - Docker Watcher</title>
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
</svelte:head>
<div>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('dashboard.title')}</h1>
<a
href="/deploy"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
>
Quick Deploy
<IconDeploy size={16} />
{$t('dashboard.quickDeploy')}
</a>
</div>
<!-- Stats -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Total Projects</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{totalProjects}</p>
<!-- Stats cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
<IconBox size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalProjects')}</p>
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">{totalProjects}</p>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Running Instances</p>
<p class="mt-1 text-3xl font-bold text-green-600">{totalRunning}</p>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600">
<IconServer size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.runningInstances')}</p>
<p class="mt-0.5 text-2xl font-bold text-emerald-600">{totalRunning}</p>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Failed Instances</p>
<p class="mt-1 text-3xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-gray-900'}">
{totalFailed}
</p>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalFailed > 0 ? 'bg-red-50 text-red-600' : 'bg-gray-50 text-gray-400'}">
<IconAlert size={24} />
</div>
<div>
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.failedInstances')}</p>
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
</div>
</div>
</div>
<!-- Project cards -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Projects</h2>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.projects')}</h2>
{#if loading}
<div class="mt-4 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
</div>
{:else if error}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button
type="button"
class="mt-2 text-sm font-medium text-red-700 underline"
onclick={loadDashboard}
>
Retry
</button>
</div>
{:else if projects.length === 0}
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<p class="text-sm text-gray-500">No projects yet.</p>
<a
href="/projects"
class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-500"
>
Add your first project
</a>
</div>
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each projects as project (project.id)}
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
{/each}
</div>
{/if}
{#if loading}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(3) as _}
<SkeletonCard />
{/each}
</div>
{:else if error}
<div class="mt-4 rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button
type="button"
class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline"
onclick={loadDashboard}
>
{$t('dashboard.retry')}
</button>
</div>
{:else if projects.length === 0}
<div class="mt-4">
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('empty.createProject')}
actionHref="/projects"
icon="projects"
/>
</div>
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each projects as project (project.id)}
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
{/each}
</div>
{/if}
</div>
</div>
+48 -144
View File
@@ -3,15 +3,15 @@
import type { InspectResult } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
let imageUrl = $state('');
let inspecting = $state(false);
let deploying = $state(false);
let inspected = $state(false);
let inspectResult: InspectResult | null = $state(null);
// Form fields populated after inspect
let projectName = $state('');
let port = $state('');
let healthcheck = $state('');
@@ -19,7 +19,6 @@
let subdomain = $state('');
let envVars = $state('');
// Validation errors
let errors = $state<Record<string, string>>({});
function validateImageUrl(url: string): string {
@@ -55,7 +54,6 @@
return Object.keys(newErrors).length === 0;
}
/** Derive a project name from the image URL (last path segment before the colon). */
function deriveProjectName(image: string): string {
const withoutTag = image.split(':')[0] ?? image;
const segments = withoutTag.split('/');
@@ -69,13 +67,10 @@
return;
}
errors = {};
inspecting = true;
try {
const result = await inspectImage(imageUrl.trim());
inspectResult = result;
// Auto-fill form with inspection results
projectName = deriveProjectName(result.image);
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
@@ -83,9 +78,9 @@
subdomain = '';
envVars = '';
inspected = true;
toasts.success('Image inspected successfully');
toasts.success($t('quickDeploy.inspectedSuccess'));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to inspect image';
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
toasts.error(message);
} finally {
inspecting = false;
@@ -94,17 +89,10 @@
async function handleDeploy() {
if (!validateAll()) return;
deploying = true;
try {
await quickDeploy({
image: imageUrl.trim(),
name: projectName.trim(),
port: parseInt(port, 10)
});
toasts.success(`Deployed ${projectName} successfully!`);
// Reset form
await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) });
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
imageUrl = '';
inspected = false;
inspectResult = null;
@@ -115,7 +103,7 @@
subdomain = '';
envVars = '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Deployment failed';
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
toasts.error(message);
} finally {
deploying = false;
@@ -124,31 +112,28 @@
</script>
<svelte:head>
<title>Quick Deploy - Docker Watcher</title>
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Quick Deploy</h1>
<p class="mt-1 text-sm text-gray-500">
Deploy a container image with zero configuration. Paste an image URL, review the defaults,
and deploy.
</p>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('quickDeploy.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.description')}</p>
</div>
<!-- Step 1: Image URL input -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">1. Enter Image URL</h2>
<!-- Step 1 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
<div class="flex gap-3">
<div class="flex-1">
<FormField
label="Image URL"
label={$t('quickDeploy.imageUrl')}
name="imageUrl"
bind:value={imageUrl}
placeholder="registry.example.com/org/app:tag"
required
error={errors.imageUrl ?? ''}
helpText="Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)"
helpText={$t('quickDeploy.imageUrlHelp')}
disabled={inspecting}
/>
</div>
@@ -156,152 +141,71 @@
<button
onclick={handleInspect}
disabled={inspecting || !imageUrl.trim()}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
>
{#if inspecting}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Inspecting...
</span>
<IconLoader size={16} />
{$t('quickDeploy.inspecting')}
{:else}
Inspect
<IconSearch size={16} />
{$t('quickDeploy.inspect')}
{/if}
</button>
</div>
</div>
</div>
<!-- Step 2: Review and configure (shown after inspect) -->
<!-- Step 2 -->
{#if inspected}
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">2. Review Configuration</h2>
<p class="mb-4 text-sm text-gray-500">
These defaults were detected from the image. Adjust as needed before deploying.
</p>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Project Name"
name="projectName"
bind:value={projectName}
placeholder="my-app"
required
error={errors.projectName ?? ''}
helpText="Lowercase with hyphens"
/>
<FormField
label="Port"
name="port"
type="number"
bind:value={port}
placeholder="3000"
required
error={errors.port ?? ''}
helpText="Container port to expose (1-65535)"
/>
<FormField
label="Health Check Path"
name="healthcheck"
bind:value={healthcheck}
placeholder="/api/health"
helpText="Optional HTTP path for health verification"
/>
<div class="flex flex-col gap-1">
<label for="stage" class="text-sm font-medium text-gray-700">Stage</label>
<select
id="stage"
bind:value={stage}
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="dev">Development</option>
<option value="rel">Release</option>
<option value="prod">Production</option>
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText="Lowercase with hyphens" />
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
<FormField label={$t('quickDeploy.healthCheckPath')} name="healthcheck" bind:value={healthcheck} placeholder="/api/health" helpText={$t('quickDeploy.healthCheckHelp')} />
<div class="flex flex-col gap-1.5">
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="dev">{$t('quickDeploy.development')}</option>
<option value="rel">{$t('quickDeploy.release')}</option>
<option value="prod">{$t('quickDeploy.production')}</option>
</select>
<p class="text-xs text-gray-500">Deployment stage for this image</p>
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
</div>
<FormField
label="Subdomain Override"
name="subdomain"
bind:value={subdomain}
placeholder="auto-generated"
helpText="Leave empty to use the default subdomain pattern"
/>
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
</div>
<div class="mt-4">
<FormField
label="Environment Variables"
name="envVars"
type="textarea"
bind:value={envVars}
placeholder="KEY=value&#10;ANOTHER_KEY=another_value"
helpText="One per line, KEY=VALUE format"
/>
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder="KEY=value&#10;ANOTHER_KEY=another_value" helpText={$t('quickDeploy.envVarsHelp')} />
</div>
</div>
<!-- Step 3: Deploy -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">3. Deploy</h2>
<p class="mb-4 text-sm text-gray-500">
A new project will be created and the container will be deployed immediately.
</p>
<!-- Step 3 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
<div class="flex gap-3">
<button
onclick={handleDeploy}
disabled={deploying}
class="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
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"
>
{#if deploying}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Deploying...
</span>
<IconLoader size={16} />
{$t('projectDetail.deploying')}
{:else}
Deploy
<IconDeploy size={16} />
{$t('quickDeploy.deployBtn')}
{/if}
</button>
<button
onclick={() => {
inspected = false;
inspectResult = null;
}}
onclick={() => { inspected = false; inspectResult = null; }}
disabled={deploying}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
Cancel
{$t('common.cancel')}
</button>
</div>
</div>
+39 -30
View File
@@ -2,21 +2,26 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { IconLoader } from '$lib/components/icons';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
// Apply theme on login page too.
$effect(() => {
applyTheme($resolvedTheme);
});
onMount(() => {
// Check if we got a token from OIDC callback redirect.
const urlToken = $page.url.searchParams.get('token');
if (urlToken) {
localStorage.setItem('auth_token', urlToken);
goto('/');
}
// If already logged in, redirect to dashboard.
const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
goto('/');
@@ -26,25 +31,21 @@
async function handleLogin() {
error = '';
loading = true;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const envelope = await res.json();
if (!envelope.success) {
error = envelope.error ?? 'Login failed';
error = envelope.error ?? $t('login.loginFailed');
return;
}
localStorage.setItem('auth_token', envelope.data.token);
goto('/');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Network error';
error = err instanceof Error ? err.message : $t('login.networkError');
} finally {
loading = false;
}
@@ -55,72 +56,80 @@
}
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="flex min-h-screen items-center justify-center bg-[var(--surface-page)] px-4">
<div class="w-full max-w-sm">
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)]">
<!-- Logo -->
<div class="mb-6 text-center">
<svg class="mx-auto h-10 w-10 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
<h1 class="mt-3 text-xl font-bold text-gray-900">Docker Watcher</h1>
<p class="mt-1 text-sm text-gray-500">Sign in to your account</p>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-600)] shadow-md">
<svg class="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
</div>
<h1 class="mt-4 text-xl font-bold text-[var(--text-primary)]">{$t('login.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('login.subtitle')}</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
<div class="mb-4 rounded-lg bg-[var(--color-danger-light)] p-3 text-sm text-[var(--color-danger)]">
{error}
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<div class="flex flex-col gap-1.5">
<label for="username" class="text-sm font-medium text-[var(--text-primary)]">{$t('login.username')}</label>
<input
id="username"
type="text"
bind:value={username}
required
autocomplete="username"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<div class="flex flex-col gap-1.5">
<label for="password" class="text-sm font-medium text-[var(--text-primary)]">{$t('login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
required
autocomplete="current-password"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:ring-offset-2 disabled:opacity-50 active:animate-press"
>
{loading ? 'Signing in...' : 'Sign in'}
{#if loading}
<IconLoader size={16} />
{$t('login.signingIn')}
{:else}
{$t('login.signIn')}
{/if}
</button>
</form>
<div class="mt-4">
<div class="mt-5">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
<div class="w-full border-t border-[var(--border-primary)]"></div>
</div>
<div class="relative flex justify-center text-xs">
<span class="bg-white px-2 text-gray-400">or</span>
<span class="bg-[var(--surface-card)] px-3 text-[var(--text-tertiary)]">{$t('login.or')}</span>
</div>
</div>
<button
onclick={handleOIDCLogin}
class="mt-4 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
class="mt-4 w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] shadow-sm transition-all duration-150 hover:bg-[var(--surface-card-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:ring-offset-2"
>
Sign in with SSO (OIDC)
{$t('login.ssoButton')}
</button>
</div>
</div>
+57 -94
View File
@@ -1,17 +1,21 @@
<script lang="ts">
import type { Project } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { IconPlus } from '$lib/components/icons';
import FormField from '$lib/components/FormField.svelte';
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
let projects = $state<Project[]>([]);
let loading = $state(true);
let error = $state('');
let showAddForm = $state(false);
// Add project form state.
let formName = $state('');
let formImage = $state('');
let formRegistry = $state('');
let formPort = $state(3000);
let formPort = $state('3000');
let formHealthcheck = $state('');
let formSubmitting = $state(false);
let formError = $state('');
@@ -22,7 +26,7 @@
try {
projects = await api.listProjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
error = e instanceof Error ? e.message : $t('projects.loadFailed');
} finally {
loading = false;
}
@@ -30,7 +34,7 @@
async function handleAddProject() {
if (!formName.trim() || !formImage.trim()) {
formError = 'Name and image are required.';
formError = $t('projects.nameRequired');
return;
}
@@ -41,19 +45,18 @@
name: formName.trim(),
image: formImage.trim(),
registry: formRegistry.trim(),
port: formPort,
port: parseInt(formPort, 10) || 3000,
healthcheck: formHealthcheck.trim()
});
// Reset form.
formName = '';
formImage = '';
formRegistry = '';
formPort = 3000;
formPort = '3000';
formHealthcheck = '';
showAddForm = false;
await loadProjects();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to create project';
formError = e instanceof Error ? e.message : $t('projects.createFailed');
} finally {
formSubmitting = false;
}
@@ -65,92 +68,51 @@
</script>
<svelte:head>
<title>Projects - Docker Watcher</title>
<title>{$t('projects.title')} - {$t('app.name')}</title>
</svelte:head>
<div>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Projects</h1>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('projects.title')}</h1>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
class="inline-flex items-center gap-2 rounded-lg {showAddForm ? 'border border-[var(--border-primary)] bg-[var(--surface-card)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'bg-[var(--color-brand-600)] text-white shadow-sm hover:bg-[var(--color-brand-700)]'} px-4 py-2.5 text-sm font-medium transition-all duration-150 active:animate-press"
onclick={() => { showAddForm = !showAddForm; }}
>
{showAddForm ? 'Cancel' : 'Add Project'}
{#if !showAddForm}<IconPlus size={16} />{/if}
{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}
</button>
</div>
<!-- Add project form -->
{#if showAddForm}
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">New Project</h2>
<div class="rounded-xl border border-[var(--color-brand-200)] bg-[var(--color-brand-50)]/30 p-6 animate-scale-in">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projects.newProject')}</h2>
{#if formError}
<div class="mt-3 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-700">{formError}</p>
<div class="mt-3 rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{formError}</p>
</div>
{/if}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name *</label>
<input
id="name"
type="text"
bind:value={formName}
placeholder="my-web-app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="image" class="block text-sm font-medium text-gray-700">Image *</label>
<input
id="image"
type="text"
bind:value={formImage}
placeholder="registry.example.com/org/app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="registry" class="block text-sm font-medium text-gray-700">Registry</label>
<input
id="registry"
type="text"
bind:value={formRegistry}
placeholder="gitea"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-gray-700">Port</label>
<input
id="port"
type="number"
bind:value={formPort}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
<FormField label={$t('projects.registry')} name="registry" bind:value={formRegistry} placeholder="gitea" />
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} />
<div class="sm:col-span-2">
<label for="healthcheck" class="block text-sm font-medium text-gray-700">Healthcheck Path</label>
<input
id="healthcheck"
type="text"
bind:value={formHealthcheck}
placeholder="/api/health"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" />
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all duration-150 active:animate-press"
disabled={formSubmitting}
onclick={handleAddProject}
>
{formSubmitting ? 'Creating...' : 'Create Project'}
{formSubmitting ? $t('projects.creating') : $t('projects.createProject')}
</button>
</div>
</div>
@@ -158,57 +120,58 @@
<!-- Projects list -->
{#if loading}
<div class="mt-6 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
</div>
<SkeletonTable rows={4} cols={5} />
{:else if error}
<div class="mt-6 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadProjects}>
Retry
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProjects}>
{$t('common.retry')}
</button>
</div>
{:else if projects.length === 0}
<div class="mt-6 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<p class="text-sm text-gray-500">No projects configured yet.</p>
<p class="mt-1 text-sm text-gray-400">Click "Add Project" to get started.</p>
</div>
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('projects.addProject')}
onaction={() => { showAddForm = true; }}
icon="projects"
/>
{:else}
<div class="mt-6 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Image</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Port</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Registry</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.name')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.image')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each projects as project (project.id)}
<tr class="hover:bg-gray-50">
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-indigo-600 hover:text-indigo-800">
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{project.name}
</a>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-gray-500">
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-[var(--text-tertiary)]">
{project.image}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{project.port || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{project.registry || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{new Date(project.created_at).toLocaleDateString()}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
<a href="/projects/{project.id}" class="text-indigo-600 hover:text-indigo-800">
View
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{$t('projects.view')}
</a>
</td>
</tr>
+195 -180
View File
@@ -5,6 +5,10 @@
import StatusBadge from '$lib/components/StatusBadge.svelte';
import InstanceCard from '$lib/components/InstanceCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconChevronRight, IconClock, IconTag, IconLoader } from '$lib/components/icons';
import { t } from '$lib/i18n';
let project = $state<Project | null>(null);
let stages = $state<Stage[]>([]);
@@ -13,17 +17,14 @@
let loading = $state(true);
let error = $state('');
// Deploy form state.
let deployStageId = $state('');
let deployTag = $state('');
let deployLoading = $state(false);
let deployError = $state('');
// Available tags for deploy dropdown.
let availableTags = $state<string[]>([]);
let tagsLoading = $state(false);
// Delete project confirmation.
let showDeleteConfirm = $state(false);
const projectId = $derived($page.params.id);
@@ -36,7 +37,6 @@
project = detail.project;
stages = detail.stages;
// Fetch instances for each stage in parallel.
const instanceResults = await Promise.all(
stages.map(async (s) => {
try {
@@ -54,7 +54,6 @@
}
instancesByStage = mapped;
// Load recent deploys.
try {
const allDeploys = await api.listDeploys(20);
deploys = allDeploys.filter((d) => d.project_id === projectId);
@@ -62,7 +61,7 @@
deploys = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load project';
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
} finally {
loading = false;
}
@@ -96,7 +95,7 @@
deployStageId = '';
await loadProject();
} catch (e) {
deployError = e instanceof Error ? e.message : 'Deploy failed';
deployError = e instanceof Error ? e.message : $t('projectDetail.deployFailed');
} finally {
deployLoading = false;
}
@@ -108,251 +107,267 @@
await api.deleteProject(projectId);
window.location.href = '/projects';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
error = e instanceof Error ? e.message : $t('projectDetail.deleteFailed');
}
}
$effect(() => {
// Re-run when projectId changes.
void projectId;
loadProject();
});
</script>
<svelte:head>
<title>{project?.name ?? 'Project'} - Docker Watcher</title>
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
<div class="space-y-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<Skeleton width="4rem" height="0.875rem" />
<Skeleton width="12rem" height="1.75rem" />
<Skeleton width="16rem" height="0.875rem" />
</div>
</div>
<div class="grid grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton height="3rem" />
{/each}
</div>
</div>
{:else if error}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadProject}>
Retry
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProject}>
{$t('common.retry')}
</button>
</div>
{:else if project}
<div>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<a href="/projects" class="text-sm text-gray-500 hover:text-gray-700">Projects</a>
<span class="text-sm text-gray-400">/</span>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects" class="hover:text-[var(--text-link)] transition-colors">{$t('projects.title')}</a>
<IconChevronRight size={14} />
</div>
<h1 class="mt-1 text-2xl font-bold text-gray-900">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-gray-500">{project.image}</p>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p>
</div>
<button
type="button"
class="rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors active:animate-press"
onclick={() => { showDeleteConfirm = true; }}
>
Delete Project
<IconTrash size={16} />
{$t('projectDetail.deleteProject')}
</button>
</div>
<!-- Project settings links -->
<div class="mt-4 flex gap-3">
<div class="flex gap-3">
<a
href="/projects/{projectId}/env"
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
Environment Variables
<IconKey size={16} />
{$t('projectDetail.envVars')}
</a>
<a
href="/projects/{projectId}/volumes"
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
Volume Mounts
<IconHardDrive size={16} />
{$t('projectDetail.volumes')}
</a>
</div>
<!-- Project info -->
<div class="mt-6 grid grid-cols-2 gap-4 rounded-lg border border-gray-200 bg-white p-5 sm:grid-cols-4">
<div class="grid grid-cols-2 gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] sm:grid-cols-4">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Port</p>
<p class="mt-1 text-sm text-gray-900">{project.port || '-'}</p>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.port || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Registry</p>
<p class="mt-1 text-sm text-gray-900">{project.registry || '-'}</p>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.registry || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Healthcheck</p>
<p class="mt-1 text-sm text-gray-900">{project.healthcheck || '-'}</p>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.healthcheck')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.healthcheck || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Created</p>
<p class="mt-1 text-sm text-gray-900">{new Date(project.created_at).toLocaleDateString()}</p>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{new Date(project.created_at).toLocaleDateString()}</p>
</div>
</div>
<!-- Stages & Instances -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Stages</h2>
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
{#if stages.length === 0}
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">No stages configured for this project.</p>
</div>
{:else}
<div class="mt-4 space-y-6">
{#each stages as stage (stage.id)}
{@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
<!-- Stage header -->
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-gray-900">{stage.name}</h3>
<span class="text-xs text-gray-500">Pattern: {stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
auto-deploy
{#if stages.length === 0}
<div class="mt-4">
<EmptyState title={$t('projectDetail.noStages')} icon="instances" />
</div>
{:else}
<div class="mt-4 space-y-4">
{#each stages as stage (stage.id)}
{@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
<!-- Stage header -->
<div class="flex items-center justify-between border-b border-[var(--border-secondary)] px-5 py-4">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('projectDetail.autoDeploy')}</span>
{/if}
{#if stage.confirm}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</span>
{/if}
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-[var(--text-tertiary)]">
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
</span>
{/if}
{#if stage.confirm}
<span class="rounded bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700">
requires confirm
</span>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">
{stageInstances.length} / {stage.max_instances} instances
</span>
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700"
onclick={() => loadTags(stage.id)}
>
Deploy new version
</button>
</div>
</div>
<!-- Deploy form (shown when this stage is selected) -->
{#if deployStageId === stage.id}
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-gray-700">
Select tag to deploy
</label>
{#if tagsLoading}
<p class="mt-1 text-sm text-gray-500">Loading tags...</p>
{:else if availableTags.length > 0}
<select
id="deploy-tag-{stage.id}"
bind:value={deployTag}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
>
<option value="">Choose a tag...</option>
{#each availableTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
{:else}
<input
id="deploy-tag-{stage.id}"
type="text"
bind:value={deployTag}
placeholder="Enter image tag (e.g., dev-abc123)"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
{/if}
</div>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
disabled={!deployTag.trim() || deployLoading}
onclick={handleDeploy}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
onclick={() => loadTags(stage.id)}
>
{deployLoading ? 'Deploying...' : 'Deploy'}
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-200"
onclick={() => { deployStageId = ''; }}
>
Cancel
<IconDeploy size={14} />
{$t('projectDetail.deployNewVersion')}
</button>
</div>
{#if deployError}
<p class="mt-2 text-xs text-red-600">{deployError}</p>
{/if}
</div>
{/if}
<!-- Instances -->
<div class="p-5">
{#if stageInstances.length === 0}
<p class="text-center text-sm text-gray-400">No instances running</p>
{:else}
<div class="space-y-3">
{#each stageInstances as instance (instance.id)}
<InstanceCard
{instance}
{projectId}
onchange={loadProject}
/>
{/each}
<!-- Deploy form -->
{#if deployStageId === stage.id}
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4 animate-scale-in">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-[var(--text-secondary)]">
{$t('projectDetail.selectTag')}
</label>
{#if tagsLoading}
<div class="mt-1 flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<IconLoader size={16} />
{$t('projectDetail.loadingTags')}
</div>
{:else if availableTags.length > 0}
<select
id="deploy-tag-{stage.id}"
bind:value={deployTag}
class="mt-1 block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
>
<option value="">{$t('projectDetail.chooseTag')}</option>
{#each availableTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
{:else}
<input
id="deploy-tag-{stage.id}"
type="text"
bind:value={deployTag}
placeholder={$t('projectDetail.enterTag')}
class="mt-1 block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
/>
{/if}
</div>
<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)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!deployTag.trim() || deployLoading}
onclick={handleDeploy}
>
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
</button>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
onclick={() => { deployStageId = ''; }}
>
{$t('common.cancel')}
</button>
</div>
{#if deployError}
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
{/if}
</div>
{/if}
<!-- Instances -->
<div class="p-5">
{#if stageInstances.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</p>
{:else}
<div class="space-y-3">
{#each stageInstances as instance (instance.id)}
<InstanceCard
{instance}
{projectId}
onchange={loadProject}
/>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Deploy History -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Recent Deploys</h2>
<!-- Deploy History Timeline -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
{#if deploys.length === 0}
<p class="mt-4 text-sm text-gray-500">No deploy history for this project.</p>
{:else}
<div class="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Tag</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Status</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Started</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Finished</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Error</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each deploys as deploy (deploy.id)}
<tr>
<td class="whitespace-nowrap px-5 py-3 font-mono text-sm text-gray-900">{deploy.image_tag}</td>
<td class="whitespace-nowrap px-5 py-3">
{#if deploys.length === 0}
<p class="mt-4 text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noDeployHistory')}</p>
{:else}
<div class="mt-4 space-y-3">
{#each deploys as deploy (deploy.id)}
<div class="flex items-start gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
<!-- Timeline dot -->
<div class="mt-1 flex flex-col items-center">
<div class="h-3 w-3 rounded-full {deploy.status === 'success' ? 'bg-emerald-500' : deploy.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}"></div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-sm font-medium text-[var(--text-primary)]">{deploy.image_tag}</span>
<StatusBadge status={deploy.status} size="sm" />
</td>
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
{deploy.started_at ? new Date(deploy.started_at).toLocaleString() : '-'}
</td>
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
{deploy.finished_at ? new Date(deploy.finished_at).toLocaleString() : '-'}
</td>
<td class="max-w-xs truncate px-5 py-3 text-sm text-red-600">
{deploy.error || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="mt-1 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
{#if deploy.started_at}
<span class="inline-flex items-center gap-1">
<IconClock size={12} />
{new Date(deploy.started_at).toLocaleString()}
</span>
{/if}
{#if deploy.finished_at}
<span>{new Date(deploy.finished_at).toLocaleString()}</span>
{/if}
</div>
{#if deploy.error}
<p class="mt-1 text-xs text-[var(--color-danger)] truncate">{deploy.error}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<ConfirmDialog
open={showDeleteConfirm}
title="Delete Project"
message="This will permanently delete the project '{project.name}' and all its stages, instances, and deploy history. This cannot be undone."
confirmLabel="Delete"
title={$t('projectDetail.deleteConfirmTitle')}
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
confirmLabel={$t('common.delete')}
confirmVariant="danger"
onconfirm={handleDeleteProject}
oncancel={() => { showDeleteConfirm = false; }}
+108 -155
View File
@@ -3,6 +3,11 @@
import type { Stage, StageEnv } from '$lib/types';
import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
let stages = $state<Stage[]>([]);
let selectedStageId = $state('');
@@ -12,13 +17,11 @@
let envLoading = $state(false);
let error = $state('');
// New env var form.
let newKey = $state('');
let newValue = $state('');
let newEncrypted = $state(false);
let saving = $state(false);
// Edit state.
let editingId = $state('');
let editKey = $state('');
let editValue = $state('');
@@ -32,19 +35,16 @@
try {
const detail = await api.getProject(projectId);
stages = detail.stages;
// Parse project-level env.
try {
projectEnv = JSON.parse(detail.project.env || '{}');
} catch {
projectEnv = {};
}
if (stages.length > 0 && !selectedStageId) {
selectedStageId = stages[0].id;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load project';
error = e instanceof Error ? e.message : $t('envEditor.loadFailed');
} finally {
loading = false;
}
@@ -56,7 +56,7 @@
try {
envVars = await api.listStageEnv(projectId, stageId);
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to load env vars');
toasts.error(e instanceof Error ? e.message : $t('envEditor.loadEnvFailed'));
envVars = [];
} finally {
envLoading = false;
@@ -75,10 +75,10 @@
newKey = '';
newValue = '';
newEncrypted = false;
toasts.success('Environment variable added');
toasts.success($t('envEditor.envAdded'));
await loadStageEnv(selectedStageId);
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to add env var');
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
} finally {
saving = false;
}
@@ -108,10 +108,10 @@
}
await api.updateStageEnv(projectId, selectedStageId, editingId, data);
editingId = '';
toasts.success('Environment variable updated');
toasts.success($t('envEditor.envUpdated'));
await loadStageEnv(selectedStageId);
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to update env var');
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
} finally {
saving = false;
}
@@ -120,19 +120,13 @@
async function handleDelete(envId: string) {
try {
await api.deleteStageEnv(projectId, selectedStageId, envId);
toasts.success('Environment variable deleted');
toasts.success($t('envEditor.envDeleted'));
await loadStageEnv(selectedStageId);
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to delete env var');
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
}
}
// Determine if a key is inherited from project level.
function isInherited(key: string): boolean {
return key in projectEnv;
}
// Determine if a key is overridden at stage level.
function isOverridden(key: string): boolean {
return envVars.some((e) => e.key === key);
}
@@ -150,36 +144,37 @@
</script>
<svelte:head>
<title>Environment Variables - Docker Watcher</title>
<title>{$t('envEditor.title')} - {$t('app.name')}</title>
</svelte:head>
<div>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-2">
<a href="/projects/{projectId}" class="text-sm text-gray-500 hover:text-gray-700">Project</a>
<span class="text-sm text-gray-400">/</span>
<h1 class="text-2xl font-bold text-gray-900">Environment Variables</h1>
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</div>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p>
</div>
<p class="mt-1 text-sm text-gray-500">
Manage per-stage environment variable overrides. Stage-level values override project-level defaults.
</p>
{#if loading}
<div class="mt-8 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
<div class="space-y-4">
<Skeleton width="16rem" height="2.5rem" />
<Skeleton height="12rem" />
</div>
{:else if error}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{:else}
<!-- Stage selector -->
<div class="mt-6">
<label for="stage-select" class="block text-sm font-medium text-gray-700">Stage</label>
<div>
<label for="stage-select" class="block text-sm font-medium text-[var(--text-primary)]">{$t('envEditor.stage')}</label>
<select
id="stage-select"
bind:value={selectedStageId}
class="mt-1 block w-64 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
class="mt-1 block w-64 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
>
{#each stages as stage (stage.id)}
<option value={stage.id}>{stage.name}</option>
@@ -188,37 +183,31 @@
</div>
{#if stages.length === 0}
<div class="mt-6 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">No stages configured. Add stages to the project first.</p>
</div>
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
{:else}
<!-- Project-level env (read-only reference) -->
<!-- Project-level env -->
{#if Object.keys(projectEnv).length > 0}
<div class="mt-6">
<h2 class="text-sm font-semibold text-gray-700">Project-Level Defaults</h2>
<div class="mt-2 rounded-lg border border-gray-200 bg-gray-50">
<table class="min-w-full divide-y divide-gray-200">
<div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
<div class="mt-2 overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Key</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each Object.entries(projectEnv) as [key, value] (key)}
<tr class={isOverridden(key) ? 'opacity-50' : ''}>
<td class="whitespace-nowrap px-4 py-2 font-mono text-sm text-gray-900">{key}</td>
<td class="px-4 py-2 font-mono text-sm text-gray-600">{value}</td>
<td class="px-4 py-2 text-sm">
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{value}</td>
<td class="px-4 py-2.5 text-sm">
{#if isOverridden(key)}
<span class="rounded bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700">
overridden
</span>
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('envEditor.overridden')}</span>
{:else}
<span class="rounded bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
inherited
</span>
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('envEditor.inherited')}</span>
{/if}
</td>
</tr>
@@ -230,143 +219,107 @@
{/if}
<!-- Stage-level overrides -->
<div class="mt-6">
<h2 class="text-sm font-semibold text-gray-700">Stage Overrides</h2>
<div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
{#if envLoading}
<div class="mt-4 flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
<IconLoader size={20} />
<span class="text-sm">{$t('common.loading')}</span>
</div>
{:else}
<div class="mt-2 rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="mt-2 overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Key</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Secret</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.secret')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each envVars as env (env.id)}
{#if editingId === env.id}
<tr class="bg-indigo-50">
<td class="px-4 py-2">
<input
type="text"
bind:value={editKey}
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<tr class="bg-[var(--color-brand-50)]/30">
<td class="px-4 py-2.5">
<input type="text" bind:value={editKey} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<input
type={editEncrypted ? 'password' : 'text'}
bind:value={editValue}
placeholder={env.encrypted ? 'Leave empty to keep current' : ''}
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<td class="px-4 py-2.5">
<input type={editEncrypted ? 'password' : 'text'} bind:value={editValue} placeholder={env.encrypted ? 'Leave empty to keep current' : ''} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<input type="checkbox" bind:checked={editEncrypted} class="rounded" />
<td class="px-4 py-2.5">
<ToggleSwitch bind:checked={editEncrypted} label="Secret" />
</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right">
<button
type="button"
class="mr-2 text-sm font-medium text-indigo-600 hover:text-indigo-800"
disabled={saving}
onclick={handleUpdate}
>
Save
</button>
<button
type="button"
class="text-sm text-gray-500 hover:text-gray-700"
onclick={cancelEdit}
>
Cancel
</button>
<td class="px-4 py-2.5"></td>
<td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate} title={$t('envEditor.save')}>
<IconCheck size={16} />
</button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit} title={$t('common.cancel')}>
<IconX size={16} />
</button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="whitespace-nowrap px-4 py-2 font-mono text-sm text-gray-900">{env.key}</td>
<td class="px-4 py-2 font-mono text-sm text-gray-600">
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{env.key}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
{env.encrypted ? '••••••••' : env.value}
</td>
<td class="px-4 py-2">
<td class="px-4 py-2.5">
{#if env.encrypted}
<span class="rounded bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700">
secret
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700">
<IconLock size={12} />
{$t('envEditor.secret')}
</span>
{/if}
</td>
<td class="px-4 py-2 text-sm">
{#if isInherited(env.key)}
<span class="rounded bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700">
overrides project
</span>
<td class="px-4 py-2.5 text-sm">
{#if env.key in projectEnv}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('envEditor.overridesProject')}</span>
{:else}
<span class="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
stage only
</span>
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('envEditor.stageOnly')}</span>
{/if}
</td>
<td class="whitespace-nowrap px-4 py-2 text-right">
<button
type="button"
class="mr-2 text-sm font-medium text-indigo-600 hover:text-indigo-800"
onclick={() => startEdit(env)}
>
{env.encrypted ? 'Change' : 'Edit'}
</button>
<button
type="button"
class="text-sm font-medium text-red-600 hover:text-red-800"
onclick={() => handleDelete(env.id)}
>
Delete
</button>
<td class="whitespace-nowrap px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
<IconEdit size={16} />
</button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(env.id)} title={$t('envEditor.delete')}>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
{/if}
{/each}
<!-- Add new row -->
<tr class="bg-gray-50">
<td class="px-4 py-2">
<input
type="text"
bind:value={newKey}
placeholder="KEY_NAME"
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<tr class="bg-[var(--surface-card-hover)]">
<td class="px-4 py-2.5">
<input type="text" bind:value={newKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<input
type={newEncrypted ? 'password' : 'text'}
bind:value={newValue}
placeholder="value"
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<td class="px-4 py-2.5">
<input type={newEncrypted ? 'password' : 'text'} bind:value={newValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<label class="flex items-center gap-1 text-xs text-gray-600">
<input type="checkbox" bind:checked={newEncrypted} class="rounded" />
Secret
</label>
<td class="px-4 py-2.5">
<ToggleSwitch bind:checked={newEncrypted} label="Secret" />
</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right">
<td class="px-4 py-2.5"></td>
<td class="px-4 py-2.5 text-right">
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!newKey.trim() || saving}
onclick={handleAdd}
>
{saving ? 'Adding...' : 'Add'}
<IconPlus size={14} />
{saving ? $t('envEditor.adding') : $t('envEditor.add')}
</button>
</td>
</tr>
+80 -134
View File
@@ -3,18 +3,20 @@
import type { Volume } from '$lib/types';
import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
let volumes = $state<Volume[]>([]);
let loading = $state(true);
let error = $state('');
// New volume form.
let newSource = $state('');
let newTarget = $state('');
let newMode = $state<'shared' | 'isolated'>('shared');
let saving = $state(false);
// Edit state.
let editingId = $state('');
let editSource = $state('');
let editTarget = $state('');
@@ -28,7 +30,7 @@
try {
volumes = await api.listVolumes(projectId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load volumes';
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
} finally {
loading = false;
}
@@ -38,18 +40,14 @@
if (!newSource.trim() || !newTarget.trim()) return;
saving = true;
try {
await api.createVolume(projectId, {
source: newSource.trim(),
target: newTarget.trim(),
mode: newMode
});
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode });
newSource = '';
newTarget = '';
newMode = 'shared';
toasts.success('Volume added');
toasts.success($t('volumeEditor.volumeAdded'));
await loadVolumes();
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to add volume');
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
} finally {
saving = false;
}
@@ -62,24 +60,18 @@
editMode = vol.mode;
}
function cancelEdit() {
editingId = '';
}
function cancelEdit() { editingId = ''; }
async function handleUpdate() {
if (!editSource.trim() || !editTarget.trim()) return;
saving = true;
try {
await api.updateVolume(projectId, editingId, {
source: editSource.trim(),
target: editTarget.trim(),
mode: editMode
});
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode });
editingId = '';
toasts.success('Volume updated');
toasts.success($t('volumeEditor.volumeUpdated'));
await loadVolumes();
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to update volume');
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
} finally {
saving = false;
}
@@ -88,10 +80,10 @@
async function handleDelete(volId: string) {
try {
await api.deleteVolume(projectId, volId);
toasts.success('Volume deleted');
toasts.success($t('volumeEditor.volumeDeleted'));
await loadVolumes();
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to delete volume');
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
}
}
@@ -102,159 +94,113 @@
</script>
<svelte:head>
<title>Volumes - Docker Watcher</title>
<title>{$t('volumeEditor.title')} - {$t('app.name')}</title>
</svelte:head>
<div>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-2">
<a href="/projects/{projectId}" class="text-sm text-gray-500 hover:text-gray-700">Project</a>
<span class="text-sm text-gray-400">/</span>
<h1 class="text-2xl font-bold text-gray-900">Volume Mounts</h1>
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</div>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">
{$t('volumeEditor.description')}
<strong>{$t('volumeEditor.shared')}</strong>{$t('volumeEditor.sharedDesc')}
<strong>{$t('volumeEditor.isolated')}</strong>{$t('volumeEditor.isolatedDesc')}
</p>
</div>
<p class="mt-1 text-sm text-gray-500">
Configure volume mounts for containers.
<strong>Shared</strong> mode uses the source path as-is for all instances.
<strong>Isolated</strong> mode appends /{'{'}stage{'}'}-{'{'}tag{'}'}/ to the source, giving each instance its own directory.
</p>
{#if loading}
<div class="mt-8 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
<div class="space-y-4">
<Skeleton height="12rem" />
</div>
{:else if error}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadVolumes}>
Retry
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}>
{$t('common.retry')}
</button>
</div>
{:else}
<div class="mt-6 rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source (Host)</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Target (Container)</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Mode</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.mode')}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each volumes as vol (vol.id)}
{#if editingId === vol.id}
<tr class="bg-indigo-50">
<td class="px-4 py-2">
<input
type="text"
bind:value={editSource}
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<tr class="bg-[var(--color-brand-50)]/30">
<td class="px-4 py-2.5">
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<input
type="text"
bind:value={editTarget}
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<td class="px-4 py-2.5">
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<select
bind:value={editMode}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
>
<option value="shared">Shared</option>
<option value="isolated">Isolated</option>
<td class="px-4 py-2.5">
<select bind:value={editMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option>
<option value="isolated">{$t('volumeEditor.isolated')}</option>
</select>
</td>
<td class="px-4 py-2 text-right">
<button
type="button"
class="mr-2 text-sm font-medium text-indigo-600 hover:text-indigo-800"
disabled={saving}
onclick={handleUpdate}
>
Save
</button>
<button
type="button"
class="text-sm text-gray-500 hover:text-gray-700"
onclick={cancelEdit}
>
Cancel
</button>
<td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit}><IconX size={16} /></button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="px-4 py-2 font-mono text-sm text-gray-900">{vol.source}</td>
<td class="px-4 py-2 font-mono text-sm text-gray-600">{vol.target}</td>
<td class="px-4 py-2">
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{vol.source}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
<td class="px-4 py-2.5">
{#if vol.mode === 'shared'}
<span class="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
shared
</span>
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span>
{:else}
<span class="rounded bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">
isolated
</span>
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span>
{/if}
</td>
<td class="whitespace-nowrap px-4 py-2 text-right">
<button
type="button"
class="mr-2 text-sm font-medium text-indigo-600 hover:text-indigo-800"
onclick={() => startEdit(vol)}
>
Edit
</button>
<button
type="button"
class="text-sm font-medium text-red-600 hover:text-red-800"
onclick={() => handleDelete(vol.id)}
>
Delete
</button>
<td class="whitespace-nowrap px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(vol.id)}><IconTrash size={16} /></button>
</div>
</td>
</tr>
{/if}
{/each}
<!-- Add new row -->
<tr class="bg-gray-50">
<td class="px-4 py-2">
<input
type="text"
bind:value={newSource}
placeholder="/data/my-app/uploads"
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<tr class="bg-[var(--surface-card-hover)]">
<td class="px-4 py-2.5">
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<input
type="text"
bind:value={newTarget}
placeholder="/app/uploads"
class="block w-full rounded-md border border-gray-300 px-2 py-1 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<td class="px-4 py-2.5">
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2">
<select
bind:value={newMode}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
>
<option value="shared">Shared</option>
<option value="isolated">Isolated</option>
<td class="px-4 py-2.5">
<select bind:value={newMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option>
<option value="isolated">{$t('volumeEditor.isolated')}</option>
</select>
</td>
<td class="px-4 py-2 text-right">
<td class="px-4 py-2.5 text-right">
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!newSource.trim() || !newTarget.trim() || saving}
onclick={handleAdd}
>
{saving ? 'Adding...' : 'Add'}
<IconPlus size={14} />
{saving ? $t('volumeEditor.adding') : $t('volumeEditor.add')}
</button>
</td>
</tr>
@@ -263,7 +209,7 @@
</div>
{#if volumes.length === 0}
<p class="mt-4 text-center text-sm text-gray-500">No volumes configured yet. Add one above.</p>
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
{/if}
{/if}
</div>
+25 -16
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { t } from '$lib/i18n';
import { IconSettings, IconDatabase, IconKey, IconShield } from '$lib/components/icons';
interface Props {
children: Snippet;
@@ -9,39 +11,46 @@
let { children }: Props = $props();
const navItems = [
{ href: '/settings', label: 'General' },
{ href: '/settings/registries', label: 'Registries' },
{ href: '/settings/credentials', label: 'Credentials' },
{ href: '/settings/auth', label: 'Authentication' }
{ href: '/settings', labelKey: 'settings.general', icon: 'general' },
{ href: '/settings/registries', labelKey: 'settings.registries', icon: 'registries' },
{ href: '/settings/credentials', labelKey: 'settings.credentials', icon: 'credentials' },
{ href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth' }
];
let currentPath = $derived($page.url.pathname);
function isActive(href: string): boolean {
if (href === '/settings') {
return currentPath === '/settings';
}
if (href === '/settings') return currentPath === '/settings';
return currentPath.startsWith(href);
}
</script>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">Settings</h1>
<h1 class="mb-6 text-2xl font-bold text-[var(--text-primary)]">{$t('settings.title')}</h1>
<div class="flex gap-6">
<div class="flex flex-col gap-6 sm:flex-row">
<!-- Sub-navigation -->
<nav class="w-48 flex-shrink-0">
<ul class="space-y-1">
<nav class="w-full flex-shrink-0 sm:w-48">
<ul class="flex gap-1 overflow-x-auto sm:flex-col sm:space-y-0.5">
{#each navItems as item}
<li>
<a
href={item.href}
class="block rounded-md px-3 py-2 text-sm font-medium transition-colors
class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150
{isActive(item.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
>
{item.label}
{#if item.icon === 'general'}
<IconSettings size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
{:else if item.icon === 'registries'}
<IconDatabase size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
{:else if item.icon === 'credentials'}
<IconKey size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
{:else if item.icon === 'auth'}
<IconShield size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
{/if}
{$t(item.labelKey)}
</a>
</li>
{/each}
@@ -49,7 +58,7 @@
</nav>
<!-- Settings content -->
<div class="flex-1">
<div class="flex-1 min-w-0">
{@render children()}
</div>
</div>
+52 -142
View File
@@ -3,13 +3,15 @@
import type { Settings } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconLoader, IconCopy, IconRefresh } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
let loading = $state(true);
let saving = $state(false);
let webhookUrl = $state('');
let regenerating = $state(false);
// Settings fields
let domain = $state('');
let serverIp = $state('');
let network = $state('');
@@ -21,37 +23,26 @@
function validateDomain(value: string): string {
if (!value.trim()) return 'Domain is required';
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) {
return 'Invalid domain format';
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return 'Invalid domain format';
return '';
}
function validateIp(value: string): string {
if (!value.trim()) return '';
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) {
return 'Invalid IP address format';
}
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return 'Invalid IP address format';
return '';
}
function validatePollingInterval(value: string): string {
if (!value.trim()) return '';
const num = parseInt(value, 10);
if (isNaN(num) || num < 10 || num > 86400) {
return 'Polling interval must be between 10 and 86400 seconds';
}
if (isNaN(num) || num < 10 || num > 86400) return 'Polling interval must be between 10 and 86400 seconds';
return '';
}
function validateUrl(value: string): string {
if (!value.trim()) return '';
try {
new URL(value.trim());
return '';
} catch {
return 'Invalid URL format';
}
try { new URL(value.trim()); return ''; } catch { return 'Invalid URL format'; }
}
function validateAll(): boolean {
@@ -79,8 +70,7 @@
pollingInterval = settings.polling_interval ?? '';
notificationUrl = settings.notification_url ?? '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load settings';
toasts.error(message);
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
loading = false;
}
@@ -90,29 +80,21 @@
try {
const result = await getWebhookUrl();
webhookUrl = result.url;
} catch {
// Webhook URL may not be configured yet
}
} catch { /* may not be configured */ }
}
async function handleSave() {
if (!validateAll()) return;
saving = true;
try {
const payload: Partial<Settings> = {
domain: domain.trim(),
server_ip: serverIp.trim(),
network: network.trim(),
subdomain_pattern: subdomainPattern.trim(),
polling_interval: pollingInterval.trim(),
await updateSettings({
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
notification_url: notificationUrl.trim()
};
await updateSettings(payload);
toasts.success('Settings saved successfully');
});
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save settings';
toasts.error(message);
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
} finally {
saving = false;
}
@@ -123,155 +105,83 @@
try {
const result = await regenerateWebhookUrl();
webhookUrl = result.url;
toasts.success('Webhook URL regenerated');
toasts.success($t('settingsGeneral.regenerated'));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to regenerate webhook URL';
toasts.error(message);
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
} finally {
regenerating = false;
}
}
$effect(() => {
loadSettings();
loadWebhookUrlValue();
});
$effect(() => { loadSettings(); loadWebhookUrlValue(); });
</script>
<svelte:head>
<title>General Settings - Docker Watcher</title>
<title>{$t('settingsGeneral.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<div class="space-y-4">
<Skeleton height="2rem" width="12rem" />
<div class="grid grid-cols-2 gap-4">
{#each Array(6) as _}
<Skeleton height="4rem" />
{/each}
</div>
</div>
{:else}
<!-- Global settings form -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Global Configuration</h2>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Domain"
name="domain"
bind:value={domain}
placeholder="example.com"
required
error={errors.domain ?? ''}
helpText="Base domain for subdomain routing"
/>
<FormField
label="Server IP"
name="serverIp"
bind:value={serverIp}
placeholder="93.84.96.191"
error={errors.serverIp ?? ''}
helpText="Public IP address of the server"
/>
<FormField
label="Docker Network"
name="network"
bind:value={network}
placeholder="staging-net"
helpText="Docker network for deployed containers"
/>
<FormField
label="Subdomain Pattern"
name="subdomainPattern"
bind:value={subdomainPattern}
placeholder="stage-{stage}-{project}"
helpText="Pattern for auto-generated subdomains"
/>
<FormField
label="Polling Interval (seconds)"
name="pollingInterval"
type="number"
bind:value={pollingInterval}
placeholder="60"
error={errors.pollingInterval ?? ''}
helpText="How often to check registries for new tags (10-86400)"
/>
<FormField
label="Notification URL"
name="notificationUrl"
bind:value={notificationUrl}
placeholder="https://notify.example.com/webhook"
error={errors.notificationUrl ?? ''}
helpText="Webhook URL for deploy notifications"
/>
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
<FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
<FormField label={$t('settingsGeneral.subdomainPattern')} name="subdomainPattern" bind:value={subdomainPattern} placeholder="stage-{'{stage}'}-{'{project}'}" helpText={$t('settingsGeneral.subdomainPatternHelp')} />
<FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} />
<FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
</div>
<div class="mt-6">
<button
onclick={handleSave}
disabled={saving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
{#if saving}<IconLoader size={16} />{/if}
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
</button>
</div>
</div>
<!-- Webhook URL section -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Webhook URL</h2>
<p class="mb-3 text-sm text-gray-500">
This secret URL receives image push notifications from your CI pipeline.
</p>
<!-- Webhook URL -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.webhookUrl')}</h2>
<p class="mb-3 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.webhookDesc')}</p>
{#if webhookUrl}
<div class="flex items-center gap-3">
<code
class="flex-1 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-mono text-gray-700 break-all"
>
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
{webhookUrl}
</code>
<button
onclick={() => {
navigator.clipboard.writeText(webhookUrl);
toasts.info('Webhook URL copied to clipboard');
}}
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
onclick={() => { navigator.clipboard.writeText(webhookUrl); toasts.info($t('settingsGeneral.copied')); }}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
Copy
<IconCopy size={16} />
{$t('settingsGeneral.copy')}
</button>
</div>
{:else}
<p class="text-sm text-gray-400 italic">No webhook URL configured</p>
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('settingsGeneral.noWebhookUrl')}</p>
{/if}
<div class="mt-4">
<button
onclick={handleRegenerateWebhook}
disabled={regenerating}
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
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-light)] transition-colors disabled:opacity-50 active:animate-press"
>
{regenerating ? 'Regenerating...' : 'Regenerate URL'}
{#if regenerating}<IconLoader size={16} />{/if}
<IconRefresh size={16} />
{regenerating ? $t('settingsGeneral.regenerating') : $t('settingsGeneral.regenerateUrl')}
</button>
<p class="mt-1 text-xs text-gray-500">
Warning: regenerating will invalidate the current URL. Update your CI pipelines.
</p>
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.regenerateWarning')}</p>
</div>
</div>
{/if}
+109 -226
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
import EmptyState from '$lib/components/EmptyState.svelte';
interface AuthSettings {
auth_mode: string;
@@ -18,299 +21,179 @@
}
let settings = $state<AuthSettings>({
auth_mode: 'local',
oidc_client_id: '',
oidc_client_secret: '',
oidc_issuer_url: '',
oidc_redirect_url: ''
auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: ''
});
let users = $state<User[]>([]);
let saving = $state(false);
let message = $state('');
let error = $state('');
// New user form
let newUsername = $state('');
let newPassword = $state('');
let newEmail = $state('');
let newRole = $state('viewer');
function getToken(): string {
return localStorage.getItem('auth_token') ?? '';
}
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
function authHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`
};
}
onMount(async () => {
await Promise.all([loadSettings(), loadUsers()]);
});
onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });
async function loadSettings() {
try {
const res = await fetch('/api/auth/settings', { headers: authHeaders() });
const envelope = await res.json();
if (envelope.success) {
settings = envelope.data;
}
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to load settings';
}
try { const res = await fetch('/api/auth/settings', { headers: authHeaders() }); const envelope = await res.json(); if (envelope.success) settings = envelope.data; }
catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.loadFailed'); }
}
async function loadUsers() {
try {
const res = await fetch('/api/auth/users', { headers: authHeaders() });
const envelope = await res.json();
if (envelope.success) {
users = envelope.data ?? [];
}
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to load users';
}
try { const res = await fetch('/api/auth/users', { headers: authHeaders() }); const envelope = await res.json(); if (envelope.success) users = envelope.data ?? []; }
catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.loadFailed'); }
}
async function saveSettings() {
saving = true;
message = '';
error = '';
saving = true; message = ''; error = '';
try {
const res = await fetch('/api/auth/settings', {
method: 'PUT',
headers: authHeaders(),
body: JSON.stringify(settings)
});
const res = await fetch('/api/auth/settings', { method: 'PUT', headers: authHeaders(), body: JSON.stringify(settings) });
const envelope = await res.json();
if (envelope.success) {
message = 'Settings saved';
} else {
error = envelope.error ?? 'Failed to save';
}
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Network error';
} finally {
saving = false;
}
if (envelope.success) message = $t('settingsAuth.saved'); else error = envelope.error ?? $t('settingsAuth.saveFailed');
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; } finally { saving = false; }
}
async function addUser() {
if (!newUsername || !newPassword) {
error = 'Username and password are required';
return;
}
if (!newUsername || !newPassword) { error = $t('settingsAuth.usernameRequired'); return; }
try {
const res = await fetch('/api/auth/users', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
username: newUsername,
password: newPassword,
email: newEmail,
role: newRole
})
});
const res = await fetch('/api/auth/users', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ username: newUsername, password: newPassword, email: newEmail, role: newRole }) });
const envelope = await res.json();
if (envelope.success) {
newUsername = '';
newPassword = '';
newEmail = '';
newRole = 'viewer';
await loadUsers();
message = 'User created';
} else {
error = envelope.error ?? 'Failed to create user';
}
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Network error';
}
if (envelope.success) { newUsername = ''; newPassword = ''; newEmail = ''; newRole = 'viewer'; await loadUsers(); message = $t('settingsAuth.userCreated'); }
else error = envelope.error ?? $t('settingsAuth.createFailed');
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; }
}
async function deleteUser(id: string) {
if (!confirm('Are you sure you want to delete this user?')) return;
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
try {
const res = await fetch(`/api/auth/users/${id}`, {
method: 'DELETE',
headers: authHeaders()
});
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
const envelope = await res.json();
if (envelope.success) {
await loadUsers();
message = 'User deleted';
} else {
error = envelope.error ?? 'Failed to delete user';
}
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Network error';
}
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
else error = envelope.error ?? $t('settingsAuth.deleteFailed');
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; }
}
</script>
<div class="space-y-8">
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Authentication Settings</h1>
<p class="mt-1 text-sm text-gray-500">Configure authentication mode and manage users.</p>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsAuth.title')}</h2>
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsAuth.description')}</p>
</div>
{#if message}
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{message}</div>
<div class="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-700">{message}</div>
{/if}
{#if error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
<div class="rounded-lg bg-[var(--color-danger-light)] p-3 text-sm text-[var(--color-danger)]">{error}</div>
{/if}
<!-- Auth Mode Toggle -->
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-semibold text-gray-900">Authentication Mode</h2>
<!-- Auth Mode -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.authMode')}</h3>
<div class="mt-4 flex gap-4">
<label class="flex items-center gap-2">
<input type="radio" bind:group={settings.auth_mode} value="local" class="text-indigo-600" />
<span class="text-sm font-medium text-gray-700">Local (username/password)</span>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={settings.auth_mode} value="local" class="h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
<span class="text-sm font-medium text-[var(--text-secondary)]">{$t('settingsAuth.local')}</span>
</label>
<label class="flex items-center gap-2">
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="text-indigo-600" />
<span class="text-sm font-medium text-gray-700">OIDC (SSO)</span>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
<span class="text-sm font-medium text-[var(--text-secondary)]">{$t('settingsAuth.oidc')}</span>
</label>
</div>
</div>
<!-- OIDC Configuration -->
<!-- OIDC Config -->
{#if settings.auth_mode === 'oidc'}
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-semibold text-gray-900">OIDC Provider Configuration</h2>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3>
<div class="mt-4 space-y-4">
<div>
<label for="issuer" class="block text-sm font-medium text-gray-700">Issuer URL</label>
<input
id="issuer"
type="url"
bind:value={settings.oidc_issuer_url}
placeholder="https://auth.example.com/application/o/docker-watcher/"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700">Client ID</label>
<input
id="client_id"
type="text"
bind:value={settings.oidc_client_id}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label for="client_secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
<input
id="client_secret"
type="password"
bind:value={settings.oidc_client_secret}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label for="redirect" class="block text-sm font-medium text-gray-700">Redirect URL</label>
<input
id="redirect"
type="url"
bind:value={settings.oidc_redirect_url}
placeholder="https://watcher.example.com/api/auth/oidc/callback"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
{#each [
{ id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/docker-watcher/' },
{ id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' },
{ id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' },
{ id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' }
] as field}
<div class="flex flex-col gap-1.5">
<label for={field.id} class="text-sm font-medium text-[var(--text-primary)]">{field.label}</label>
<input
id={field.id}
type={field.type}
bind:value={settings[field.key as keyof AuthSettings]}
placeholder={field.placeholder}
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
/>
</div>
{/each}
</div>
</div>
{/if}
<button
onclick={saveSettings}
disabled={saving}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
<button onclick={saveSettings} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
{#if saving}<IconLoader size={16} />{/if}
{saving ? $t('settingsAuth.saving') : $t('settingsAuth.saveSettings')}
</button>
<!-- Local Users Management -->
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h2 class="text-lg font-semibold text-gray-900">Local Users</h2>
<!-- Local Users -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.localUsers')}</h3>
{#if users.length > 0}
<table class="mt-4 min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Username</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Email</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Role</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Created</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each users as user}
<div class="mt-4 overflow-hidden rounded-xl border border-[var(--border-primary)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<td class="px-3 py-2 text-sm text-gray-900">{user.username}</td>
<td class="px-3 py-2 text-sm text-gray-500">{user.email || '-'}</td>
<td class="px-3 py-2">
<span class="inline-flex rounded-full px-2 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}">
{user.role}
</span>
</td>
<td class="px-3 py-2 text-sm text-gray-500">{user.created_at}</td>
<td class="px-3 py-2 text-right">
<button
onclick={() => deleteUser(user.id)}
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</td>
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.username')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.email')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.role')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.created')}</th>
<th class="px-4 py-2.5"></th>
</tr>
{/each}
</tbody>
</table>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each users as user}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-2.5 text-sm text-[var(--text-primary)]">{user.username}</td>
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.email || '-'}</td>
<td class="px-4 py-2.5">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-700'}">
{user.role}
</span>
</td>
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
<td class="px-4 py-2.5 text-right">
<button onclick={() => deleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
<IconTrash size={16} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="mt-4 text-sm text-gray-500">No users found.</p>
<div class="mt-4">
<EmptyState title={$t('empty.noUsers')} description={$t('empty.noUsersDesc')} icon="users" />
</div>
{/if}
<div class="mt-6 border-t border-gray-200 pt-4">
<h3 class="text-sm font-semibold text-gray-900">Add User</h3>
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h4 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsAuth.addUser')}</h4>
<div class="mt-3 grid grid-cols-2 gap-3">
<input
type="text"
bind:value={newUsername}
placeholder="Username"
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password"
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<input
type="email"
bind:value={newEmail}
placeholder="Email (optional)"
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<select
bind:value={newRole}
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
<input type="text" bind:value={newUsername} placeholder={$t('settingsAuth.username')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
<input type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
<input type="email" bind:value={newEmail} placeholder="{$t('settingsAuth.email')} (optional)" class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
<select bind:value={newRole} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]">
<option value="viewer">{$t('settingsAuth.viewer')}</option>
<option value="admin">{$t('settingsAuth.admin')}</option>
</select>
</div>
<button
onclick={addUser}
class="mt-3 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-900"
>
Add User
<button onclick={addUser} class="mt-3 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press">
<IconPlus size={16} />
{$t('settingsAuth.addUser')}
</button>
</div>
</div>
+47 -159
View File
@@ -1,39 +1,25 @@
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconLoader, IconCheck, IconEdit } from '$lib/components/icons';
let loading = $state(true);
let saving = $state(false);
// NPM credentials
let npmUrl = $state('');
let npmEmail = $state('');
let npmPassword = $state('');
let npmHasCredentials = $state(false);
let editingNpm = $state(false);
let errors = $state<Record<string, string>>({});
function validateNpmForm(): boolean {
const newErrors: Record<string, string> = {};
if (!npmUrl.trim()) {
newErrors.npmUrl = 'NPM URL is required';
} else {
try {
new URL(npmUrl.trim());
} catch {
newErrors.npmUrl = 'Invalid URL format';
}
}
if (!npmEmail.trim()) {
newErrors.npmEmail = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) {
newErrors.npmEmail = 'Invalid email format';
}
if (editingNpm && !npmPassword.trim()) {
newErrors.npmPassword = 'Password is required when updating credentials';
}
if (!npmUrl.trim()) { newErrors.npmUrl = 'NPM URL is required'; } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = 'Invalid URL format'; } }
if (!npmEmail.trim()) { newErrors.npmEmail = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = 'Invalid email format'; }
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = 'Password is required when updating credentials'; }
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
@@ -44,178 +30,83 @@
const settings = await getSettings();
npmUrl = settings.npm_url ?? '';
npmEmail = settings.npm_email ?? '';
// If npm_password is present (even masked), credentials exist
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
npmPassword = '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load credentials';
toasts.error(message);
} finally {
loading = false;
}
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
}
async function handleSaveNpm() {
if (!validateNpmForm()) return;
saving = true;
try {
const payload: Record<string, string> = {
npm_url: npmUrl.trim(),
npm_email: npmEmail.trim()
};
if (npmPassword.trim()) {
payload.npm_password = npmPassword.trim();
}
const payload: Record<string, string> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
await updateSettings(payload);
npmHasCredentials = true;
editingNpm = false;
npmPassword = '';
toasts.success('NPM credentials saved');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save NPM credentials';
toasts.error(message);
} finally {
saving = false;
}
toasts.success($t('settingsCredentials.saved'));
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); } finally { saving = false; }
}
$effect(() => {
loadCredentials();
});
$effect(() => { loadCredentials(); });
</script>
<svelte:head>
<title>Credentials - Docker Watcher</title>
<title>{$t('settingsCredentials.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-800">Credentials</h2>
<p class="text-sm text-gray-500">
Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at
rest.
</p>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.title')}</h2>
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsCredentials.description')}</p>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
<div class="space-y-4"><Skeleton height="12rem" /></div>
{:else}
<!-- NPM Credentials -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-semibold text-gray-800">Nginx Proxy Manager</h3>
<p class="text-xs text-gray-500">Credentials for managing proxy hosts via NPM API</p>
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.npm')}</h3>
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsCredentials.npmDesc')}</p>
</div>
{#if npmHasCredentials && !editingNpm}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
Configured
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700">
<IconCheck size={12} />
{$t('settingsCredentials.configured')}
</span>
{/if}
</div>
{#if !editingNpm && npmHasCredentials}
<!-- Masked display -->
<div class="space-y-3">
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">URL</p>
<p class="text-sm text-gray-700">{npmUrl || 'Not set'}</p>
<div class="space-y-2">
{#each [{ label: $t('settingsCredentials.npmUrl'), value: npmUrl }, { label: $t('settingsCredentials.email'), value: npmEmail }, { label: $t('settingsCredentials.password'), value: '--------' }] as item}
<div class="flex items-center justify-between rounded-lg bg-[var(--surface-card-hover)] px-4 py-2.5">
<div>
<p class="text-xs font-medium text-[var(--text-tertiary)]">{item.label}</p>
<p class="text-sm text-[var(--text-secondary)] {item.label === $t('settingsCredentials.password') ? 'font-mono' : ''}">{item.value || 'Not set'}</p>
</div>
</div>
</div>
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">Email</p>
<p class="text-sm text-gray-700">{npmEmail || 'Not set'}</p>
</div>
</div>
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">Password</p>
<p class="text-sm font-mono text-gray-700">--------</p>
</div>
</div>
<button
onclick={() => {
editingNpm = true;
}}
class="mt-2 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Change Credentials
{/each}
<button onclick={() => { editingNpm = true; }} class="mt-3 inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconEdit size={16} />
{$t('settingsCredentials.changeCredentials')}
</button>
</div>
{:else}
<!-- Edit form -->
<div class="space-y-4">
<FormField
label="NPM URL"
name="npmUrl"
bind:value={npmUrl}
placeholder="http://npm:81"
required
error={errors.npmUrl ?? ''}
helpText="Nginx Proxy Manager API URL"
/>
<FormField
label="Email"
name="npmEmail"
type="email"
bind:value={npmEmail}
placeholder="admin@example.com"
required
error={errors.npmEmail ?? ''}
helpText="NPM admin email"
/>
<FormField
label="Password"
name="npmPassword"
type="password"
bind:value={npmPassword}
placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'}
required={editingNpm}
error={errors.npmPassword ?? ''}
helpText={npmHasCredentials
? 'Enter the new password to replace the existing one'
: 'NPM admin password (will be encrypted)'}
/>
<FormField label={$t('settingsCredentials.npmUrl')} name="npmUrl" bind:value={npmUrl} placeholder="http://npm:81" required error={errors.npmUrl ?? ''} helpText={$t('settingsCredentials.npmUrlHelp')} />
<FormField label={$t('settingsCredentials.email')} name="npmEmail" type="email" bind:value={npmEmail} placeholder="admin@example.com" required error={errors.npmEmail ?? ''} helpText={$t('settingsCredentials.emailHelp')} />
<FormField label={$t('settingsCredentials.password')} name="npmPassword" type="password" bind:value={npmPassword} placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'} required={editingNpm} error={errors.npmPassword ?? ''} helpText={npmHasCredentials ? $t('settingsCredentials.passwordHelpEdit') : $t('settingsCredentials.passwordHelpNew')} />
<div class="flex gap-3">
<button
onclick={handleSaveNpm}
disabled={saving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
<button onclick={handleSaveNpm} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
{#if saving}<IconLoader size={16} />{/if}
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
</button>
{#if npmHasCredentials}
<button
onclick={() => {
editingNpm = false;
npmPassword = '';
errors = {};
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
<button onclick={() => { editingNpm = false; npmPassword = ''; errors = {}; }} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
{$t('common.cancel')}
</button>
{/if}
</div>
@@ -223,15 +114,12 @@
{/if}
</div>
<!-- Registry Tokens info -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h3 class="text-sm font-semibold text-gray-800">Registry Tokens</h3>
<p class="mt-1 text-sm text-gray-500">
Registry authentication tokens are managed per-registry in the
<a href="/settings/registries" class="text-blue-600 hover:text-blue-700 underline"
>Registries</a
>
section. Each registry stores its token encrypted in the database.
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.registryTokens')}</h3>
<p class="mt-1 text-sm text-[var(--text-secondary)]">
{$t('settingsCredentials.registryTokensDesc')}
<a href="/settings/registries" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">{$t('settingsCredentials.registriesLink')}</a>
{$t('settingsCredentials.registryTokensSuffix')}
</p>
</div>
{/if}
+57 -220
View File
@@ -1,19 +1,16 @@
<script lang="ts">
import {
listRegistries,
createRegistry,
updateRegistry,
deleteRegistry,
testRegistry
} from '$lib/api';
import { listRegistries, createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
import type { Registry } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
let registries = $state<Registry[]>([]);
let loading = $state(true);
// Form state
let showForm = $state(false);
let editingId = $state<string | null>(null);
let formName = $state('');
@@ -28,285 +25,125 @@
function validateForm(): boolean {
const newErrors: Record<string, string> = {};
if (!formName.trim()) newErrors.name = 'Name is required';
if (!formUrl.trim()) {
newErrors.url = 'URL is required';
} else {
try {
new URL(formUrl.trim());
} catch {
newErrors.url = 'Invalid URL format';
}
}
if (!formToken.trim() && !editingId) {
newErrors.token = 'Token is required for new registries';
}
if (!formUrl.trim()) { newErrors.url = 'URL is required'; } else { try { new URL(formUrl.trim()); } catch { newErrors.url = 'Invalid URL format'; } }
if (!formToken.trim() && !editingId) newErrors.token = 'Token is required for new registries';
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
function resetForm() {
showForm = false;
editingId = null;
formName = '';
formUrl = '';
formType = 'gitea';
formToken = '';
errors = {};
}
function startEdit(registry: Registry) {
editingId = registry.id;
formName = registry.name;
formUrl = registry.url;
formType = registry.type;
formToken = '';
showForm = true;
errors = {};
}
function resetForm() { showForm = false; editingId = null; formName = ''; formUrl = ''; formType = 'gitea'; formToken = ''; errors = {}; }
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; showForm = true; errors = {}; }
async function loadRegistryList() {
loading = true;
try {
registries = await listRegistries();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load registries';
toasts.error(message);
} finally {
loading = false;
}
try { registries = await listRegistries(); } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.loadFailed')); } finally { loading = false; }
}
async function handleSave() {
if (!validateForm()) return;
formSaving = true;
try {
const payload: Partial<Registry> = {
name: formName.trim(),
url: formUrl.trim(),
type: formType
};
if (formToken.trim()) {
payload.token = formToken.trim();
}
if (editingId) {
await updateRegistry(editingId, payload);
toasts.success('Registry updated');
} else {
await createRegistry(payload);
toasts.success('Registry added');
}
const payload: Partial<Registry> = { name: formName.trim(), url: formUrl.trim(), type: formType };
if (formToken.trim()) payload.token = formToken.trim();
if (editingId) { await updateRegistry(editingId, payload); toasts.success($t('settingsRegistries.registryUpdated')); }
else { await createRegistry(payload); toasts.success($t('settingsRegistries.registryAdded')); }
resetForm();
await loadRegistryList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save registry';
toasts.error(message);
} finally {
formSaving = false;
}
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.saveFailed')); } finally { formSaving = false; }
}
async function handleDelete(registry: Registry) {
if (!confirm(`Delete registry "${registry.name}"? This cannot be undone.`)) return;
try {
await deleteRegistry(registry.id);
toasts.success(`Registry "${registry.name}" deleted`);
await loadRegistryList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete registry';
toasts.error(message);
}
if (!confirm($t('settingsRegistries.deleteConfirm', { name: registry.name }))) return;
try { await deleteRegistry(registry.id); toasts.success($t('settingsRegistries.registryDeleted', { name: registry.name })); await loadRegistryList(); }
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.deleteFailed')); }
}
async function handleTestConnection(registry: Registry) {
testingId = registry.id;
try {
await testRegistry(registry.id);
toasts.success(`Connection to "${registry.name}" successful`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection test failed';
toasts.error(message);
} finally {
testingId = null;
}
try { await testRegistry(registry.id); toasts.success($t('settingsRegistries.testSuccess', { name: registry.name })); }
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.testFailed')); }
finally { testingId = null; }
}
$effect(() => {
loadRegistryList();
});
$effect(() => { loadRegistryList(); });
</script>
<svelte:head>
<title>Registries - Docker Watcher</title>
<title>{$t('settingsRegistries.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-800">Container Registries</h2>
<p class="text-sm text-gray-500">Manage your container registries for image detection.</p>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsRegistries.title')}</h2>
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsRegistries.description')}</p>
</div>
{#if !showForm}
<button
onclick={() => {
resetForm();
showForm = true;
}}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Add Registry
<button onclick={() => { resetForm(); showForm = true; }} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press">
<IconPlus size={16} />
{$t('settingsRegistries.addRegistry')}
</button>
{/if}
</div>
<!-- Add/Edit Form -->
{#if showForm}
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-800">
{editingId ? 'Edit Registry' : 'Add New Registry'}
<div class="rounded-xl border border-[var(--color-brand-200)] bg-[var(--color-brand-50)]/30 p-6 animate-scale-in">
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">
{editingId ? $t('settingsRegistries.editRegistry') : $t('settingsRegistries.addNewRegistry')}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Name"
name="registryName"
bind:value={formName}
placeholder="gitea"
required
error={errors.name ?? ''}
helpText="A friendly name for this registry"
/>
<FormField
label="URL"
name="registryUrl"
bind:value={formUrl}
placeholder="https://git.example.com"
required
error={errors.url ?? ''}
helpText="Registry base URL"
/>
<div class="flex flex-col gap-1">
<label for="registryType" class="text-sm font-medium text-gray-700">Type</label>
<select
id="registryType"
bind:value={formType}
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<FormField label={$t('settingsRegistries.name')} name="registryName" bind:value={formName} placeholder="gitea" required error={errors.name ?? ''} helpText={$t('settingsRegistries.nameHelp')} />
<FormField label={$t('settingsRegistries.url')} name="registryUrl" bind:value={formUrl} placeholder="https://git.example.com" required error={errors.url ?? ''} helpText={$t('settingsRegistries.urlHelp')} />
<div class="flex flex-col gap-1.5">
<label for="registryType" class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsRegistries.type')}</label>
<select id="registryType" bind:value={formType} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
<option value="docker_hub">Docker Hub</option>
<option value="custom">Custom</option>
</select>
<p class="text-xs text-gray-500">Registry type for API compatibility</p>
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsRegistries.typeHelp')}</p>
</div>
<FormField
label="Token"
name="registryToken"
type="password"
bind:value={formToken}
placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'}
required={!editingId}
error={errors.token ?? ''}
helpText={editingId
? 'Leave empty to keep the existing token'
: 'API token for authentication'}
/>
<FormField label={$t('settingsRegistries.token')} name="registryToken" type="password" bind:value={formToken} placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'} required={!editingId} error={errors.token ?? ''} helpText={editingId ? $t('settingsRegistries.tokenHelpEdit') : $t('settingsRegistries.tokenHelpNew')} />
</div>
<div class="mt-4 flex gap-3">
<button
onclick={handleSave}
disabled={formSaving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{formSaving ? 'Saving...' : editingId ? 'Update' : 'Add Registry'}
<button onclick={handleSave} disabled={formSaving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
{#if formSaving}<IconLoader size={16} />{/if}
{formSaving ? $t('settingsRegistries.saving') : editingId ? $t('settingsRegistries.update') : $t('settingsRegistries.addRegistry')}
</button>
<button
onclick={resetForm}
disabled={formSaving}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
<button onclick={resetForm} disabled={formSaving} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
{$t('common.cancel')}
</button>
</div>
</div>
{/if}
<!-- Registry List -->
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<div class="space-y-3">
{#each Array(2) as _}
<Skeleton height="5rem" />
{/each}
</div>
{:else if registries.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">No registries configured yet.</p>
{#if !showForm}
<button
onclick={() => {
showForm = true;
}}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Add your first registry
</button>
{/if}
</div>
<EmptyState title={$t('empty.noRegistries')} description={$t('empty.noRegistriesDesc')} actionLabel={$t('settingsRegistries.addFirst')} onaction={() => { showForm = true; }} icon="registries" />
{:else}
<div class="space-y-3">
{#each registries as registry (registry.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
>
<div class="flex items-center justify-between rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-150 hover:shadow-[var(--shadow-md)]">
<div>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-800">{registry.name}</h3>
<span
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
{registry.type}
</span>
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{registry.name}</h3>
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">{registry.type}</span>
</div>
<p class="mt-1 text-sm text-gray-500">{registry.url}</p>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{registry.url}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => handleTestConnection(registry)}
disabled={testingId === registry.id}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
>
{testingId === registry.id ? 'Testing...' : 'Test'}
</button>
<button
onclick={() => startEdit(registry)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Edit
</button>
<button
onclick={() => handleDelete(registry)}
class="rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50"
>
Delete
<button onclick={() => handleTestConnection(registry)} disabled={testingId === registry.id} class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors">
{#if testingId === registry.id}<IconLoader size={14} />{:else}<IconWifi size={14} />{/if}
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
</button>
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
<button onclick={() => handleDelete(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
</div>
</div>
{/each}