new destruction flow

This commit is contained in:
2026-09-17 16:57:08 -04:00
parent 5f8e03c693
commit a611850649
13 changed files with 349 additions and 96 deletions
+8
View File
@@ -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);
+52 -14
View File
@@ -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
+20
View File
@@ -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.');
}
+3
View File
@@ -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;
+3 -1
View File
@@ -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 });
}
+13 -11
View File
@@ -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%; }
+9 -2
View File
@@ -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');
}
+103 -2
View File
@@ -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>
+6 -63
View File
@@ -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;