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:
+26
-1
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
|
||||
@@ -23,11 +24,16 @@
|
||||
}
|
||||
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let deployOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
async function refresh(): Promise<void> {
|
||||
const res = await fetch('/api/servers');
|
||||
const body = (await res.json()) as { servers: ServerRow[] };
|
||||
servers = body.servers;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Live server view — merge SSE data over the initial GET
|
||||
@@ -62,7 +68,13 @@
|
||||
<h1>Fleet</h1>
|
||||
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="hdr-actions">
|
||||
<button class="deploy" onclick={() => (deployOpen = true)}>Deploy new server</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DeployServerDialog bind:open={deployOpen} />
|
||||
|
||||
{#if servers.length === 0}
|
||||
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||
{:else}
|
||||
@@ -123,4 +135,17 @@
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.hdr-actions { display: flex; gap: 10px; }
|
||||
.deploy {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 18px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.deploy:hover { box-shadow: var(--accent-glow); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { T5_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
export function GET() {
|
||||
return json({ t5Zombie: T5_ZOMBIE_MAPS });
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
|
||||
import { classifyHealth } from '$lib/server/fleet/health.js';
|
||||
import { createServer } from '$lib/server/fleet/create.js';
|
||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
||||
import type { GameMode } from '$lib/server/fleet/map-aliases.js';
|
||||
|
||||
function safeJson<T>(raw: string | null): T | null {
|
||||
if (!raw) return null;
|
||||
@@ -35,3 +37,34 @@ export async function GET() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
gameMode?: string;
|
||||
mapSlug?: string;
|
||||
plutoKey?: string;
|
||||
};
|
||||
if (typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || typeof body.plutoKey !== 'string') {
|
||||
return json(
|
||||
{ ok: false, error: 'gameMode, mapSlug, and plutoKey are required strings', kind: 'bad-request' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.plutoKey.trim().length === 0) {
|
||||
return json({ ok: false, error: 'plutoKey cannot be empty', kind: 'bad-request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createServer({
|
||||
gameMode: body.gameMode as GameMode,
|
||||
mapSlug: body.mapSlug,
|
||||
plutoKey: body.plutoKey,
|
||||
createdBy: 'ui'
|
||||
});
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'unconfigured' ? 503
|
||||
: result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
|
||||
: 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind }, { status });
|
||||
}
|
||||
return json({ ok: true, name: result.name, port: result.port });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { destroyServer } from '$lib/server/fleet/create.js';
|
||||
|
||||
export async function DELETE({ params }: { params: { name: string } }) {
|
||||
const name = params.name;
|
||||
if (!name) {
|
||||
return json({ ok: false, error: 'server name required', kind: 'bad-request' }, { status: 400 });
|
||||
}
|
||||
const result = await destroyServer(name);
|
||||
if (!result.ok) {
|
||||
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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user