289 lines
11 KiB
Svelte
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 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>
|