feat(docker-watcher): phase 9 - SvelteKit dashboard & project views

SvelteKit project with Svelte 5, TypeScript, Tailwind CSS v4.
Dashboard with project cards, project detail with stage/instance
management, deploy history, instance controls. Shared API client
and reusable components (StatusBadge, InstanceCard, ProjectCard,
ConfirmDialog). Add Phase 14 (Volumes & Environment) to plan.
This commit is contained in:
2026-03-27 22:15:54 +03:00
parent 757c72eea1
commit 09d185d94e
13 changed files with 1787 additions and 53 deletions
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import '../app.css';
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
interface Props {
children: Snippet;
}
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' }
] as const;
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
</script>
<div class="flex h-screen bg-gray-50">
<!-- 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>
</div>
<nav class="flex-1 space-y-1 px-3 py-4">
{#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'}"
>
{#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>
{: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>
{: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>
{: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>
{/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>
</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>
+3
View File
@@ -0,0 +1,3 @@
// Disable SSR for static adapter — all rendering happens client-side.
export const ssr = false;
export const prerender = true;
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import type { Project, Instance } from '$lib/types';
import * as api from '$lib/api';
import ProjectCard from '$lib/components/ProjectCard.svelte';
let projects = $state<Project[]>([]);
let instancesByProject = $state<Record<string, Instance[]>>({});
let loading = $state(true);
let error = $state('');
async function loadDashboard() {
loading = true;
error = '';
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))
);
return { projectId: p.id, instances: stageInstances.flat() };
} catch {
return { projectId: p.id, instances: [] };
}
});
const results = await Promise.all(detailPromises);
const mapped: Record<string, Instance[]> = {};
for (const r of results) {
mapped[r.projectId] = r.instances;
}
instancesByProject = mapped;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load dashboard';
} finally {
loading = false;
}
}
$effect(() => {
loadDashboard();
});
const totalProjects = $derived(projects.length);
const totalRunning = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'running').length
);
const totalFailed = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'failed').length
);
</script>
<svelte:head>
<title>Dashboard - Docker Watcher</title>
</svelte:head>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</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"
>
Quick Deploy
</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>
</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>
<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>
</div>
<!-- Project cards -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">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}
</div>
+18 -29
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import type { InspectResult, QuickDeployRequest } from '$lib/types';
import { inspectImage, quickDeploy } from '$lib/api';
import type { InspectResult } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
@@ -39,7 +39,7 @@
function validateProjectName(value: string): string {
if (!value.trim()) return 'Project name is required';
if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim()) && value.trim().length > 1) {
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
}
return '';
@@ -55,6 +55,13 @@
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('/');
return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-');
}
async function handleInspect() {
const urlError = validateImageUrl(imageUrl);
if (urlError) {
@@ -65,18 +72,16 @@
inspecting = true;
try {
const result = await api.post<InspectResult>('/api/deploy/inspect', {
image: imageUrl.trim()
});
const result = await inspectImage(imageUrl.trim());
inspectResult = result;
// Auto-fill form with inspection results
projectName = result.project_name ?? '';
projectName = deriveProjectName(result.image);
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
stage = 'dev';
subdomain = result.suggested_subdomain ?? '';
envVars = result.env_vars ? result.env_vars.map((e) => `${e.key}=${e.value}`).join('\n') : '';
subdomain = '';
envVars = '';
inspected = true;
toasts.success('Image inspected successfully');
} catch (err) {
@@ -92,27 +97,11 @@
deploying = true;
try {
const envMap: Record<string, string> = {};
if (envVars.trim()) {
for (const line of envVars.split('\n')) {
const eqIndex = line.indexOf('=');
if (eqIndex > 0) {
envMap[line.substring(0, eqIndex).trim()] = line.substring(eqIndex + 1).trim();
}
}
}
const payload: QuickDeployRequest = {
await quickDeploy({
image: imageUrl.trim(),
project_name: projectName.trim(),
port: parseInt(port, 10),
healthcheck: healthcheck.trim() || undefined,
stage: stage,
subdomain: subdomain.trim() || undefined,
env: Object.keys(envMap).length > 0 ? envMap : undefined
};
await api.post('/api/deploy/quick', payload);
name: projectName.trim(),
port: parseInt(port, 10)
});
toasts.success(`Deployed ${projectName} successfully!`);
// Reset form
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import type { Project } from '$lib/types';
import * as api from '$lib/api';
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 formHealthcheck = $state('');
let formSubmitting = $state(false);
let formError = $state('');
async function loadProjects() {
loading = true;
error = '';
try {
projects = await api.listProjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
} finally {
loading = false;
}
}
async function handleAddProject() {
if (!formName.trim() || !formImage.trim()) {
formError = 'Name and image are required.';
return;
}
formSubmitting = true;
formError = '';
try {
await api.createProject({
name: formName.trim(),
image: formImage.trim(),
registry: formRegistry.trim(),
port: formPort,
healthcheck: formHealthcheck.trim()
});
// Reset form.
formName = '';
formImage = '';
formRegistry = '';
formPort = 3000;
formHealthcheck = '';
showAddForm = false;
await loadProjects();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to create project';
} finally {
formSubmitting = false;
}
}
$effect(() => {
loadProjects();
});
</script>
<svelte:head>
<title>Projects - Docker Watcher</title>
</svelte:head>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Projects</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"
onclick={() => { showAddForm = !showAddForm; }}
>
{showAddForm ? 'Cancel' : 'Add Project'}
</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>
{#if formError}
<div class="mt-3 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-700">{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>
<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"
/>
</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"
disabled={formSubmitting}
onclick={handleAddProject}
>
{formSubmitting ? 'Creating...' : 'Create Project'}
</button>
</div>
</div>
{/if}
<!-- 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>
{: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
</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>
{: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">
<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"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each projects as project (project.id)}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-indigo-600 hover:text-indigo-800">
{project.name}
</a>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-gray-500">
{project.image}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{project.port || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{project.registry || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{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>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+344
View File
@@ -0,0 +1,344 @@
<script lang="ts">
import { page } from '$app/stores';
import type { Project, Stage, Instance, Deploy } from '$lib/types';
import * as api from '$lib/api';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import InstanceCard from '$lib/components/InstanceCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let project = $state<Project | null>(null);
let stages = $state<Stage[]>([]);
let instancesByStage = $state<Record<string, Instance[]>>({});
let deploys = $state<Deploy[]>([]);
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);
async function loadProject() {
loading = true;
error = '';
try {
const detail = await api.getProject(projectId);
project = detail.project;
stages = detail.stages;
// Fetch instances for each stage in parallel.
const instanceResults = await Promise.all(
stages.map(async (s) => {
try {
const instances = await api.listInstances(projectId, s.id);
return { stageId: s.id, instances };
} catch {
return { stageId: s.id, instances: [] };
}
})
);
const mapped: Record<string, Instance[]> = {};
for (const r of instanceResults) {
mapped[r.stageId] = r.instances;
}
instancesByStage = mapped;
// Load recent deploys.
try {
const allDeploys = await api.listDeploys(20);
deploys = allDeploys.filter((d) => d.project_id === projectId);
} catch {
deploys = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load project';
} finally {
loading = false;
}
}
async function loadTags(stageId: string) {
deployStageId = stageId;
deployTag = '';
availableTags = [];
if (!project?.registry || !project?.image) return;
tagsLoading = true;
try {
availableTags = await api.listRegistryTags(project.registry, project.image);
} catch {
availableTags = [];
} finally {
tagsLoading = false;
}
}
async function handleDeploy() {
if (!deployTag.trim() || !deployStageId) return;
deployLoading = true;
deployError = '';
try {
await api.deployInstance(projectId, deployStageId, deployTag.trim());
deployTag = '';
deployStageId = '';
await loadProject();
} catch (e) {
deployError = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deployLoading = false;
}
}
async function handleDeleteProject() {
showDeleteConfirm = false;
try {
await api.deleteProject(projectId);
window.location.href = '/projects';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
}
}
$effect(() => {
// Re-run when projectId changes.
void projectId;
loadProject();
});
</script>
<svelte:head>
<title>{project?.name ?? 'Project'} - Docker Watcher</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>
{: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
</button>
</div>
{:else if project}
<div>
<!-- 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>
<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>
</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"
onclick={() => { showDeleteConfirm = true; }}
>
Delete Project
</button>
</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>
<p class="text-xs font-medium text-gray-500 uppercase">Port</p>
<p class="mt-1 text-sm text-gray-900">{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>
</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>
</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>
</div>
</div>
<!-- Stages & Instances -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">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
</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}
>
{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
</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}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- Deploy History -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Recent Deploys</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">
<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>
<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"
confirmVariant="danger"
onconfirm={handleDeleteProject}
oncancel={() => { showDeleteConfirm = false; }}
/>
{/if}
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl } from '$lib/api';
import type { Settings } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
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('');
let subdomainPattern = $state('');
let pollingInterval = $state('');
let notificationUrl = $state('');
let errors = $state<Record<string, string>>({});
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';
}
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';
}
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';
}
return '';
}
function validateUrl(value: string): string {
if (!value.trim()) return '';
try {
new URL(value.trim());
return '';
} catch {
return 'Invalid URL format';
}
}
function validateAll(): boolean {
const newErrors: Record<string, string> = {};
const domainErr = validateDomain(domain);
if (domainErr) newErrors.domain = domainErr;
const ipErr = validateIp(serverIp);
if (ipErr) newErrors.serverIp = ipErr;
const intervalErr = validatePollingInterval(pollingInterval);
if (intervalErr) newErrors.pollingInterval = intervalErr;
const urlErr = validateUrl(notificationUrl);
if (urlErr) newErrors.notificationUrl = urlErr;
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
async function loadSettings() {
loading = true;
try {
const settings = await getSettings();
domain = settings.domain ?? '';
serverIp = settings.server_ip ?? '';
network = settings.network ?? '';
subdomainPattern = settings.subdomain_pattern ?? '';
pollingInterval = settings.polling_interval ?? '';
notificationUrl = settings.notification_url ?? '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load settings';
toasts.error(message);
} finally {
loading = false;
}
}
async function loadWebhookUrlValue() {
try {
const result = await getWebhookUrl();
webhookUrl = result.url;
} catch {
// Webhook URL may not be configured yet
}
}
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(),
notification_url: notificationUrl.trim()
};
await updateSettings(payload);
toasts.success('Settings saved successfully');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save settings';
toasts.error(message);
} finally {
saving = false;
}
}
async function handleRegenerateWebhook() {
regenerating = true;
try {
const result = await regenerateWebhookUrl();
webhookUrl = result.url;
toasts.success('Webhook URL regenerated');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to regenerate webhook URL';
toasts.error(message);
} finally {
regenerating = false;
}
}
$effect(() => {
loadSettings();
loadWebhookUrlValue();
});
</script>
<svelte:head>
<title>General Settings - Docker Watcher</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>
{: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="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"
/>
</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>
</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>
{#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"
>
{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"
>
Copy
</button>
</div>
{:else}
<p class="text-sm text-gray-400 italic">No webhook URL configured</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"
>
{regenerating ? 'Regenerating...' : 'Regenerate URL'}
</button>
<p class="mt-1 text-xs text-gray-500">
Warning: regenerating will invalidate the current URL. Update your CI pipelines.
</p>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,238 @@
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
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';
}
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
async function loadCredentials() {
loading = true;
try {
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;
}
}
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();
}
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;
}
}
$effect(() => {
loadCredentials();
});
</script>
<svelte:head>
<title>Credentials - Docker Watcher</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>
</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>
{:else}
<!-- NPM Credentials -->
<div class="rounded-lg border border-gray-200 bg-white p-6 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>
</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>
{/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>
</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
</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)'}
/>
<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>
{#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>
{/if}
</div>
</div>
{/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.
</p>
</div>
{/if}
</div>
@@ -0,0 +1,315 @@
<script lang="ts">
import {
listRegistries,
createRegistry,
updateRegistry,
deleteRegistry,
testRegistry
} from '$lib/api';
import type { Registry } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
let registries = $state<Registry[]>([]);
let loading = $state(true);
// Form state
let showForm = $state(false);
let editingId = $state<string | null>(null);
let formName = $state('');
let formUrl = $state('');
let formType = $state('gitea');
let formToken = $state('');
let formSaving = $state(false);
let testingId = $state<string | null>(null);
let errors = $state<Record<string, string>>({});
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';
}
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 = {};
}
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;
}
}
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');
}
resetForm();
await loadRegistryList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save registry';
toasts.error(message);
} 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);
}
}
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;
}
}
$effect(() => {
loadRegistryList();
});
</script>
<svelte:head>
<title>Registries - Docker Watcher</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>
</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>
{/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'}
</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"
>
<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>
</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'}
/>
</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>
<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>
</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>
{: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>
{: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>
<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>
</div>
<p class="mt-1 text-sm text-gray-500">{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>
</div>
</div>
{/each}
</div>
{/if}
</div>