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:
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
// Disable SSR for static adapter — all rendering happens client-side.
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user