Preserve deleted server history

Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
This commit is contained in:
2026-09-27 21:48:55 -04:00
parent 53b29f726c
commit 981fb27c1c
61 changed files with 929 additions and 223 deletions
+6 -6
View File
@@ -7,7 +7,7 @@
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -22,7 +22,7 @@
let lifecycleOpen = $state(false);
interface Deployment {
id: string; server: string; action: ProvisioningAction;
id: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction;
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
@@ -72,10 +72,10 @@
let action = $state('all');
let search = $state('');
let refreshing = false;
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
let active = $derived(deployments.filter(d => !d.server_deleted_at && ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
const attention = $derived(deployments.filter(needsAttention));
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
let history = $derived(deployments.filter(d => (d.server_deleted_at || !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)) &&
(historyState === 'all' || needsAttention(d)) &&
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
async function load() {
@@ -151,7 +151,7 @@
<article class="deployment-row"><a class="row-link" href="/provisioning/{d.id}">
<div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{actionLabels[d.action]}</Badge>
<strong class="server"><ServerIdentity name={d.server} /></strong>
<strong class="server"><HistoricalServerIdentity name={d.server} deletedAt={d.server_deleted_at} /></strong>
</div>
<StatusBadge state={d.state} />
</div>
@@ -159,7 +159,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}
</a>{#if d.canCancelRemoval}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
</a>{#if d.canCancelRemoval && !d.server_deleted_at}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
{/snippet}
<style>
+9 -8
View File
@@ -18,7 +18,7 @@
import { actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
interface Deployment {
id: string; expected_map?: string; server: string; action: ProvisioningAction; state: string;
id: string; expected_map?: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction; state: string;
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
@@ -30,7 +30,7 @@
let open = $state(false);
let resuming = $state(false);
let deployment = $derived(detail?.deployment);
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let active = $derived(!deployment?.server_deleted_at && ['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let warnings = $derived.by(() => {
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
});
@@ -58,6 +58,7 @@
});
let explanation = $derived.by(() => {
if (!deployment) return '';
if (deployment.server_deleted_at) return 'This server was deleted. Its recorded operation and results are preserved below.';
if (detail?.canResumeRebuild) return 'The old Komodo instance was deleted. Resume file cleanup, then create a fresh instance in the reserved server slot.';
if (['start', 'restart', 'stop'].includes(deployment.action)) {
if (deployment.state === 'done') return deployment.action === 'stop' ? 'Komodo confirmed the container is stopped. Start it again when ready.' : 'The container is healthy and RCON responds.';
@@ -138,16 +139,16 @@
{#if deployment}
<section class="summary">
<div class="top"><StatusBadge state={deployment.state} />
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
{#if deployment.server_deleted_at}<Badge>Deleted</Badge>{:else if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div>
<h2>{deployment.state === 'done' ? actionCompleted[deployment.action] : deployment.stage}</h2>
<p class="explanation" aria-live="polite">{explanation}</p>
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if detail?.canResumeRebuild}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume rebuild cleanup</Button></div>{/if}
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
{#if detail?.canCancelRemoval}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
{#if deployment.server_exists && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
{#if detail?.canResumeVerification && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if detail?.canResumeRebuild && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume rebuild cleanup</Button></div>{/if}
{#if detail?.canResumeCleanup && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
{#if detail?.canCancelRemoval && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
{#if deployment.server_exists && !deployment.server_deleted_at && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -13,12 +14,12 @@
let error = $state('');
let requestNumber = 0;
let collection = $derived(detail?.collection);
const launching = (server: { state: string }) => ['running', 'awaiting-health'].includes(server.state);
const launching = (server: { state: string; server_deleted_at?: string | null }) => !server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning', 'cancelled'].includes(server.state);
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => server.state === 'queued'));
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting for empty server', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
@@ -81,7 +82,7 @@
{#each currentServers as server (server.id)}
<a class="current-launch" href="/provisioning/{server.id}">
<span class="activity" aria-hidden="true"></span>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<ServerIdentity name={server.server} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
</a>
{/each}
@@ -100,7 +101,7 @@
{#if exact && detail.retainedServers.length}
<details><summary>{detail.retainedServers.length} retained server{detail.retainedServers.length === 1 ? '' : 's'} (unchanged)</summary>
<ul>{#each detail.retainedServers as name}<li><ServerIdentity {name} /> <span class="muted">{name}</span></li>{/each}</ul>
<ul>{#each detail.retainedServers as name}<li><HistoricalServerIdentity {name} deletedAt={detail.retainedServerDeletedAt?.[name]} /> <span class="muted">{name}</span></li>{/each}</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title">
@@ -111,7 +112,7 @@
<a class="server" href="/provisioning/{server.id}">
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<div class="server-content">
<div class="server-heading"><strong><ServerIdentity name={server.server} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>{server.action === 'destroy' ? 'Remove server' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
</div>