new destruction flow

This commit is contained in:
2026-09-17 16:57:08 -04:00
parent 5f8e03c693
commit a611850649
13 changed files with 349 additions and 96 deletions
+103 -2
View File
@@ -1,2 +1,103 @@
<script lang="ts">import { page } from "$app/stores";</script>
<p>This rollout is now a campaign. <a href="/campaigns/{$page.params.id}">View campaign</a></p>
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
interface Deployment {
id: string; server: string; action: 'create' | 'destroy'; state: string;
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[] }
let detail = $state<Detail | null>(null);
let error = $state('');
let refreshing = false;
let deployment = $derived(detail?.deployment);
let active = $derived(deployment?.state === 'waiting' || deployment?.state === 'running');
let warnings = $derived.by(() => {
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
});
let steps = $derived(deployment?.action === 'destroy'
? ['Request destruction', 'Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry']
: ['Request creation', 'Prepare server files', 'Create Komodo deployment', 'Start container']);
let currentStep = $derived.by(() => {
if (!deployment) return 0;
const stages = deployment.action === 'destroy'
? ['Starting', 'Waiting', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container'];
if (deployment.state === 'waiting') return 1;
if (deployment.state === 'done') return stages.length;
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
});
function stateLabel(state: string) {
return ({ waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
}
async function load() {
if (refreshing) return;
refreshing = true;
const id = $page.params.id ?? '';
try {
const res = await fetch('/api/deployments/' + encodeURIComponent(id));
if (!res.ok) throw new Error(res.status === 404 ? 'Deployment not found.' : 'Could not refresh deployment.');
const body = await res.json();
if ($page.params.id === id) { detail = body; error = ''; }
} catch (err) { error = (err as Error).message; }
finally { refreshing = false; }
}
$effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => {
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
</script>
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
</SectionHeader>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if deployment}
<section class="summary">
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div>
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server created; container start requested') : deployment.stage}</h2>
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
</section>
<section>
<h2>Operation flow</h2>
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index + 1}</span>{step}</li>{/each}</ol>
</section>
<section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if detail && detail.events.length > 0}
<ol class="timeline">{#each detail.events as event (event.id)}<li><time datetime={event.created_at}>{fmt(event.created_at)}</time><p>{event.message}</p></li>{/each}</ol>
{:else}<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown above.</p>{/if}
</section>
{:else if !error}<p>Loading deployment...</p>{/if}
</div>
<style>
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; }
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow li.reached { color: var(--text-primary); }
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
.timeline p { margin-top: 6px; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
</style>