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