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
+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>