Files
tiny-forge/web/src/routes/deploy/+page.svelte
T

289 lines
11 KiB
Svelte

<script lang="ts">
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api';
import type { InspectResult, Registry, RegistryImage } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
let imageUrl = $state('');
let inspecting = $state(false);
let deploying = $state(false);
let inspected = $state(false);
let inspectResult: InspectResult | null = $state(null);
let projectName = $state('');
let port = $state('');
let healthcheck = $state('');
let stage = $state('dev');
let subdomain = $state('');
let envVars = $state('');
let errors = $state<Record<string, string>>({});
// Image browser state
let showImageBrowser = $state(false);
let browseImages = $state<(RegistryImage & { registryName: string })[]>([]);
let browseLoading = $state(false);
async function handleBrowseImages() {
showImageBrowser = !showImageBrowser;
if (!showImageBrowser) return;
browseLoading = true;
browseImages = [];
try {
const registries = await listRegistries();
const allImages: (RegistryImage & { registryName: string })[] = [];
for (const reg of registries) {
if (!reg.owner) continue;
try {
const images = await listRegistryImages(reg.id);
for (const img of images) {
allImages.push({ ...img, registryName: reg.name });
}
} catch {
// Skip registries that fail.
}
}
browseImages = allImages;
} catch {
toasts.error($t('quickDeploy.imageLoadFailed'));
} finally {
browseLoading = false;
}
}
function selectBrowsedImage(image: RegistryImage & { registryName: string }) {
imageUrl = image.full_ref + ':latest';
showImageBrowser = false;
}
function validateImageUrl(url: string): string {
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
return $t('validation.invalidUrl');
}
return '';
}
function validatePort(value: string): string {
if (!value.trim()) return $t('validation.required', { field: 'Port' });
const num = parseInt(value, 10);
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
return '';
}
function validateProjectName(value: string): string {
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
return $t('validation.invalidProjectName');
}
return '';
}
function validateAll(): boolean {
const newErrors: Record<string, string> = {};
const nameErr = validateProjectName(projectName);
if (nameErr) newErrors.projectName = nameErr;
const portErr = validatePort(port);
if (portErr) newErrors.port = portErr;
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
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) {
errors = { imageUrl: urlError };
return;
}
errors = {};
inspecting = true;
try {
const result = await inspectImage(imageUrl.trim());
inspectResult = result;
projectName = deriveProjectName(result.image);
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
stage = 'dev';
subdomain = '';
envVars = '';
inspected = true;
toasts.success($t('quickDeploy.inspectedSuccess'));
} catch (err) {
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
toasts.error(message);
} finally {
inspecting = false;
}
}
async function handleDeploy() {
if (!validateAll()) return;
deploying = true;
try {
await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) });
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
imageUrl = '';
inspected = false;
inspectResult = null;
projectName = '';
port = '';
healthcheck = '';
stage = 'dev';
subdomain = '';
envVars = '';
} catch (err) {
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
toasts.error(message);
} finally {
deploying = false;
}
}
</script>
<svelte:head>
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('quickDeploy.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.description')}</p>
</div>
<!-- Step 1 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
<div class="flex gap-3">
<div class="flex-1">
<FormField
label={$t('quickDeploy.imageUrl')}
name="imageUrl"
bind:value={imageUrl}
placeholder="registry.example.com/org/app:tag"
required
error={errors.imageUrl ?? ''}
helpText={$t('quickDeploy.imageUrlHelp')}
disabled={inspecting}
/>
</div>
<div class="flex items-end gap-2">
<button
type="button"
onclick={handleBrowseImages}
title={$t('quickDeploy.browseImages')}
aria-label={$t('quickDeploy.browseImages')}
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
>
<IconSearch size={16} />
</button>
<button
onclick={handleInspect}
disabled={inspecting || !imageUrl.trim()}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
>
{#if inspecting}
<IconLoader size={16} />
{$t('quickDeploy.inspecting')}
{:else}
<IconSearch size={16} />
{$t('quickDeploy.inspect')}
{/if}
</button>
</div>
</div>
{#if showImageBrowser}
<div class="mt-3 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3 shadow-[var(--shadow-md)] max-h-60 overflow-y-auto animate-scale-in">
{#if browseLoading}
<div class="flex items-center gap-2 py-2 text-sm text-[var(--text-secondary)]">
<IconLoader size={14} />
{$t('quickDeploy.loadingImages')}
</div>
{:else if browseImages.length === 0}
<p class="text-sm text-[var(--text-tertiary)]">{$t('quickDeploy.noImages')}</p>
{:else}
<p class="mb-2 text-xs font-medium text-[var(--text-tertiary)]">{$t('quickDeploy.selectImage')}</p>
<ul class="space-y-1">
{#each browseImages as image}
<li>
<button
type="button"
onclick={() => selectBrowsedImage(image)}
class="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
>
<span class="font-medium text-[var(--text-primary)]">{image.full_ref}</span>
<span class="ml-2 text-xs text-[var(--text-tertiary)]">({image.registryName})</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<!-- Step 2 -->
{#if inspected}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText="Lowercase with hyphens" />
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
<FormField label={$t('quickDeploy.healthCheckPath')} name="healthcheck" bind:value={healthcheck} placeholder="/api/health" helpText={$t('quickDeploy.healthCheckHelp')} />
<div class="flex flex-col gap-1.5">
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="dev">{$t('quickDeploy.development')}</option>
<option value="rel">{$t('quickDeploy.release')}</option>
<option value="prod">{$t('quickDeploy.production')}</option>
</select>
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
</div>
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
</div>
<div class="mt-4">
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder="KEY=value&#10;ANOTHER_KEY=another_value" helpText={$t('quickDeploy.envVarsHelp')} />
</div>
</div>
<!-- Step 3 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
<div class="flex gap-3">
<button
onclick={handleDeploy}
disabled={deploying}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
>
{#if deploying}
<IconLoader size={16} />
{$t('projectDetail.deploying')}
{:else}
<IconDeploy size={16} />
{$t('quickDeploy.deployBtn')}
{/if}
</button>
<button
onclick={() => { inspected = false; inspectResult = null; }}
disabled={deploying}
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
{$t('common.cancel')}
</button>
</div>
</div>
{/if}
</div>