new destruction flow
This commit is contained in:
@@ -268,3 +268,11 @@ CREATE TABLE IF NOT EXISTS server_deployments (
|
||||
error TEXT,
|
||||
warnings TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_deployment_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_deployment_events ON server_deployment_events(deployment_id, id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rconGetPlayerCount } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -334,19 +335,56 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
|
||||
}
|
||||
|
||||
const destroying = new Set<string>();
|
||||
export async function destroyServer(name: string): Promise<DestroyServerResult> {
|
||||
if (creating || destroying.has(name)) return { ok: false, kind: 'komodo', message: 'A server operation is already in progress. Retry shortly.', warnings: [] };
|
||||
|
||||
// Persist the request before returning so navigation and client disconnects cannot lose it.
|
||||
export function destroyServer(name: string): DestroyServerResult & { deploymentId?: string } {
|
||||
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
|
||||
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
|
||||
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
|
||||
const operation = beginDeployment(name, 'destroy');
|
||||
destroying.add(name);
|
||||
try {
|
||||
const result = await destroyServerUnlocked(name, operation);
|
||||
finishDeployment(operation, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
finishDeployment(operation, { ok: false, message: (err as Error).message });
|
||||
throw err;
|
||||
} finally { destroying.delete(name); }
|
||||
const operation = db().transaction(() => {
|
||||
const id = beginDeployment(name, 'destroy');
|
||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||
deploymentStage(id, 'Waiting to verify the server is empty');
|
||||
return id;
|
||||
})();
|
||||
return { ok: true, warnings: [], deploymentId: operation };
|
||||
}
|
||||
|
||||
let destructionTick: Promise<void> | null = null;
|
||||
export function processDestructions(): Promise<void> {
|
||||
if (destructionTick) return destructionTick;
|
||||
destructionTick = runDestructions().finally(() => { destructionTick = null; });
|
||||
return destructionTick;
|
||||
}
|
||||
|
||||
async function runDestructions(): Promise<void> {
|
||||
const queued = db().prepare("SELECT id, server FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string }[];
|
||||
for (const operation of queued) {
|
||||
if (creating || destroying.has(operation.server)) continue;
|
||||
destroying.add(operation.server);
|
||||
try {
|
||||
if (!getServer(operation.server)) {
|
||||
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
|
||||
continue;
|
||||
}
|
||||
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
||||
const players = await rconGetPlayerCount(operation.server);
|
||||
if (players !== 0) {
|
||||
deploymentStage(operation.id, players === null
|
||||
? 'Waiting for player count: server could not be reached'
|
||||
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
||||
continue;
|
||||
}
|
||||
deploymentStage(operation.id, 'Server is empty (0 players verified)');
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
const result = await destroyServerUnlocked(operation.server, operation.id);
|
||||
finishDeployment(operation.id, result);
|
||||
} catch (err) {
|
||||
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
||||
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
|
||||
else finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
} finally { destroying.delete(operation.server); }
|
||||
}
|
||||
}
|
||||
|
||||
async function destroyServerUnlocked(name: string, operation: string): Promise<DestroyServerResult> {
|
||||
@@ -362,7 +400,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
||||
deploymentStage(operation, 'Stopping and removing container');
|
||||
const destroyRes = await komodoDestroyDeployment(name);
|
||||
if (!destroyRes.ok) {
|
||||
warnings.push(`DestroyDeployment failed: ${destroyRes.message}`);
|
||||
return { ok: false, kind: 'komodo', message: 'DestroyDeployment failed: ' + destroyRes.message, warnings };
|
||||
}
|
||||
|
||||
// 2: wait for the container to actually disappear. Komodo refuses to
|
||||
@@ -380,7 +418,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
||||
deploymentStage(operation, 'Deleting Komodo deployment');
|
||||
const deleteRes = await komodoDeleteDeployment(name);
|
||||
if (!deleteRes.ok) {
|
||||
warnings.push(`DeleteDeployment failed: ${deleteRes.message}`);
|
||||
return { ok: false, kind: 'komodo', message: 'DeleteDeployment failed: ' + deleteRes.message, warnings };
|
||||
}
|
||||
|
||||
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
||||
|
||||
@@ -5,17 +5,35 @@ export function beginDeployment(server: string, action: 'create' | 'destroy', tr
|
||||
const id = randomUUID();
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
|
||||
recordDeploymentEvent(id, action === 'create' ? 'Server creation requested' : 'Server destruction requested');
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deploymentStage(id: string, stage: string): void {
|
||||
const previous = db().prepare('SELECT stage FROM server_deployments WHERE id = ?').get(id) as { stage: string } | undefined;
|
||||
db().prepare('UPDATE server_deployments SET stage = ? WHERE id = ?').run(stage, id);
|
||||
if (previous && previous.stage !== stage) recordDeploymentEvent(id, stage);
|
||||
}
|
||||
|
||||
export function recordDeploymentEvent(id: string, message: string): void {
|
||||
db().prepare('INSERT INTO server_deployment_events (deployment_id, created_at, message) VALUES (?, ?, ?)')
|
||||
.run(id, new Date().toISOString(), message);
|
||||
}
|
||||
|
||||
export function getDeployment(id: string) {
|
||||
const deployment = db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
||||
FROM server_deployments d WHERE d.id = ?`).get(id);
|
||||
if (!deployment) return null;
|
||||
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
|
||||
return { deployment, events };
|
||||
}
|
||||
|
||||
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
|
||||
const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done';
|
||||
db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`)
|
||||
.run(state, new Date().toISOString(), result.message ?? null, JSON.stringify(result.warnings ?? []), id);
|
||||
for (const warning of result.warnings ?? []) recordDeploymentEvent(id, warning);
|
||||
recordDeploymentEvent(id, result.ok ? (state === 'warning' ? 'Completed with warnings' : 'Completed') : `Failed: ${result.message ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
export function listDeployments() {
|
||||
@@ -24,7 +42,9 @@ export function listDeployments() {
|
||||
}
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running'").all() as { id: string }[];
|
||||
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?,
|
||||
error = 'The application restarted before this operation finished. Check the server before retrying.'
|
||||
WHERE state = 'running'`).run(new Date().toISOString());
|
||||
for (const { id } of running) recordDeploymentEvent(id, 'Interrupted by application restart. Check the server before retrying.');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { processDestructions } from '../fleet/create.js';
|
||||
import { sweep } from './sweep.js';
|
||||
import { betaTick } from './beta-runner.js';
|
||||
import { emit } from './bus.js';
|
||||
@@ -27,6 +28,8 @@ function tick(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runTick(): Promise<void> {
|
||||
try { await processDestructions(); }
|
||||
catch (err) { console.error('[worker] destruction error:', err); }
|
||||
try {
|
||||
await sweep();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), name, players }: { open?: boolean; name: string; players: number | null } = $props();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) { error = ''; dialog.showModal(); }
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
|
||||
async function destroy() {
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue destruction. Please retry.');
|
||||
await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
||||
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Destroy container?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
||||
</div>
|
||||
<p class="irreversible">Once destruction begins, it cannot be undone.</p>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" type="submit" loading={submitting}>Destroy container</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { line-height: 1.6; color: var(--text-secondary); }
|
||||
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: 8px; line-height: 1.6; }
|
||||
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
|
||||
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getDeployment } from '$lib/server/fleet/deployments.js';
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const lifecycle = getDeployment(params.id);
|
||||
if (lifecycle) return json(lifecycle);
|
||||
const dep = db()
|
||||
.prepare('SELECT * FROM deployments WHERE id = ?')
|
||||
.get(params.id) as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { destroyServer } from '$lib/server/fleet/create.js';
|
||||
|
||||
@@ -11,5 +12,6 @@ export async function DELETE({ params }: { params: { name: string } }) {
|
||||
const status = result.kind === 'not-found' ? 404 : 500;
|
||||
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
|
||||
}
|
||||
return json({ ok: true, warnings: result.warnings });
|
||||
void kick().catch((err) => console.error('[destroy] worker kick failed:', err));
|
||||
return json({ ok: true, deploymentId: result.deploymentId }, { status: 202 });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
interface Deployment {
|
||||
id: string; server: string; action: 'create' | 'destroy';
|
||||
state: 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||
state: 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
let active = $derived(deployments.filter(d => d.state === 'running'));
|
||||
let history = $derived(deployments.filter(d => d.state !== 'running' &&
|
||||
let active = $derived(deployments.filter(d => (d.state === 'running' || d.state === 'waiting')));
|
||||
let history = $derived(deployments.filter(d => d.state !== 'running' && d.state !== 'waiting' &&
|
||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
@@ -41,11 +41,11 @@
|
||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
function status(d: Deployment) {
|
||||
return { running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||
return { waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Server deployments ? Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
|
||||
<div class="toolbar">
|
||||
@@ -55,7 +55,7 @@
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
{#if loading}<p>Loading deployments?</p>
|
||||
{#if loading}<p>Loading deployments...</p>
|
||||
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
|
||||
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
</section>
|
||||
@@ -71,10 +71,10 @@
|
||||
</div>
|
||||
|
||||
{#snippet row(d: Deployment)}
|
||||
<article>
|
||||
<a class="deployment-row" href="/deployments/{d.id}">
|
||||
<div class="row-head">
|
||||
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
||||
{#if d.server_exists}<a class="server" href="/servers/{encodeURIComponent(d.server)}">{d.server}</a>{:else}<strong class="server">{d.server}</strong>{/if}
|
||||
<strong class="server">{d.server}</strong>
|
||||
</div>
|
||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
||||
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||
</article>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
@@ -93,9 +93,11 @@
|
||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||
p { margin: 8px 0; } a { color: var(--accent); }
|
||||
.rows { display: grid; gap: 12px; }
|
||||
article { padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||
.meta, .toolbar p { font-size: 13px; color: var(--text-secondary); }
|
||||
.meta { font-size: 13px; color: var(--text-secondary); }
|
||||
.error { color: var(--state-danger); }
|
||||
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { getDeployment } from '$lib/server/fleet/deployments.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
export function load({ params }: { params: { id: string } }) {
|
||||
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
|
||||
const detail = getDeployment(params.id);
|
||||
if (detail) return {};
|
||||
if (db().prepare('SELECT id FROM deployments WHERE id = ?').get(params.id)) {
|
||||
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
|
||||
}
|
||||
error(404, 'Deployment not found');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,103 @@
|
||||
<script lang="ts">import { page } from "$app/stores";</script>
|
||||
<p>This rollout is now a campaign. <a href="/campaigns/{$page.params.id}">View campaign</a></p>
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
|
||||
interface Deployment {
|
||||
id: string; server: string; action: 'create' | 'destroy'; state: string;
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
}
|
||||
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[] }
|
||||
let detail = $state<Detail | null>(null);
|
||||
let error = $state('');
|
||||
let refreshing = false;
|
||||
let deployment = $derived(detail?.deployment);
|
||||
let active = $derived(deployment?.state === 'waiting' || deployment?.state === 'running');
|
||||
let warnings = $derived.by(() => {
|
||||
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
|
||||
});
|
||||
let steps = $derived(deployment?.action === 'destroy'
|
||||
? ['Request destruction', 'Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry']
|
||||
: ['Request creation', 'Prepare server files', 'Create Komodo deployment', 'Start container']);
|
||||
let currentStep = $derived.by(() => {
|
||||
if (!deployment) return 0;
|
||||
const stages = deployment.action === 'destroy'
|
||||
? ['Starting', 'Waiting', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']
|
||||
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container'];
|
||||
if (deployment.state === 'waiting') return 1;
|
||||
if (deployment.state === 'done') return stages.length;
|
||||
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
|
||||
});
|
||||
function stateLabel(state: string) {
|
||||
return ({ waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
|
||||
}
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
const id = $page.params.id ?? '';
|
||||
try {
|
||||
const res = await fetch('/api/deployments/' + encodeURIComponent(id));
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Deployment not found.' : 'Could not refresh deployment.');
|
||||
const body = await res.json();
|
||||
if ($page.params.id === id) { detail = body; error = ''; }
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { refreshing = false; }
|
||||
}
|
||||
$effect(() => { $page.params.id; detail = null; void load(); });
|
||||
onMount(() => {
|
||||
const timer = setInterval(load, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment?.server}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if deployment}
|
||||
<section class="summary">
|
||||
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
|
||||
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
|
||||
</div>
|
||||
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server created; container start requested') : deployment.stage}</h2>
|
||||
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if}
|
||||
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Operation flow</h2>
|
||||
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index + 1}</span>{step}</li>{/each}</ol>
|
||||
</section>
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if detail && detail.events.length > 0}
|
||||
<ol class="timeline">{#each detail.events as event (event.id)}<li><time datetime={event.created_at}>{fmt(event.created_at)}</time><p>{event.message}</p></li>{/each}</ol>
|
||||
{:else}<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown above.</p>{/if}
|
||||
</section>
|
||||
{:else if !error}<p>Loading deployment...</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
||||
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
|
||||
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; }
|
||||
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
|
||||
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow li.reached { color: var(--text-primary); }
|
||||
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
|
||||
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
|
||||
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
|
||||
.timeline p { margin-top: 6px; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||
import { statusByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
@@ -90,9 +90,7 @@
|
||||
let diagnosing = $state(false);
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroying = $state(false);
|
||||
let destroyError = $state<string | null>(null);
|
||||
let destroyWarnings = $state<string[]>([]);
|
||||
let destroyOpen = $state(false);
|
||||
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
|
||||
|
||||
async function loadServer(): Promise<void> {
|
||||
@@ -207,35 +205,6 @@
|
||||
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
||||
} finally { probingRcon = false; }
|
||||
}
|
||||
async function destroyThisServer(): Promise<void> {
|
||||
if (destroying || !name) return;
|
||||
const confirmed = confirm(
|
||||
`Destroy ${name}?\n\nThis stops the container, deletes the Komodo deployment, ` +
|
||||
`removes the host directory, and removes this server?s campaign details. Server deployment history is retained. Cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
destroying = true; destroyError = null; destroyWarnings = [];
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; warnings?: string[] };
|
||||
if (!body.ok) {
|
||||
destroyError = body.error ?? 'destroy failed';
|
||||
destroyWarnings = body.warnings ?? [];
|
||||
destroying = false;
|
||||
return;
|
||||
}
|
||||
if (body.warnings && body.warnings.length > 0) {
|
||||
destroyWarnings = body.warnings;
|
||||
setTimeout(() => goto('/'), 1500);
|
||||
return;
|
||||
}
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
destroyError = (err as Error).message;
|
||||
destroying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab config — Diagnostics gets a warning badge when there's an issue.
|
||||
let tabs = $derived([
|
||||
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
||||
@@ -246,6 +215,8 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
||||
|
||||
{#if server}
|
||||
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
||||
<!-- Hero: name + status + player count + primary actions -->
|
||||
@@ -255,7 +226,7 @@
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button variant="danger" onclick={destroyThisServer} disabled={destroying} loading={destroying}>
|
||||
<Button variant="danger" onclick={() => destroyOpen = true}>
|
||||
Destroy
|
||||
</Button>
|
||||
{/snippet}
|
||||
@@ -307,14 +278,6 @@
|
||||
|
||||
<RoundTelemetry {name} />
|
||||
|
||||
{#if destroyError}
|
||||
<div class="destroy-error" role="alert">{destroyError}</div>
|
||||
{/if}
|
||||
{#if destroyWarnings.length > 0}
|
||||
<ul class="destroy-warn">
|
||||
{#each destroyWarnings as w}<li>{w}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<div class="tabs-row">
|
||||
@@ -604,27 +567,7 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.destroy-error {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(255, 86, 112, 0.08);
|
||||
border: 1px solid rgba(255, 86, 112, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-danger);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.destroy-warn {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
border: 1px solid rgba(255, 176, 32, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
list-style: disc inside;
|
||||
}
|
||||
|
||||
/* Tabs row */
|
||||
/* Tabs row */
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user