new destruction flow
This commit is contained in:
@@ -204,3 +204,5 @@ until the parity check passes.
|
||||
Campaigns (`/campaigns`) contains script/config rollouts, drift reconciliation, restart progress, file diffs, and rollbacks. Existing rollout records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy rollout detail API endpoints remain available for compatibility.
|
||||
|
||||
Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. Creation completion means the server was provisioned and Komodo accepted the container start request; runtime health remains on the Fleet page.
|
||||
|
||||
Each server deployment opens a detail page with its flow and timestamped activity. Destroy uses an in-app confirmation modal and immediately returns a queued deployment (`DELETE /api/servers/:name` returns HTTP 202 with `deploymentId`). The worker waits for a fresh RCON player count of zero before teardown; occupied or unreachable servers remain queued and are checked each worker tick. Waiting requests survive application restarts, while interrupted teardown is marked for inspection. Completed destruction stays on the deployment detail page.
|
||||
|
||||
@@ -268,3 +268,11 @@ CREATE TABLE IF NOT EXISTS server_deployments (
|
||||
error TEXT,
|
||||
warnings TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_deployment_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_deployment_events ON server_deployment_events(deployment_id, id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rconGetPlayerCount } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -334,19 +335,56 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
|
||||
}
|
||||
|
||||
const destroying = new Set<string>();
|
||||
export async function destroyServer(name: string): Promise<DestroyServerResult> {
|
||||
if (creating || destroying.has(name)) return { ok: false, kind: 'komodo', message: 'A server operation is already in progress. Retry shortly.', warnings: [] };
|
||||
|
||||
// Persist the request before returning so navigation and client disconnects cannot lose it.
|
||||
export function destroyServer(name: string): DestroyServerResult & { deploymentId?: string } {
|
||||
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
|
||||
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
|
||||
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
||||
const operation = beginDeployment(name, 'destroy');
|
||||
destroying.add(name);
|
||||
try {
|
||||
const result = await destroyServerUnlocked(name, operation);
|
||||
finishDeployment(operation, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
finishDeployment(operation, { ok: false, message: (err as Error).message });
|
||||
throw err;
|
||||
} finally { destroying.delete(name); }
|
||||
const operation = db().transaction(() => {
|
||||
const id = beginDeployment(name, 'destroy');
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting to verify the server is empty');
|
||||
return id;
|
||||
})();
|
||||
return { ok: true, warnings: [], deploymentId: operation };
|
||||
}
|
||||
|
||||
let destructionTick: Promise<void> | null = null;
|
||||
export function processDestructions(): Promise<void> {
|
||||
if (destructionTick) return destructionTick;
|
||||
destructionTick = runDestructions().finally(() => { destructionTick = null; });
|
||||
return destructionTick;
|
||||
}
|
||||
|
||||
async function runDestructions(): Promise<void> {
|
||||
const queued = db().prepare("SELECT id, server FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string }[];
|
||||
for (const operation of queued) {
|
||||
if (creating || destroying.has(operation.server)) continue;
|
||||
destroying.add(operation.server);
|
||||
try {
|
||||
if (!getServer(operation.server)) {
|
||||
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
|
||||
continue;
|
||||
}
|
||||
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
||||
const players = await rconGetPlayerCount(operation.server);
|
||||
if (players !== 0) {
|
||||
deploymentStage(operation.id, players === null
|
||||
? 'Waiting for player count: server could not be reached'
|
||||
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
||||
continue;
|
||||
}
|
||||
deploymentStage(operation.id, 'Server is empty (0 players verified)');
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
const result = await destroyServerUnlocked(operation.server, operation.id);
|
||||
finishDeployment(operation.id, result);
|
||||
} catch (err) {
|
||||
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
||||
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
|
||||
else finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
} finally { destroying.delete(operation.server); }
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyServerUnlocked(name: string, operation: string): Promise<DestroyServerResult> {
|
||||
@@ -362,7 +400,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
||||
deploymentStage(operation, 'Stopping and removing container');
|
||||
const destroyRes = await komodoDestroyDeployment(name);
|
||||
if (!destroyRes.ok) {
|
||||
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
|
||||
return { ok: false, kind: 'komodo', message: 'DestroyDeployment failed: ' + destroyRes.message, warnings };
|
||||
}
|
||||
|
||||
// 2: wait for the container to actually disappear. Komodo refuses to
|
||||
@@ -380,7 +418,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
||||
deploymentStage(operation, 'Deleting Komodo deployment');
|
||||
const deleteRes = await komodoDeleteDeployment(name);
|
||||
if (!deleteRes.ok) {
|
||||
warnings.push(`DeleteDeployment failed: ${deleteRes.message}`);
|
||||
return { ok: false, kind: 'komodo', message: 'DeleteDeployment failed: ' + deleteRes.message, warnings };
|
||||
}
|
||||
|
||||
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
||||
|
||||
@@ -5,17 +5,35 @@ export function beginDeployment(server: string, action: 'create' | 'destroy', tr
|
||||
const id = randomUUID();
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
|
||||
recordDeploymentEvent(id, action === 'create' ? 'Server creation requested' : 'Server destruction requested');
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deploymentStage(id: string, stage: string): void {
|
||||
const previous = db().prepare('SELECT stage FROM server_deployments WHERE id = ?').get(id) as { stage: string } | undefined;
|
||||
db().prepare('UPDATE server_deployments SET stage = ? WHERE id = ?').run(stage, id);
|
||||
if (previous && previous.stage !== stage) recordDeploymentEvent(id, stage);
|
||||
}
|
||||
|
||||
export function recordDeploymentEvent(id: string, message: string): void {
|
||||
db().prepare('INSERT INTO server_deployment_events (deployment_id, created_at, message) VALUES (?, ?, ?)')
|
||||
.run(id, new Date().toISOString(), message);
|
||||
}
|
||||
|
||||
export function getDeployment(id: string) {
|
||||
const deployment = db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
||||
FROM server_deployments d WHERE d.id = ?`).get(id);
|
||||
if (!deployment) return null;
|
||||
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
|
||||
return { deployment, events };
|
||||
}
|
||||
|
||||
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
|
||||
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
|
||||
db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`)
|
||||
.run(state, new Date().toISOString(), result.message ?? null, JSON.stringify(result.warnings ?? []), id);
|
||||
for (const warning of result.warnings ?? []) recordDeploymentEvent(id, warning);
|
||||
recordDeploymentEvent(id, result.ok ? (state === 'warning' ? 'Completed with warnings' : 'Completed') : `Failed: ${result.message ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
export function listDeployments() {
|
||||
@@ -24,7 +42,9 @@ export function listDeployments() {
|
||||
}
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running'").all() as { id: string }[];
|
||||
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?,
|
||||
error = 'The application restarted before this operation finished. Check the server before retrying.'
|
||||
WHERE state = 'running'`).run(new Date().toISOString());
|
||||
for (const { id } of running) recordDeploymentEvent(id, 'Interrupted by application restart. Check the server before retrying.');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { processDestructions } from '../fleet/create.js';
|
||||
import { sweep } from './sweep.js';
|
||||
import { betaTick } from './beta-runner.js';
|
||||
import { emit } from './bus.js';
|
||||
@@ -27,6 +28,8 @@ function tick(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runTick(): Promise<void> {
|
||||
try { await processDestructions(); }
|
||||
catch (err) { console.error('[worker] destruction error:', err); }
|
||||
try {
|
||||
await sweep();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), name, players }: { open?: boolean; name: string; players: number | null } = $props();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) { error = ''; dialog.showModal(); }
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
|
||||
async function destroy() {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue destruction. Please retry.');
|
||||
await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
||||
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Destroy container?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
||||
</div>
|
||||
<p class="irreversible">Once destruction begins, it cannot be undone.</p>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" type="submit" loading={submitting}>Destroy container</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { line-height: 1.6; color: var(--text-secondary); }
|
||||
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: 8px; line-height: 1.6; }
|
||||
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
|
||||
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getDeployment } from '$lib/server/fleet/deployments.js';
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const lifecycle = getDeployment(params.id);
|
||||
if (lifecycle) return json(lifecycle);
|
||||
const dep = db()
|
||||
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||
.get(params.id) as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { destroyServer } from '$lib/server/fleet/create.js';
|
||||
|
||||
@@ -11,5 +12,6 @@ export async function DELETE({ params }: { params: { name: string } }) {
|
||||
const status = result.kind === 'not-found' ? 404 : 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
|
||||
}
|
||||
return json({ ok: true, warnings: result.warnings });
|
||||
void kick().catch((err) => console.error('[destroy] worker kick failed:', err));
|
||||
return json({ ok: true, deploymentId: result.deploymentId }, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
interface Deployment {
|
||||
id: string; server: string; action: 'create' | 'destroy';
|
||||
state: 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||
state: 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
let active = $derived(deployments.filter(d => d.state === 'running'));
|
||||
let history = $derived(deployments.filter(d => d.state !== 'running' &&
|
||||
let active = $derived(deployments.filter(d => (d.state === 'running' || d.state === 'waiting')));
|
||||
let history = $derived(deployments.filter(d => d.state !== 'running' && d.state !== 'waiting' &&
|
||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
@@ -41,11 +41,11 @@
|
||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
function status(d: Deployment) {
|
||||
return { running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||
return { waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Server deployments ? Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
|
||||
<div class="toolbar">
|
||||
@@ -55,7 +55,7 @@
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
{#if loading}<p>Loading deployments?</p>
|
||||
{#if loading}<p>Loading deployments...</p>
|
||||
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
|
||||
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
</section>
|
||||
@@ -71,10 +71,10 @@
|
||||
</div>
|
||||
|
||||
{#snippet row(d: Deployment)}
|
||||
<article>
|
||||
<a class="deployment-row" href="/deployments/{d.id}">
|
||||
<div class="row-head">
|
||||
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
||||
{#if d.server_exists}<a class="server" href="/servers/{encodeURIComponent(d.server)}">{d.server}</a>{:else}<strong class="server">{d.server}</strong>{/if}
|
||||
<strong class="server">{d.server}</strong>
|
||||
</div>
|
||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
||||
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||
</article>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
@@ -93,9 +93,11 @@
|
||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||
p { margin: 8px 0; } a { color: var(--accent); }
|
||||
.rows { display: grid; gap: 12px; }
|
||||
article { padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||
.meta, .toolbar p { font-size: 13px; color: var(--text-secondary); }
|
||||
.meta { font-size: 13px; color: var(--text-secondary); }
|
||||
.error { color: var(--state-danger); }
|
||||
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { getDeployment } from '$lib/server/fleet/deployments.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
export function load({ params }: { params: { id: string } }) {
|
||||
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
|
||||
const detail = getDeployment(params.id);
|
||||
if (detail) return {};
|
||||
if (db().prepare('SELECT id FROM deployments WHERE id = ?').get(params.id)) {
|
||||
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
|
||||
}
|
||||
error(404, 'Deployment not found');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||
import { statusByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
@@ -90,9 +90,7 @@
|
||||
let diagnosing = $state(false);
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroying = $state(false);
|
||||
let destroyError = $state<string | null>(null);
|
||||
let destroyWarnings = $state<string[]>([]);
|
||||
let destroyOpen = $state(false);
|
||||
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
|
||||
|
||||
async function loadServer(): Promise<void> {
|
||||
@@ -207,35 +205,6 @@
|
||||
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
||||
} finally { probingRcon = false; }
|
||||
}
|
||||
async function destroyThisServer(): Promise<void> {
|
||||
if (destroying || !name) return;
|
||||
const confirmed = confirm(
|
||||
`Destroy ${name}?\n\nThis stops the container, deletes the Komodo deployment, ` +
|
||||
`removes the host directory, and removes this server?s campaign details. Server deployment history is retained. Cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
destroying = true; destroyError = null; destroyWarnings = [];
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; warnings?: string[] };
|
||||
if (!body.ok) {
|
||||
destroyError = body.error ?? 'destroy failed';
|
||||
destroyWarnings = body.warnings ?? [];
|
||||
destroying = false;
|
||||
return;
|
||||
}
|
||||
if (body.warnings && body.warnings.length > 0) {
|
||||
destroyWarnings = body.warnings;
|
||||
setTimeout(() => goto('/'), 1500);
|
||||
return;
|
||||
}
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
destroyError = (err as Error).message;
|
||||
destroying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab config — Diagnostics gets a warning badge when there's an issue.
|
||||
let tabs = $derived([
|
||||
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
||||
@@ -246,6 +215,8 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
||||
|
||||
{#if server}
|
||||
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
||||
<!-- Hero: name + status + player count + primary actions -->
|
||||
@@ -255,7 +226,7 @@
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button variant="danger" onclick={destroyThisServer} disabled={destroying} loading={destroying}>
|
||||
<Button variant="danger" onclick={() => destroyOpen = true}>
|
||||
Destroy
|
||||
</Button>
|
||||
{/snippet}
|
||||
@@ -307,14 +278,6 @@
|
||||
|
||||
<RoundTelemetry {name} />
|
||||
|
||||
{#if destroyError}
|
||||
<div class="destroy-error" role="alert">{destroyError}</div>
|
||||
{/if}
|
||||
{#if destroyWarnings.length > 0}
|
||||
<ul class="destroy-warn">
|
||||
{#each destroyWarnings as w}<li>{w}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<div class="tabs-row">
|
||||
@@ -604,27 +567,7 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.destroy-error {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(255, 86, 112, 0.08);
|
||||
border: 1px solid rgba(255, 86, 112, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.destroy-warn {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
border: 1px solid rgba(255, 176, 32, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
list-style: disc inside;
|
||||
}
|
||||
|
||||
/* Tabs row */
|
||||
/* Tabs row */
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
||||
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
||||
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0) }));
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||
|
||||
let fixture: Fixture;
|
||||
@@ -53,8 +54,69 @@ async function promotedConfig() {
|
||||
}
|
||||
|
||||
describe('server creation map selection', () => {
|
||||
it('returns a deployment URL identifier immediately and serves its lifecycle detail', async () => {
|
||||
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { DELETE } = await import('../../src/routes/api/servers/[name]/+server.js');
|
||||
const { GET } = await import('../../src/routes/api/deployments/[id]/+server.js');
|
||||
const { komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
await createServer(input);
|
||||
const response = await DELETE({ params: { name: serverName } });
|
||||
expect(response.status).toBe(202);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({ ok: true, deploymentId: expect.any(String) });
|
||||
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
|
||||
const detail = await (await GET({ params: { id: body.deploymentId } })).json();
|
||||
expect(detail.deployment).toMatchObject({ server: serverName, action: 'destroy', state: 'waiting' });
|
||||
expect(detail.events).toHaveLength(2);
|
||||
const missing = await DELETE({ params: { name: 'missing-server' } });
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
it('queues destruction, waits for live zero players, and preserves an ordered timeline', async () => {
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const { komodoDestroyDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { getDeployment, interruptDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
await createServer(input);
|
||||
const queued = destroyServer(serverName);
|
||||
expect(queued).toMatchObject({ ok: true, deploymentId: expect.any(String) });
|
||||
expect(destroyServer(serverName).deploymentId).toBe(queued.deploymentId);
|
||||
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
|
||||
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(3).mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('RCON unavailable'));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await processDestructions();
|
||||
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
|
||||
expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'waiting' });
|
||||
}
|
||||
closeDb();
|
||||
interruptDeployments();
|
||||
expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'waiting' });
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
|
||||
await Promise.all([processDestructions(), processDestructions()]);
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(1);
|
||||
const detail = getDeployment(queued.deploymentId!);
|
||||
expect(detail?.deployment).toMatchObject({ state: 'done', server_exists: 0 });
|
||||
const messages = (detail?.events as { message: string }[]).map(e => e.message);
|
||||
expect(messages).toEqual(expect.arrayContaining(['Server destruction requested', 'Waiting for server to empty: 3 player(s) online', 'Server is empty (0 players verified)', 'Stopping and removing container', 'Completed']));
|
||||
expect(messages.indexOf('Server is empty (0 players verified)')).toBeLessThan(messages.indexOf('Stopping and removing container'));
|
||||
});
|
||||
|
||||
it('preserves files and registry when Komodo rejects destruction and exposes failure details', async () => {
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
||||
await createServer(input);
|
||||
vi.mocked(komodoDestroyDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'denied' });
|
||||
const queued = destroyServer(serverName);
|
||||
await processDestructions();
|
||||
expect(komodoDeleteDeployment).not.toHaveBeenCalled();
|
||||
expect(existsSync(join(fixture.t5, serverName))).toBe(true);
|
||||
expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'failed', server_exists: 1, error: 'DestroyDeployment failed: denied' });
|
||||
});
|
||||
|
||||
it('records in-flight creation and retains both operations after destruction', async () => {
|
||||
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { listDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||
const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
|
||||
@@ -68,6 +130,7 @@ describe('server creation map selection', () => {
|
||||
return { ok: true };
|
||||
});
|
||||
expect((await destroyServer(serverName)).ok).toBe(true);
|
||||
await processDestructions();
|
||||
expect(listDeployments()).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ server: serverName, action: 'create', state: 'done', server_exists: 0 }),
|
||||
expect.objectContaining({ server: serverName, action: 'destroy', state: 'done', server_exists: 0 })
|
||||
@@ -198,7 +261,7 @@ describe('server creation map selection', () => {
|
||||
|
||||
describe('persistent server keys', () => {
|
||||
it('saves a key, retains it through destruction, and reuses it for the exact lobby', async () => {
|
||||
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { getServerKey, listServerKeys } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never);
|
||||
@@ -207,6 +270,7 @@ describe('persistent server keys', () => {
|
||||
expect(await createServer(input)).toMatchObject({ ok: true, name });
|
||||
expect(getServerKey(name)).toBe('test-key');
|
||||
expect(await destroyServer(name)).toMatchObject({ ok: true });
|
||||
await processDestructions();
|
||||
expect(listServerKeys()).toContainEqual(expect.objectContaining({ server_id: name, deployed: 0 }));
|
||||
expect(JSON.stringify(listServerKeys())).not.toContain('test-key');
|
||||
expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: name })).toMatchObject({ ok: true, name });
|
||||
@@ -231,13 +295,14 @@ describe('persistent server keys', () => {
|
||||
});
|
||||
|
||||
it('fills the lowest gap and rejects stale previews before saving a key or deploying', async () => {
|
||||
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false } as never);
|
||||
expect(await createServer(input)).toMatchObject({ ok: true, name: serverName });
|
||||
expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
|
||||
await destroyServer(serverName);
|
||||
await processDestructions();
|
||||
expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' }))
|
||||
.toMatchObject({ ok: false, kind: 'no-slot' });
|
||||
expect(getServerKey(serverName)).toBe('test-key');
|
||||
|
||||
Reference in New Issue
Block a user