1
0
forked from wrenn/wrenn

Rename /dashboard/snapshots to /dashboard/templates, show specs for all template types

- Rename snapshots route to templates for consistency with sidebar label
- Show vCPU and Memory values for base templates (not just snapshots),
  with tooltip distinguishing "Required" vs "Recommended"
- Show recipe copy button in admin build logs
- Admin panel defaults to /admin/templates on entry
- WORKDIR creates directory if not present (mkdir -p)
- Use USER command in pre-build instead of raw adduser
- Fix Svelte whitespace stripping in step keyword display
This commit is contained in:
2026-04-12 02:22:43 +06:00
parent 75af2a4f66
commit f5eeb0ffcc
3 changed files with 210 additions and 118 deletions

View File

@ -49,7 +49,7 @@
const platformItems: NavItem[] = [ const platformItems: NavItem[] = [
{ label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' }, { label: 'Capsules', icon: IconMonitor, href: '/dashboard/capsules' },
{ label: 'Templates', icon: IconBox, href: '/dashboard/snapshots' }, { label: 'Templates', icon: IconBox, href: '/dashboard/templates' },
{ label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' } { label: 'Metrics', icon: IconMetrics, href: '/dashboard/metrics' }
]; ];

View File

@ -270,47 +270,50 @@
<main class="flex min-w-0 flex-1 flex-col overflow-hidden"> <main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header --> <!-- Header -->
<header class="flex shrink-0 flex-col gap-4 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6 py-5"> <header class="relative shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]">
<div class="flex items-start justify-between"> <!-- Subtle gradient wash behind header for depth -->
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-accent)]/[0.02] to-transparent pointer-events-none"></div>
<div class="relative flex items-start justify-between px-8 pt-7 pb-5">
<div> <div>
<h1 class="font-serif text-[1.75rem] leading-none tracking-[-0.03em] text-[var(--color-text-bright)]"> <h1 class="font-serif text-page leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
Templates Templates
</h1> </h1>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Build and manage global templates available to all teams. Build and manage global templates available to all teams.
</p> </p>
</div> </div>
<button <button
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null }; }} onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null }; }}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0" class="group flex items-center gap-2.5 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform duration-200 group-hover:rotate-90"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create Template Create Template
</button> </button>
</div> </div>
<!-- Stat pills --> <!-- Stat strip — generous horizontal spacing, bolder presence -->
{#if !templatesLoading && !templatesError} {#if !templatesLoading && !templatesError}
<div class="flex items-center gap-2"> <div class="relative flex items-center gap-3 px-8 pb-5">
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1"> <div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{templateCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{templateCount}</span>
<span class="text-label text-[var(--color-text-muted)]">templates</span> <span class="text-label text-[var(--color-text-muted)]">templates</span>
</div> </div>
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-2.5 py-1"> <div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-text-bright)]">{baseCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{baseCount}</span>
<span class="text-label text-[var(--color-text-muted)]">base</span> <span class="text-label text-[var(--color-text-muted)]">base</span>
</div> </div>
<div class="flex items-baseline gap-1 rounded-[var(--radius-button)] border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8">
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-accent-bright)]">{snapshotCount}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-accent-bright)]">{snapshotCount}</span>
<span class="text-label text-[var(--color-accent-bright)]/70">snapshots</span> <span class="text-label text-[var(--color-accent-bright)]/70">snapshots</span>
</div> </div>
{#if runningBuilds > 0} {#if runningBuilds > 0}
<div class="flex items-baseline gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-2.5 py-1"> <div class="stat-pill border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 gap-2">
<span class="relative mt-px flex h-1.5 w-1.5 shrink-0 self-center"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-blue)] opacity-60"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-blue)] opacity-60"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-[var(--color-blue)]"></span> <span class="relative inline-flex h-2 w-2 rounded-full bg-[var(--color-blue)]"></span>
</span> </span>
<span class="font-mono font-semibold text-ui tabular-nums text-[var(--color-blue)]">{runningBuilds}</span> <span class="font-mono text-body font-bold tabular-nums text-[var(--color-blue)]">{runningBuilds}</span>
<span class="text-label text-[var(--color-blue)]/70">building</span> <span class="text-label text-[var(--color-blue)]/70">building</span>
</div> </div>
{/if} {/if}
@ -318,30 +321,32 @@
{/if} {/if}
</header> </header>
<!-- Tabs --> <!-- Tabs — heavier presence -->
<div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-6"> <div class="flex shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-8">
{#each [['templates', 'Templates', templateCount], ['builds', 'Builds', builds.length]] as [id, label, count] (id)} {#each [['templates', 'Templates', templateCount], ['builds', 'Builds', builds.length]] as [id, label, count] (id)}
<button <button
onclick={() => { activeTab = id as 'templates' | 'builds'; }} onclick={() => { activeTab = id as 'templates' | 'builds'; }}
class="relative py-3 pr-5 text-ui transition-colors duration-150 {activeTab === id class="tab-button {activeTab === id
? 'font-medium text-[var(--color-text-bright)]' ? 'text-[var(--color-text-bright)] font-medium'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}" : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'}"
> >
{label} {label}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-5 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
{#if !templatesLoading} {#if !templatesLoading}
<span class="ml-2 rounded-full bg-[var(--color-bg-4)] px-1.5 py-0.5 text-label text-[var(--color-text-muted)]"> <span class="ml-2 rounded-full px-2 py-0.5 text-label tabular-nums transition-colors duration-200 {activeTab === id
? 'bg-[var(--color-accent)]/12 text-[var(--color-accent-bright)]'
: 'bg-[var(--color-bg-4)] text-[var(--color-text-muted)]'}">
{count} {count}
</span> </span>
{/if} {/if}
{#if activeTab === id}
<span class="absolute bottom-0 left-0 right-0 h-[2px] rounded-t-full bg-[var(--color-accent)]"></span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto px-8 py-6">
{#if activeTab === 'templates'} {#if activeTab === 'templates'}
{#if templatesLoading} {#if templatesLoading}
{@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])} {@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])}
@ -374,20 +379,20 @@
<!-- ── Snippets ─────────────────────────────────────────────────────── --> <!-- ── Snippets ─────────────────────────────────────────────────────── -->
{#snippet skeletonRows(count: number, headers: string[])} {#snippet skeletonRows(count: number, headers: string[])}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
{#each headers as h} {#each headers as h}
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">{h}</th> <th class="table-header">{h}</th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each Array(count) as _, i} {#each Array(count) as _, i}
<tr class="border-b border-[var(--color-border)] last:border-0" style="animation-delay: {i * 60}ms"> <tr class="border-b border-[var(--color-border)] last:border-0 table-row-animate" style="animation-delay: {i * 60}ms">
{#each headers as _h, j} {#each headers as _h, j}
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="skeleton h-3 rounded" style="width: {60 + j * 12}px"></div> <div class="skeleton h-3 rounded" style="width: {60 + j * 12}px"></div>
</td> </td>
{/each} {/each}
@ -399,81 +404,99 @@
{/snippet} {/snippet}
{#snippet emptyState(type: 'templates' | 'builds')} {#snippet emptyState(type: 'templates' | 'builds')}
<div class="flex flex-col items-center justify-center py-24 text-center"> <div class="flex flex-col items-center justify-center py-28 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-2)]"> <!-- Floating icon with glow ring -->
<div class="relative mb-7">
<div class="absolute -inset-3 rounded-2xl bg-[var(--color-accent-glow)] blur-xl"></div>
<div class="empty-icon-float relative flex h-18 w-18 items-center justify-center rounded-xl border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-card">
{#if type === 'templates'} {#if type === 'templates'}
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
{:else} {:else}
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-muted)]"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="m16 13-3.5 3.5-2-2L8 17"/></svg> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-accent-mid)]"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="m16 13-3.5 3.5-2-2L8 17"/></svg>
{/if} {/if}
</div> </div>
<p class="font-serif text-[1.125rem] leading-snug text-[var(--color-text-secondary)]"> </div>
{type === 'templates' ? 'No templates yet.' : 'No builds yet.'} <p class="font-serif text-heading leading-snug text-[var(--color-text-secondary)]">
{type === 'templates' ? 'No templates yet' : 'No builds yet'}
</p> </p>
<p class="mt-1.5 text-ui text-[var(--color-text-muted)]"> <p class="mt-2 max-w-[320px] text-ui text-[var(--color-text-muted)]">
{type === 'templates' {type === 'templates'
? 'Create a template to provide pre-configured environments for all teams.' ? 'Create a template to provide pre-configured environments for all teams.'
: 'Start a template build to see progress and logs here.'} : 'Start a template build to see progress and logs here.'}
</p> </p>
{#if type === 'templates'}
<button
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, archive: null }; }}
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 px-4 py-2 text-ui font-medium text-[var(--color-accent-bright)] transition-all duration-200 hover:bg-[var(--color-accent)]/20 hover:border-[var(--color-accent)]/50"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create your first template
</button>
{/if}
</div> </div>
{/snippet} {/snippet}
{#snippet templatesTable()} {#snippet templatesTable()}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</th> <th class="table-header">Name</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Type</th> <th class="table-header">Type</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Specs</th> <th class="table-header hidden md:table-cell">Specs</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Size</th> <th class="table-header hidden lg:table-cell">Size</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Created</th> <th class="table-header hidden lg:table-cell">Created</th>
<th class="px-4 py-3"></th> <th class="table-header w-20"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each templates as tmpl (tmpl.name)} {#each templates as tmpl, i (tmpl.name)}
<tr class="border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]"> <tr
<td class="px-4 py-3.5"> class="table-row-animate border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]"
<div class="flex items-center gap-1.5"> style="animation-delay: {i * 30}ms"
<span class="font-mono text-meta text-[var(--color-text-primary)]">{tmpl.name}</span> >
<td class="px-5 py-3.5">
<div class="flex items-center gap-2">
<span class="font-mono text-ui font-medium text-[var(--color-text-bright)]">{tmpl.name}</span>
<CopyButton value={tmpl.name} /> <CopyButton value={tmpl.name} />
</div> </div>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
{#if tmpl.type === 'snapshot'} {#if tmpl.type === 'snapshot'}
<span class="inline-flex items-center rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2 py-0.5 text-label font-medium text-[var(--color-accent-bright)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-2.5 py-0.5 text-label font-medium text-[var(--color-accent-bright)]">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
snapshot snapshot
</span> </span>
{:else} {:else}
<span class="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2 py-0.5 text-label font-medium text-[var(--color-text-secondary)]"> <span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-0.5 text-label font-medium text-[var(--color-text-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-text-muted)]"></span>
base base
</span> </span>
{/if} {/if}
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
{#if tmpl.vcpus && tmpl.memory_mb} {#if tmpl.vcpus && tmpl.memory_mb}
<span class="text-meta text-[var(--color-text-secondary)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
{tmpl.vcpus} vCPU · {tmpl.memory_mb} MB {tmpl.vcpus} vCPU · {tmpl.memory_mb} MB
</span> </span>
{:else} {:else}
<span class="text-meta text-[var(--color-text-muted)]"></span> <span class="text-meta text-[var(--color-text-muted)]"></span>
{/if} {/if}
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-muted)]">
{tmpl.size_bytes ? formatBytes(tmpl.size_bytes) : '—'} {tmpl.size_bytes ? formatBytes(tmpl.size_bytes) : '—'}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={formatDate(tmpl.created_at)}> <span class="text-meta text-[var(--color-text-muted)]" title={formatDate(tmpl.created_at)}>
{timeAgo(tmpl.created_at)} {timeAgo(tmpl.created_at)}
</span> </span>
</td> </td>
<td class="px-4 py-3.5 text-right"> <td class="px-5 py-3.5 text-right">
<button <button
onclick={() => { deleteTarget = tmpl; deleteError = null; }} onclick={() => { deleteTarget = tmpl; deleteError = null; }}
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]" class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
> >
Delete Delete
</button> </button>
@ -486,28 +509,30 @@
{/snippet} {/snippet}
{#snippet buildsTable()} {#snippet buildsTable()}
<div class="space-y-0 rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden"> <div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] overflow-hidden shadow-sm">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-[var(--color-border)]"> <tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Build</th> <th class="table-header">Build</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Name</th> <th class="table-header">Name</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Base</th> <th class="table-header hidden md:table-cell">Base</th>
<th class="px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Status</th> <th class="table-header">Status</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] md:table-cell">Progress</th> <th class="table-header hidden md:table-cell">Progress</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Started</th> <th class="table-header hidden lg:table-cell">Started</th>
<th class="hidden px-4 py-3 text-left text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)] lg:table-cell">Duration</th> <th class="table-header hidden lg:table-cell">Duration</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each builds as build (build.id)} {#each builds as build, i (build.id)}
<tr <tr
class="border-b border-[var(--color-border)] last:border-0 cursor-pointer transition-colors duration-200 class="table-row-animate border-b border-[var(--color-border)] last:border-0 cursor-pointer transition-colors duration-200
{expandedBuildId === build.id ? 'bg-[var(--color-bg-2)]' : 'hover:bg-[var(--color-bg-2)]'}" {expandedBuildId === build.id ? 'bg-[var(--color-bg-2)]' : 'hover:bg-[var(--color-bg-2)]'}
{build.status === 'running' ? 'build-row-active' : ''}"
style="animation-delay: {i * 30}ms"
onclick={() => toggleBuildExpand(build.id)} onclick={() => toggleBuildExpand(build.id)}
> >
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2.5">
<svg <svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
@ -518,49 +543,51 @@
<span class="font-mono text-meta text-[var(--color-text-primary)]">{build.id}</span> <span class="font-mono text-meta text-[var(--color-text-primary)]">{build.id}</span>
</div> </div>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<span class="text-meta text-[var(--color-text-primary)]">{build.name}</span> <span class="text-ui font-medium text-[var(--color-text-primary)]">{build.name}</span>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]">{build.base_template}</span> <span class="font-mono text-meta text-[var(--color-text-muted)]">{build.base_template}</span>
</td> </td>
<td class="px-4 py-3.5"> <td class="px-5 py-3.5">
<span class="flex items-center gap-1.5 text-meta font-medium" style="color: {statusColor(build.status)}"> <span class="flex items-center gap-2 text-meta font-semibold" style="color: {statusColor(build.status)}">
{#if build.status === 'running'} {#if build.status === 'running'}
<span class="relative flex h-1.5 w-1.5 shrink-0"> <span class="relative flex h-2 w-2 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background: {statusColor(build.status)}"></span> <span class="animate-status-ping absolute inline-flex h-full w-full rounded-full opacity-60" style="background: {statusColor(build.status)}"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background: {statusColor(build.status)}"></span> <span class="relative inline-flex h-2 w-2 rounded-full" style="background: {statusColor(build.status)}"></span>
</span> </span>
{:else if build.status === 'success'} {:else if build.status === 'success'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{:else if build.status === 'failed'} {:else if build.status === 'failed'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{:else} {:else}
<span class="h-1.5 w-1.5 shrink-0 rounded-full" style="background: {statusColor(build.status)}"></span> <span class="h-2 w-2 shrink-0 rounded-full" style="background: {statusColor(build.status)}"></span>
{/if} {/if}
{build.status} {build.status}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 md:table-cell"> <td class="hidden px-5 py-3.5 md:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <div class="flex items-center gap-3">
{build.current_step} / {build.total_steps} <span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
{build.current_step}/{build.total_steps}
</span> </span>
{#if build.status === 'running' && build.total_steps > 0} {#if build.total_steps > 0}
<div class="mt-1.5 h-1 w-20 overflow-hidden rounded-full bg-[var(--color-bg-4)]"> <div class="relative h-1.5 w-24 overflow-hidden rounded-full bg-[var(--color-bg-4)]">
<div <div
class="h-full rounded-full bg-[var(--color-blue)] transition-all duration-500" class="h-full rounded-full transition-all duration-700 ease-out {build.status === 'running' ? 'progress-bar-glow' : ''}"
style="width: {(build.current_step / build.total_steps) * 100}%" style="width: {(build.current_step / build.total_steps) * 100}%; background: {statusColor(build.status)}"
></div> ></div>
</div> </div>
{/if} {/if}
</div>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="text-meta text-[var(--color-text-muted)]" title={formatDate(build.started_at)}> <span class="text-meta text-[var(--color-text-muted)]" title={formatDate(build.started_at)}>
{build.started_at ? timeAgo(build.started_at) : '—'} {build.started_at ? timeAgo(build.started_at) : '—'}
</span> </span>
</td> </td>
<td class="hidden px-4 py-3.5 lg:table-cell"> <td class="hidden px-5 py-3.5 lg:table-cell">
<span class="font-mono text-meta text-[var(--color-text-muted)]"> <span class="font-mono text-meta tabular-nums text-[var(--color-text-muted)]">
{formatDuration(build.started_at, build.completed_at)} {formatDuration(build.started_at, build.completed_at)}
</span> </span>
</td> </td>
@ -569,7 +596,7 @@
{#if expandedBuildId === build.id} {#if expandedBuildId === build.id}
<tr> <tr>
<td colspan="7" class="border-b border-[var(--color-border)] last:border-0"> <td colspan="7" class="border-b border-[var(--color-border)] last:border-0">
<div class="bg-[var(--color-bg-0)] px-6 py-4" style="animation: fadeUp 0.15s ease both"> <div class="bg-[var(--color-bg-0)] px-6 py-5" style="animation: fadeUp 0.15s ease both">
{#if build.status === 'pending' || build.status === 'running'} {#if build.status === 'pending' || build.status === 'running'}
<div class="mb-4 flex justify-end"> <div class="mb-4 flex justify-end">
<button <button
@ -708,10 +735,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }}
></div> ></div>
<div <div
class="relative w-full max-w-[520px] max-h-[90vh] overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[520px] max-h-[90vh] overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Top accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-accent)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Create Template Create Template
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -872,7 +903,7 @@
<button <button
onclick={handleCreate} onclick={handleCreate}
disabled={creating || !createForm.name.trim() || !createForm.recipe.trim()} disabled={creating || !createForm.name.trim() || !createForm.recipe.trim()}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0" class="group flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
> >
{#if creating} {#if creating}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
@ -884,6 +915,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{/if} {/if}
<!-- ── Delete Template Confirmation ────────────────────────────────── --> <!-- ── Delete Template Confirmation ────────────────────────────────── -->
@ -897,10 +929,14 @@
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }}
></div> ></div>
<div <div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6 shadow-xl" class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] shadow-dialog"
style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both" style="animation: fadeUp 0.18s cubic-bezier(0.25,1,0.5,1) both"
> >
<h2 class="font-serif text-[1.375rem] leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]"> <!-- Danger accent edge -->
<div class="h-[2px] rounded-t-[var(--radius-card)] bg-gradient-to-r from-transparent via-[var(--color-red)] to-transparent"></div>
<div class="p-6">
<h2 class="font-serif text-heading leading-tight tracking-[-0.02em] text-[var(--color-text-bright)]">
Delete Template Delete Template
</h2> </h2>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]"> <p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
@ -924,7 +960,7 @@
<button <button
onclick={handleDeleteTemplate} onclick={handleDeleteTemplate}
disabled={deleting} disabled={deleting}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 disabled:opacity-50" class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2.5 text-ui font-semibold text-white transition-all duration-200 hover:shadow-[0_0_16px_rgba(207,129,114,0.25)] hover:brightness-110 disabled:opacity-50"
> >
{#if deleting} {#if deleting}
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
@ -936,6 +972,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{/if} {/if}
<style> <style>
@ -959,4 +996,59 @@
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.4s ease infinite; animation: shimmer 1.4s ease infinite;
} }
/* Stat pill — shared base */
.stat-pill {
display: flex;
align-items: baseline;
gap: 6px;
border-radius: var(--radius-button);
border-width: 1px;
padding: 6px 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.stat-pill:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
/* Table header */
.table-header {
padding: 10px 20px;
text-align: left;
font-size: var(--text-label);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-tertiary);
}
/* Staggered row entrance */
.table-row-animate {
animation: fadeUp 0.25s ease both;
}
/* Tab button */
.tab-button {
position: relative;
padding: 14px 20px 14px 0;
font-size: var(--text-ui);
transition: color 0.15s ease;
cursor: pointer;
}
/* Active build row — subtle left accent */
.build-row-active {
box-shadow: inset 3px 0 0 var(--color-blue);
}
/* Progress bar glow for running builds */
.progress-bar-glow {
box-shadow: 0 0 8px rgba(90, 159, 212, 0.4);
}
/* Empty state icon float */
.empty-icon-float {
animation: iconFloat 3s ease-in-out infinite;
}
</style> </style>

View File

@ -383,8 +383,8 @@
<!-- vCPUs --> <!-- vCPUs -->
<div class="px-5 py-4"> <div class="px-5 py-4">
{#if snapshot.type === 'snapshot' && snapshot.vcpus != null} {#if snapshot.vcpus != null && snapshot.vcpus > 0}
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5" title={isSnapshot ? 'Required' : 'Recommended'}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" /><line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" /><line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" /><line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" /> <rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" /><line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" /><line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" /><line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg> </svg>
@ -397,8 +397,8 @@
<!-- Memory --> <!-- Memory -->
<div class="px-5 py-4"> <div class="px-5 py-4">
{#if snapshot.type === 'snapshot' && snapshot.memory_mb != null} {#if snapshot.memory_mb != null && snapshot.memory_mb > 0}
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5" title={isSnapshot ? 'Required' : 'Recommended'}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={typeColor} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 opacity-50">
<rect x="2" y="6" width="20" height="12" rx="2" /><line x1="6" y1="12" x2="6" y2="12.01" /><line x1="10" y1="12" x2="10" y2="12.01" /><line x1="14" y1="12" x2="14" y2="12.01" /><line x1="18" y1="12" x2="18" y2="12.01" /> <rect x="2" y="6" width="20" height="12" rx="2" /><line x1="6" y1="12" x2="6" y2="12.01" /><line x1="10" y1="12" x2="10" y2="12.01" /><line x1="14" y1="12" x2="14" y2="12.01" /><line x1="18" y1="12" x2="18" y2="12.01" />
</svg> </svg>