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