feat(docker-watcher): phase 14 - frontend polish & modern UI
Design system with CSS custom properties (light/dark themes). 38 Lucide SVG icon components. Dark mode with system preference. EN/RU localization with i18n store. Skeleton loaders, empty states, toggle switches, micro-interactions. Responsive sidebar with mobile hamburger menu. All pages polished with consistent styling.
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
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, IconChevronRight, IconClock, IconTag, IconLoader } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let stages = $state<Stage[]>([]);
|
||||
@@ -13,17 +17,14 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Deploy form state.
|
||||
let deployStageId = $state('');
|
||||
let deployTag = $state('');
|
||||
let deployLoading = $state(false);
|
||||
let deployError = $state('');
|
||||
|
||||
// Available tags for deploy dropdown.
|
||||
let availableTags = $state<string[]>([]);
|
||||
let tagsLoading = $state(false);
|
||||
|
||||
// Delete project confirmation.
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
const projectId = $derived($page.params.id);
|
||||
@@ -36,7 +37,6 @@
|
||||
project = detail.project;
|
||||
stages = detail.stages;
|
||||
|
||||
// Fetch instances for each stage in parallel.
|
||||
const instanceResults = await Promise.all(
|
||||
stages.map(async (s) => {
|
||||
try {
|
||||
@@ -54,7 +54,6 @@
|
||||
}
|
||||
instancesByStage = mapped;
|
||||
|
||||
// Load recent deploys.
|
||||
try {
|
||||
const allDeploys = await api.listDeploys(20);
|
||||
deploys = allDeploys.filter((d) => d.project_id === projectId);
|
||||
@@ -62,7 +61,7 @@
|
||||
deploys = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load project';
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -96,7 +95,7 @@
|
||||
deployStageId = '';
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
deployError = e instanceof Error ? e.message : 'Deploy failed';
|
||||
deployError = e instanceof Error ? e.message : $t('projectDetail.deployFailed');
|
||||
} finally {
|
||||
deployLoading = false;
|
||||
}
|
||||
@@ -108,251 +107,267 @@
|
||||
await api.deleteProject(projectId);
|
||||
window.location.href = '/projects';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete project';
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.deleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-run when projectId changes.
|
||||
void projectId;
|
||||
loadProject();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{project?.name ?? 'Project'} - Docker Watcher</title>
|
||||
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
|
||||
<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-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-700">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadProject}>
|
||||
Retry
|
||||
<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>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/projects" class="text-sm text-gray-500 hover:text-gray-700">Projects</a>
|
||||
<span class="text-sm text-gray-400">/</span>
|
||||
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||
<a href="/projects" class="hover:text-[var(--text-link)] transition-colors">{$t('projects.title')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<h1 class="mt-1 text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||
<p class="mt-1 font-mono text-sm text-gray-500">{project.image}</p>
|
||||
<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="rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
|
||||
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; }}
|
||||
>
|
||||
Delete Project
|
||||
<IconTrash size={16} />
|
||||
{$t('projectDetail.deleteProject')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Project settings links -->
|
||||
<div class="mt-4 flex gap-3">
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/projects/{projectId}/env"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
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"
|
||||
>
|
||||
Environment Variables
|
||||
<IconKey size={16} />
|
||||
{$t('projectDetail.envVars')}
|
||||
</a>
|
||||
<a
|
||||
href="/projects/{projectId}/volumes"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
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"
|
||||
>
|
||||
Volume Mounts
|
||||
<IconHardDrive size={16} />
|
||||
{$t('projectDetail.volumes')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Project info -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 rounded-lg border border-gray-200 bg-white p-5 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Port</p>
|
||||
<p class="mt-1 text-sm text-gray-900">{project.port || '-'}</p>
|
||||
<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 || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Registry</p>
|
||||
<p class="mt-1 text-sm text-gray-900">{project.registry || '-'}</p>
|
||||
<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-gray-500 uppercase">Healthcheck</p>
|
||||
<p class="mt-1 text-sm text-gray-900">{project.healthcheck || '-'}</p>
|
||||
<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 || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase">Created</p>
|
||||
<p class="mt-1 text-sm text-gray-900">{new Date(project.created_at).toLocaleDateString()}</p>
|
||||
<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>
|
||||
|
||||
<!-- Stages & Instances -->
|
||||
<h2 class="mt-8 text-lg font-semibold text-gray-900">Stages</h2>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
|
||||
|
||||
{#if stages.length === 0}
|
||||
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
||||
<p class="text-sm text-gray-500">No stages configured for this project.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-6">
|
||||
{#each stages as stage (stage.id)}
|
||||
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
||||
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<!-- Stage header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-semibold text-gray-900">{stage.name}</h3>
|
||||
<span class="text-xs text-gray-500">Pattern: {stage.tag_pattern}</span>
|
||||
{#if stage.auto_deploy}
|
||||
<span class="rounded bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
auto-deploy
|
||||
{#if stages.length === 0}
|
||||
<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 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 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('projectDetail.autoDeploy')}</span>
|
||||
{/if}
|
||||
{#if stage.confirm}
|
||||
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</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>
|
||||
{/if}
|
||||
{#if stage.confirm}
|
||||
<span class="rounded bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700">
|
||||
requires confirm
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">
|
||||
{stageInstances.length} / {stage.max_instances} instances
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700"
|
||||
onclick={() => loadTags(stage.id)}
|
||||
>
|
||||
Deploy new version
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy form (shown when this stage is selected) -->
|
||||
{#if deployStageId === stage.id}
|
||||
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-gray-700">
|
||||
Select tag to deploy
|
||||
</label>
|
||||
{#if tagsLoading}
|
||||
<p class="mt-1 text-sm text-gray-500">Loading tags...</p>
|
||||
{:else if availableTags.length > 0}
|
||||
<select
|
||||
id="deploy-tag-{stage.id}"
|
||||
bind:value={deployTag}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Choose a tag...</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="Enter image tag (e.g., dev-abc123)"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
disabled={!deployTag.trim() || deployLoading}
|
||||
onclick={handleDeploy}
|
||||
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)}
|
||||
>
|
||||
{deployLoading ? 'Deploying...' : 'Deploy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-200"
|
||||
onclick={() => { deployStageId = ''; }}
|
||||
>
|
||||
Cancel
|
||||
<IconDeploy size={14} />
|
||||
{$t('projectDetail.deployNewVersion')}
|
||||
</button>
|
||||
</div>
|
||||
{#if deployError}
|
||||
<p class="mt-2 text-xs text-red-600">{deployError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Instances -->
|
||||
<div class="p-5">
|
||||
{#if stageInstances.length === 0}
|
||||
<p class="text-center text-sm text-gray-400">No instances running</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each stageInstances as instance (instance.id)}
|
||||
<InstanceCard
|
||||
{instance}
|
||||
{projectId}
|
||||
onchange={loadProject}
|
||||
/>
|
||||
{/each}
|
||||
<!-- 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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Deploy History -->
|
||||
<h2 class="mt-8 text-lg font-semibold text-gray-900">Recent Deploys</h2>
|
||||
<!-- 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-gray-500">No deploy history for this project.</p>
|
||||
{:else}
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Tag</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Status</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Started</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Finished</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each deploys as deploy (deploy.id)}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-5 py-3 font-mono text-sm text-gray-900">{deploy.image_tag}</td>
|
||||
<td class="whitespace-nowrap px-5 py-3">
|
||||
{#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" />
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
|
||||
{deploy.started_at ? new Date(deploy.started_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
|
||||
{deploy.finished_at ? new Date(deploy.finished_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-5 py-3 text-sm text-red-600">
|
||||
{deploy.error || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</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="Delete Project"
|
||||
message="This will permanently delete the project '{project.name}' and all its stages, instances, and deploy history. This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
title={$t('projectDetail.deleteConfirmTitle')}
|
||||
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDeleteProject}
|
||||
oncancel={() => { showDeleteConfirm = false; }}
|
||||
|
||||
Reference in New Issue
Block a user