c26c41e6a1
- 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
548 lines
21 KiB
Svelte
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}
|