157 lines
14 KiB
Svelte
157 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
import { onMount } from 'svelte';
|
|
import { pollVisible } from '$ui/poll.js';
|
|
import { serverTitle } from '$ui/server-labels.js';
|
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
|
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
|
import type { OperationStep } from '$lib/operation-flow.js';
|
|
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';
|
|
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 }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; canCancelRemoval: boolean; collection: { id: string; name: string } | null }
|
|
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(() => {
|
|
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', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map']);
|
|
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', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
|
|
if (deployment.state === 'waiting' && deployment.stage !== 'Removing server files and registry') return 1;
|
|
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
|
|
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 === 'cancelled') return 'The queued removal was cancelled before removal began. The server and its files were kept.';
|
|
if (detail?.canResumeCleanup) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.';
|
|
if (deployment.state === 'waiting' && deployment.stage === 'Removing server files and registry') return 'Local cleanup is queued. Komodo deletion has already succeeded.';
|
|
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 in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.';
|
|
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 latest promoted setup 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 the operation.');
|
|
await load();
|
|
} catch (err) { error = (err as Error).message; }
|
|
finally { resuming = false; }
|
|
}
|
|
async function cancelRemoval() {
|
|
if (!deployment || resuming) return;
|
|
resuming = true; error = '';
|
|
try {
|
|
const response = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/cancel`, { method: 'POST' });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error ?? 'Could not cancel this request.');
|
|
await load();
|
|
} catch (err) { error = (err as Error).message; await load(); error = (err as Error).message; }
|
|
finally { resuming = false; }
|
|
}
|
|
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(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
|
|
function fmt(iso: string) { return $timeFormat.dateTime(iso); }
|
|
</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' ? 'Remove server' : 'Create server') : 'Deployment'} subtitle={deployment ? serverTitle(deployment.server) : undefined}>
|
|
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
|
</SectionHeader>
|
|
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
|
{#if detail?.collection}<a href="/deployments/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
|
{#if deployment}
|
|
<section class="summary">
|
|
<div class="top"><StatusBadge state={deployment.state} />
|
|
{#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>
|
|
<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 detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
|
|
{#if detail?.canCancelRemoval}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
|
|
{#if deployment.server_exists && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</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}
|
|
<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>
|
|
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch files'}</h2><Button variant="ghost" onclick={() => open = true}>Launch another server →</Button></div><p class="muted">Launch this map with the latest promoted files using a fresh server ID and port. This does not repair the existing server.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
|
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:deployment.state === 'cancelled' ? 'pending' : index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
|
|
|
|
{:else if !error}<p>Loading deployment...</p>{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.recovery-links { display: flex; gap: 16px; flex-wrap: wrap; font-size: 14px; }
|
|
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
|
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
|
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); 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, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
|
|
|
|
.error { color: var(--state-danger); } .warning { color: var(--state-warning); }
|
|
@media (max-width: 640px) {
|
|
.timing { flex-direction: column; gap: var(--space-2); }
|
|
}
|
|
</style>
|