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