Files
tiny-forge/web/src/routes/projects/[id]/+page.svelte
T
alexei.dolgolyov c26c41e6a1 feat: enable proxy toggle on quick deploy, event log clearing, and UX fixes
- Add enable_proxy toggle to Quick Deploy form (defaults to on)
- Add DELETE /api/events/log/{id} and DELETE /api/events/log endpoints
- Add Clear All button with confirmation on Events page
- Rename "NPM Proxy" to "Enable Proxy" on stage form (provider-agnostic)
- Fix polling interval validation (min 60s) and number input trim errors
- Fix domain field no longer required in settings
2026-04-05 01:50:19 +03:00

548 lines
21 KiB
Svelte

<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';
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
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('');
let deployStageId = $state('');
let deployTag = $state('');
let deployLoading = $state(false);
let deployError = $state('');
let availableTags = $state<string[]>([]);
// Add stage form
let showAddStage = $state(false);
let stageName = $state('');
let stageTagPattern = $state('*');
let stageAutoDeploy = $state(true);
let stageEnableProxy = $state(true);
let stageMaxInstances = $state('1');
let addingStage = $state(false);
async function handleAddStage() {
if (!stageName.trim()) return;
addingStage = true;
try {
await api.createStage(projectId, {
name: stageName.trim(),
tag_pattern: stageTagPattern.trim() || '*',
auto_deploy: stageAutoDeploy,
enable_proxy: stageEnableProxy,
max_instances: parseInt(stageMaxInstances) || 1,
});
toasts.success($t('projectDetail.stageCreated', { name: stageName }));
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1';
showAddStage = false;
await loadProject();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageCreateFailed'));
} finally {
addingStage = false;
}
}
// Edit project
let editing = $state(false);
let editName = $state('');
let editImage = $state('');
let editPort = $state('');
let editHealthcheck = $state('');
let saving = $state(false);
function startEditing() {
if (!project) return;
editName = project.name;
editImage = project.image;
editPort = String(project.port || '');
editHealthcheck = project.healthcheck || '';
editing = true;
}
async function saveProject() {
if (!editName.trim() || !editImage.trim()) return;
saving = true;
try {
await api.updateProject(projectId, {
name: editName.trim(),
image: editImage.trim(),
port: parseInt(editPort) || 0,
healthcheck: editHealthcheck.trim(),
});
toasts.success($t('projectDetail.projectUpdated'));
editing = false;
await loadProject();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.updateFailed'));
} finally {
saving = false;
}
}
async function handleDeleteStage(stageId: string, name: string) {
try {
await api.deleteStage(projectId, stageId);
toasts.success($t('projectDetail.stageDeleted', { name }));
await loadProject();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
}
}
let tagsLoading = $state(false);
let showDeleteConfirm = $state(false);
const projectId = $derived($page.params.id);
async function loadProject() {
if (!project) loading = true;
error = '';
try {
const detail = await api.getProject(projectId);
project = detail.project;
stages = detail.stages ?? [];
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;
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 : $t('projectDetail.loadFailed');
} 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 : $t('projectDetail.deployFailed');
} 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 : $t('projectDetail.deleteFailed');
}
}
$effect(() => {
void projectId;
loadProject();
});
</script>
<svelte:head>
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
</svelte:head>
{#if loading}
<div class="space-y-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<Skeleton width="4rem" height="0.875rem" />
<Skeleton width="12rem" height="1.75rem" />
<Skeleton width="16rem" height="0.875rem" />
</div>
</div>
<div class="grid grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton height="3rem" />
{/each}
</div>
</div>
{:else if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProject}>
{$t('common.retry')}
</button>
</div>
{:else if project}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<Breadcrumb items={[{ label: $t('projects.title'), href: '/projects' }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p>
</div>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors active:animate-press"
onclick={() => { showDeleteConfirm = true; }}
>
<IconTrash size={16} />
{$t('projectDetail.deleteProject')}
</button>
</div>
<!-- Project settings links -->
<div class="flex gap-3">
<a
href="/projects/{projectId}/env"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconKey size={16} />
{$t('projectDetail.envVars')}
</a>
<a
href="/projects/{projectId}/volumes"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconHardDrive size={16} />
{$t('projectDetail.volumes')}
</a>
</div>
<!-- Project info -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
{#if editing}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField label={$t('projectDetail.nameLabel')} name="editName" bind:value={editName} />
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
</div>
<div class="mt-4 flex items-center gap-2 justify-end">
<button
type="button"
onclick={() => { editing = false; }}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconX size={14} />
{$t('projects.cancel')}
</button>
<button
type="button"
onclick={saveProject}
disabled={saving || !editName.trim() || !editImage.trim()}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
<IconCheck size={14} />
{saving ? $t('projectDetail.saving') : $t('common.save')}
</button>
</div>
{:else}
<div class="flex items-start justify-between">
<div class="grid grid-cols-2 gap-4 flex-1 sm:grid-cols-4">
<div>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.port || 'Auto'}</p>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.healthcheck')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.healthcheck || 'Auto'}</p>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.registry || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
<p class="mt-1 text-sm text-[var(--text-primary)]">{new Date(project.created_at).toLocaleDateString()}</p>
</div>
</div>
<button
type="button"
onclick={startEditing}
title={$t('common.edit')}
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
>
<IconEdit size={16} />
</button>
</div>
{/if}
</div>
<!-- Stages & Instances -->
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
<button
type="button"
onclick={() => { showAddStage = !showAddStage; }}
class="inline-flex items-center gap-1.5 rounded-lg {showAddStage ? 'border border-[var(--border-primary)] text-[var(--text-secondary)]' : 'bg-[var(--color-brand-600)] text-white'} px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
>
{#if !showAddStage}<IconPlus size={14} />{/if}
{showAddStage ? $t('projects.cancel') : $t('projectDetail.addStage')}
</button>
</div>
{#if showAddStage}
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
<FormField label={$t('projectDetail.nameLabel')} name="stageName" bind:value={stageName} placeholder="dev" />
<FormField label={$t('projectDetail.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} />
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.autoDeployLabel')}</label>
<div class="flex items-center h-[38px]">
<ToggleSwitch bind:checked={stageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('projectDetail.enableProxy')}</label>
<div class="flex items-center h-[38px]">
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="button"
onclick={handleAddStage}
disabled={addingStage || !stageName.trim()}
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all"
>
{addingStage ? $t('projectDetail.creating') : $t('projectDetail.createStage')}
</button>
</div>
</div>
{/if}
{#if stages.length === 0 && !showAddStage}
<div class="mt-4">
<EmptyState title={$t('projectDetail.noStages')} icon="instances" />
</div>
{:else}
<div class="mt-4 space-y-4">
{#each stages as stage (stage.id)}
{@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
<!-- Stage header -->
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
{/if}
{#if stage.confirm}
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
{/if}
{#if !stage.enable_proxy}
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
{/if}
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-[var(--text-tertiary)]">
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
</span>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
onclick={() => loadTags(stage.id)}
>
<IconDeploy size={14} />
{$t('projectDetail.deployNewVersion')}
</button>
<button
type="button"
title={$t('projectDetail.deleteStage')}
onclick={() => { if (confirm($t('projectDetail.deleteStageConfirm', { name: stage.name }))) handleDeleteStage(stage.id, stage.name); }}
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
</button>
</div>
</div>
<!-- Deploy form -->
{#if deployStageId === stage.id}
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4 animate-scale-in">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-[var(--text-secondary)]">
{$t('projectDetail.selectTag')}
</label>
{#if tagsLoading}
<div class="mt-1 flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<IconLoader size={16} />
{$t('projectDetail.loadingTags')}
</div>
{:else if availableTags.length > 0}
<select
id="deploy-tag-{stage.id}"
bind:value={deployTag}
class="mt-1 block w-full 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="">{$t('projectDetail.chooseTag')}</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={$t('projectDetail.enterTag')}
class="mt-1 block w-full 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"
/>
{/if}
</div>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!deployTag.trim() || deployLoading}
onclick={handleDeploy}
>
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
</button>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
onclick={() => { deployStageId = ''; }}
>
{$t('common.cancel')}
</button>
</div>
{#if deployError}
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
{/if}
</div>
{/if}
<!-- Instances -->
<div class="p-5">
{#if stageInstances.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</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}
</div>
<!-- Deploy History Timeline -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
{#if deploys.length === 0}
<p class="mt-4 text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noDeployHistory')}</p>
{:else}
<div class="mt-4 space-y-3">
{#each deploys as deploy (deploy.id)}
<div class="flex items-start gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
<!-- Timeline dot -->
<div class="mt-1 flex flex-col items-center">
<div class="h-3 w-3 rounded-full {deploy.status === 'success' ? 'bg-emerald-500' : deploy.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}"></div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-sm font-medium text-[var(--text-primary)]">{deploy.image_tag}</span>
<StatusBadge status={deploy.status} size="sm" />
</div>
<div class="mt-1 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
{#if deploy.started_at}
<span class="inline-flex items-center gap-1">
<IconClock size={12} />
{new Date(deploy.started_at).toLocaleString()}
</span>
{/if}
{#if deploy.finished_at}
<span>{new Date(deploy.finished_at).toLocaleString()}</span>
{/if}
</div>
{#if deploy.error}
<p class="mt-1 text-xs text-[var(--color-danger)] truncate">{deploy.error}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<ConfirmDialog
open={showDeleteConfirm}
title={$t('projectDetail.deleteConfirmTitle')}
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
confirmLabel={$t('common.delete')}
confirmVariant="danger"
onconfirm={handleDeleteProject}
oncancel={() => { showDeleteConfirm = false; }}
/>
{/if}