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.
|
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.
|
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,
|
error TEXT,
|
||||||
warnings TEXT NOT NULL DEFAULT '[]'
|
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 { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
@@ -334,19 +335,56 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destroying = new Set<string>();
|
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: [] };
|
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
||||||
const operation = beginDeployment(name, 'destroy');
|
const operation = db().transaction(() => {
|
||||||
destroying.add(name);
|
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 {
|
try {
|
||||||
const result = await destroyServerUnlocked(name, operation);
|
if (!getServer(operation.server)) {
|
||||||
finishDeployment(operation, result);
|
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
|
||||||
return result;
|
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) {
|
} catch (err) {
|
||||||
finishDeployment(operation, { ok: false, message: (err as Error).message });
|
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
||||||
throw err;
|
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
|
||||||
} finally { destroying.delete(name); }
|
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> {
|
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');
|
deploymentStage(operation, 'Stopping and removing container');
|
||||||
const destroyRes = await komodoDestroyDeployment(name);
|
const destroyRes = await komodoDestroyDeployment(name);
|
||||||
if (!destroyRes.ok) {
|
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
|
// 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');
|
deploymentStage(operation, 'Deleting Komodo deployment');
|
||||||
const deleteRes = await komodoDeleteDeployment(name);
|
const deleteRes = await komodoDeleteDeployment(name);
|
||||||
if (!deleteRes.ok) {
|
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
|
// 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();
|
const id = randomUUID();
|
||||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
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);
|
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
|
||||||
|
recordDeploymentEvent(id, action === 'create' ? 'Server creation requested' : 'Server destruction requested');
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deploymentStage(id: string, stage: string): void {
|
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);
|
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 {
|
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
|
||||||
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
|
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
|
||||||
db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`)
|
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);
|
.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() {
|
export function listDeployments() {
|
||||||
@@ -24,7 +42,9 @@ export function listDeployments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function interruptDeployments(): void {
|
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 = ?,
|
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?,
|
||||||
error = 'The application restarted before this operation finished. Check the server before retrying.'
|
error = 'The application restarted before this operation finished. Check the server before retrying.'
|
||||||
WHERE state = 'running'`).run(new Date().toISOString());
|
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 { sweep } from './sweep.js';
|
||||||
import { betaTick } from './beta-runner.js';
|
import { betaTick } from './beta-runner.js';
|
||||||
import { emit } from './bus.js';
|
import { emit } from './bus.js';
|
||||||
@@ -27,6 +28,8 @@ function tick(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runTick(): Promise<void> {
|
async function runTick(): Promise<void> {
|
||||||
|
try { await processDestructions(); }
|
||||||
|
catch (err) { console.error('[worker] destruction error:', err); }
|
||||||
try {
|
try {
|
||||||
await sweep();
|
await sweep();
|
||||||
} catch (err) {
|
} 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 { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { getDeployment } from '$lib/server/fleet/deployments.js';
|
||||||
|
|
||||||
export async function GET({ params }: { params: { id: string } }) {
|
export async function GET({ params }: { params: { id: string } }) {
|
||||||
|
const lifecycle = getDeployment(params.id);
|
||||||
|
if (lifecycle) return json(lifecycle);
|
||||||
const dep = db()
|
const dep = db()
|
||||||
.prepare('SELECT * FROM deployments WHERE id = ?')
|
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||||
.get(params.id) as Record<string, unknown> | undefined;
|
.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 { json } from '@sveltejs/kit';
|
||||||
import { destroyServer } from '$lib/server/fleet/create.js';
|
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;
|
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: 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 {
|
interface Deployment {
|
||||||
id: string; server: string; action: 'create' | 'destroy';
|
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;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
let action = $state('all');
|
let action = $state('all');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
let active = $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' &&
|
let history = $derived(deployments.filter(d => d.state !== 'running' && d.state !== 'waiting' &&
|
||||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||||
async function load() {
|
async function load() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||||
function status(d: Deployment) {
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Server deployments ? Electric Cherry</title></svelte:head>
|
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
|
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||||
<section>
|
<section>
|
||||||
<h2>In flight <span>{active.length}</span></h2>
|
<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 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}
|
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -71,10 +71,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet row(d: Deployment)}
|
{#snippet row(d: Deployment)}
|
||||||
<article>
|
<a class="deployment-row" href="/deployments/{d.id}">
|
||||||
<div class="row-head">
|
<div class="row-head">
|
||||||
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
<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>
|
</div>
|
||||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||||
</div>
|
</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>
|
<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}
|
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||||
</article>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -93,9 +93,11 @@
|
|||||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||||
p { margin: 8px 0; } a { color: var(--accent); }
|
p { margin: 8px 0; } a { color: var(--accent); }
|
||||||
.rows { display: grid; gap: 12px; }
|
.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; }
|
.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); }
|
.error { color: var(--state-danger); }
|
||||||
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
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%; }
|
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 } }) {
|
export function load({ params }: { params: { id: string } }) {
|
||||||
|
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));
|
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
|
||||||
}
|
}
|
||||||
|
error(404, 'Deployment not found');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,103 @@
|
|||||||
<script lang="ts">import { page } from "$app/stores";</script>
|
<script lang="ts">
|
||||||
<p>This rollout is now a campaign. <a href="/campaigns/{$page.params.id}">View campaign</a></p>
|
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 RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
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 { statusByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||||
import { elapsedSince } from '$ui/duration.js';
|
import { elapsedSince } from '$ui/duration.js';
|
||||||
@@ -90,9 +90,7 @@
|
|||||||
let diagnosing = $state(false);
|
let diagnosing = $state(false);
|
||||||
let rconProbe = $state<RconProbeResult | null>(null);
|
let rconProbe = $state<RconProbeResult | null>(null);
|
||||||
let probingRcon = $state(false);
|
let probingRcon = $state(false);
|
||||||
let destroying = $state(false);
|
let destroyOpen = $state(false);
|
||||||
let destroyError = $state<string | null>(null);
|
|
||||||
let destroyWarnings = $state<string[]>([]);
|
|
||||||
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
|
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
|
||||||
|
|
||||||
async function loadServer(): Promise<void> {
|
async function loadServer(): Promise<void> {
|
||||||
@@ -207,35 +205,6 @@
|
|||||||
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
||||||
} finally { probingRcon = false; }
|
} 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.
|
// Tab config — Diagnostics gets a warning badge when there's an issue.
|
||||||
let tabs = $derived([
|
let tabs = $derived([
|
||||||
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
||||||
@@ -246,6 +215,8 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
||||||
|
|
||||||
{#if server}
|
{#if server}
|
||||||
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
||||||
<!-- Hero: name + status + player count + primary actions -->
|
<!-- Hero: name + status + player count + primary actions -->
|
||||||
@@ -255,7 +226,7 @@
|
|||||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
|
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="danger" onclick={destroyThisServer} disabled={destroying} loading={destroying}>
|
<Button variant="danger" onclick={() => destroyOpen = true}>
|
||||||
Destroy
|
Destroy
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -307,14 +278,6 @@
|
|||||||
|
|
||||||
<RoundTelemetry {name} />
|
<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>
|
</Card>
|
||||||
|
|
||||||
<div class="tabs-row">
|
<div class="tabs-row">
|
||||||
@@ -604,26 +567,6 @@
|
|||||||
color: var(--text-tertiary);
|
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 {
|
.tabs-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
|||||||
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
||||||
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
|
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 () => {}) }));
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||||
|
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
@@ -53,8 +54,69 @@ async function promotedConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('server creation map selection', () => {
|
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 () => {
|
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 { listDeployments } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
|
vi.mocked(komodoDeploy).mockImplementationOnce(async () => {
|
||||||
@@ -68,6 +130,7 @@ describe('server creation map selection', () => {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
expect((await destroyServer(serverName)).ok).toBe(true);
|
expect((await destroyServer(serverName)).ok).toBe(true);
|
||||||
|
await processDestructions();
|
||||||
expect(listDeployments()).toEqual(expect.arrayContaining([
|
expect(listDeployments()).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ server: serverName, action: 'create', state: 'done', server_exists: 0 }),
|
expect.objectContaining({ server: serverName, action: 'create', state: 'done', server_exists: 0 }),
|
||||||
expect.objectContaining({ server: serverName, action: 'destroy', 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', () => {
|
describe('persistent server keys', () => {
|
||||||
it('saves a key, retains it through destruction, and reuses it for the exact lobby', async () => {
|
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 { getServerKey, listServerKeys } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.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);
|
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(await createServer(input)).toMatchObject({ ok: true, name });
|
||||||
expect(getServerKey(name)).toBe('test-key');
|
expect(getServerKey(name)).toBe('test-key');
|
||||||
expect(await destroyServer(name)).toMatchObject({ ok: true });
|
expect(await destroyServer(name)).toMatchObject({ ok: true });
|
||||||
|
await processDestructions();
|
||||||
expect(listServerKeys()).toContainEqual(expect.objectContaining({ server_id: name, deployed: 0 }));
|
expect(listServerKeys()).toContainEqual(expect.objectContaining({ server_id: name, deployed: 0 }));
|
||||||
expect(JSON.stringify(listServerKeys())).not.toContain('test-key');
|
expect(JSON.stringify(listServerKeys())).not.toContain('test-key');
|
||||||
expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', expectedName: name })).toMatchObject({ ok: true, name });
|
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 () => {
|
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 { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false } as never);
|
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: serverName });
|
||||||
expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
|
expect(await createServer(input)).toMatchObject({ ok: true, name: 't5_zombie_cosmodrome_2' });
|
||||||
await destroyServer(serverName);
|
await destroyServer(serverName);
|
||||||
|
await processDestructions();
|
||||||
expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' }))
|
expect(await createServer({ ...input, plutoKey: 'wrong-key', expectedName: 't5_zombie_cosmodrome_3' }))
|
||||||
.toMatchObject({ ok: false, kind: 'no-slot' });
|
.toMatchObject({ ok: false, kind: 'no-slot' });
|
||||||
expect(getServerKey(serverName)).toBe('test-key');
|
expect(getServerKey(serverName)).toBe('test-key');
|
||||||
|
|||||||
Reference in New Issue
Block a user