Add deployment blueprints and reviewed launch recipes
This commit is contained in:
@@ -4,16 +4,23 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
|
||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||
|
||||
interface Deployment {
|
||||
id: string; expected_map?: 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 }[] }
|
||||
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean }
|
||||
let detail = $state<Detail | null>(null);
|
||||
let error = $state('');
|
||||
let refreshing = false;
|
||||
let now = $state(Date.now());
|
||||
let open = $state(false);
|
||||
let resuming = $state(false);
|
||||
let deployment = $derived(detail?.deployment);
|
||||
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
|
||||
let warnings = $derived.by(() => {
|
||||
@@ -29,9 +36,43 @@
|
||||
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
|
||||
if (deployment.state === 'waiting') return 1;
|
||||
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
|
||||
if (deployment.state === 'done') return stages.length;
|
||||
if (deployment.state === 'done' || deployment.state === 'warning') return stages.length;
|
||||
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
|
||||
});
|
||||
let explanation = $derived.by(() => {
|
||||
if (!deployment) return '';
|
||||
if (deployment.state === 'done') return deployment.action === 'create'
|
||||
? 'The container is healthy, RCON responds, and the reported engine map matches your selection.'
|
||||
: 'The container, deployment, and server registration have been removed.';
|
||||
if (deployment.state === 'warning') return 'The operation finished with the warnings below. Review any remaining cleanup.';
|
||||
if (deployment.state === 'failed' || deployment.state === 'interrupted') return detail?.canResumeVerification
|
||||
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
|
||||
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
|
||||
if (deployment.state === 'queued') return 'Your request is saved. Provisioning starts when the current server operation releases the worker.';
|
||||
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
|
||||
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
|
||||
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
|
||||
if (deployment.state === 'awaiting-health') return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.';
|
||||
return deployment.action === 'create' ? 'Installing the saved launch recipe and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.';
|
||||
});
|
||||
function duration(start: string, end: number) {
|
||||
const seconds = Math.max(0, Math.floor((end - new Date(start).getTime()) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
|
||||
}
|
||||
let stageStarted = $derived(detail?.events.slice().reverse().find(event => event.message === deployment?.stage)?.created_at ?? deployment?.started_at);
|
||||
async function resume() {
|
||||
if (!deployment || resuming) return;
|
||||
resuming = true;
|
||||
try {
|
||||
const res = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/resume`, { method: 'POST' });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not resume verification.');
|
||||
await load();
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
function stateLabel(state: string) {
|
||||
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
|
||||
}
|
||||
@@ -49,7 +90,7 @@
|
||||
}
|
||||
$effect(() => { $page.params.id; detail = null; void load(); });
|
||||
onMount(() => {
|
||||
const timer = setInterval(load, 2000);
|
||||
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
|
||||
@@ -67,7 +108,9 @@
|
||||
{#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 creation completed') : 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}
|
||||
<p class="explanation" aria-live="polite">{explanation}</p>
|
||||
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
|
||||
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
|
||||
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
||||
@@ -75,8 +118,9 @@
|
||||
</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>
|
||||
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} class:failed={!active && ['failed', 'interrupted'].includes(deployment.state) && index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index < currentStep ? '✓' : index + 1}</span>{step}</li>{/each}</ol>
|
||||
</section>
|
||||
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if detail && detail.events.length > 0}
|
||||
@@ -89,7 +133,8 @@
|
||||
<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; }
|
||||
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
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; }
|
||||
@@ -98,6 +143,7 @@
|
||||
.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); }
|
||||
.flow li.failed { border-color: var(--state-danger); }
|
||||
.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); }
|
||||
|
||||
Reference in New Issue
Block a user