Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming

Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto
key in the Fleet page, EC provisions the deployment via Komodo, allocates a
port in 28960-28980, creates the 0o777 host dir, and starts the container.
Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir,
transactional DB cleanup) and surfaces any partial-failure warnings inline.

Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.).
Komodo entity names now match EC server names 1:1 (no more _/- transform), and
the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie
maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id,
image, created_by, and destroyed_at columns for provisioning + audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 18:38:42 -04:00
co-authored by Claude Opus 4.7
parent 8c15cae80d
commit 13a29e5ace
25 changed files with 1103 additions and 123 deletions
+102 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { statusByServer } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import StatusDot from '$ui/components/StatusDot.svelte';
@@ -111,6 +112,9 @@
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[]>([]);
async function loadServer(): Promise<void> {
const [srvRes, depRes, invRes] = await Promise.all([
@@ -213,6 +217,46 @@
}
}
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 drops all deployment history for this server. ` +
`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;
}
// Best-effort partial failures still deleted the server — proceed
// to the fleet page and let the operator see the warnings there,
// but for now just show them here if any occurred.
if (body.warnings && body.warnings.length > 0) {
destroyWarnings = body.warnings;
// Give the operator a moment to notice, then navigate.
setTimeout(() => goto('/'), 1500);
return;
}
await goto('/');
} catch (err) {
destroyError = (err as Error).message;
destroying = false;
}
}
async function probeRcon(): Promise<void> {
if (probingRcon || !name) return;
probingRcon = true;
@@ -242,13 +286,31 @@
<StatusDot health={effectiveHealth} size={12} />
<h1>{server.name}</h1>
</div>
<div class="player-block">
<div class="player-count" class:has-players={(effectivePlayers ?? 0) > 0}>
{effectivePlayers ?? '—'}
<div class="hero-right">
<div class="player-block">
<div class="player-count" class:has-players={(effectivePlayers ?? 0) > 0}>
{effectivePlayers ?? '—'}
</div>
<div class="player-label">players</div>
</div>
<div class="player-label">players</div>
<button
class="danger destroy-btn"
onclick={destroyThisServer}
disabled={destroying}
title="Stop container, delete Komodo deployment, remove host dir, drop DB rows."
>
{destroying ? 'destroying…' : 'destroy server'}
</button>
</div>
</div>
{#if destroyError}
<p class="destroy-flash">{destroyError}</p>
{/if}
{#if destroyWarnings.length > 0}
<ul class="destroy-warn">
{#each destroyWarnings as w}<li>{w}</li>{/each}
</ul>
{/if}
<div class="hero-meta">
<span class="pair"><span class="k">map</span> <span class="v">{effectiveMap ?? '—'}</span></span>
{#if gametypeLabel(effectiveGametype)}
@@ -530,6 +592,42 @@
gap: 24px;
}
.name-row { display: flex; align-items: center; gap: 14px; min-width: 0; }
.hero-right { display: flex; align-items: flex-start; gap: 20px; }
.destroy-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
align-self: center;
}
.destroy-btn:hover:not(:disabled) {
background: rgba(255, 48, 80, 0.10);
box-shadow: 0 0 0 1px rgba(255, 48, 80, 0.4);
}
.destroy-btn:disabled { opacity: 0.5; cursor: default; }
.destroy-flash {
margin: 16px 0 0;
color: var(--danger-500);
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.destroy-warn {
margin: 12px 0 0;
padding: 10px 16px;
color: var(--amber-500);
background: rgba(255, 176, 32, 0.08);
border: 1px solid rgba(255, 176, 32, 0.3);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
list-style: disc inside;
}
h1 {
margin: 0;
font-family: var(--font-display);