Files
electric-cherry/src/routes/collections/launches/[id]/+page.svelte
T

209 lines
23 KiB
Svelte

<script lang="ts">
import { plural } from '$ui/plural.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.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';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import type { CollectionDetail } from '$lib/deployment-plan.js';
type CollectionServer = CollectionDetail['servers'][number];
let detail = $state<CollectionDetail | null>(null);
let error = $state('');
let cancelError = $state('');
let cancelling = $state(false);
let cancellationAccepted = $state(false);
let requestNumber = 0;
let collection = $derived(detail?.collection);
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'].includes(server.state);
const cleanupNeedsAttention = (cancellation: NonNullable<CollectionServer['cancellation']>) => needsAttention(cancellation) || cancellation.state === 'cancelled';
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const cancellationRequested = $derived(!!collection?.cancelled_at || cancellationAccepted);
const inProgress = $derived(!!collection && (collection.active > 0 || collection.cleanupActive > 0));
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const heading = $derived.by(() => {
if (!collection) return 'Collection launch';
if (cancellationRequested) return inProgress ? 'Cancellation requested' : collection.failed || collection.cleanupFailed ? 'Cancellation needs attention' : 'Collection launch cancelled';
if (collection.cleanupActive) return 'Cleaning up cancelled launches';
if (collection.active) return exact ? 'Applying your collection' : currentServers.length ? 'Bringing your lineup online' : 'Your lineup is queued';
if (collection.failed || collection.cleanupFailed) return exact ? 'Collection finished with issues' : 'Launch complete with issues';
if (collection.cancelled) return 'Collection finished with cancellations';
return exact ? 'Collection applied' : 'Your lineup is ready';
});
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
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', 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) {
if (waitingForPort(server)) return 'Waiting for removal';
if (server.action === 'destroy' && server.state === 'done') return 'Removed';
if (server.action === 'destroy' && server.state === 'running') return 'Removing';
return labels[server.state] ?? server.state;
}
function queueHint(server: CollectionServer) {
const reason = server.stage || 'Waiting for a startup slot and available host capacity.';
return server.queuePosition && server.queuePosition > 1
? `${reason} ${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead.` : reason;
}
async function load() {
const id = $page.params.id ?? '';
const current = ++requestNumber;
try {
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('Could not refresh this collection.');
const body = await res.json();
if (current === requestNumber && id === $page.params.id) { detail = body; error = ''; }
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
}
async function cancelLaunch() {
if (!collection || !detail?.canCancel || cancellationRequested || cancelling || !canOperate) return;
const id = collection.id;
const accepted = await confirmAction({
title: 'Cancel collection launch?',
message: 'Stops unfinished server creations, including eligible failed or interrupted launches, and deletes their containers and instance files. This can disconnect active players, even when player status is unknown. Shared game files are kept. Keys and ports are released only after removal is confirmed.\n\nCompleted launches and retained servers are kept. Pending removals are cancelled where safe; removals that have already started or are shared with another collection may still finish.',
confirmLabel: 'Cancel launch and clean up',
danger: true
});
if (!accepted || id !== $page.params.id || cancellationRequested || cancelling) return;
cancelling = true;
cancelError = '';
try {
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Could not cancel this collection launch.');
if (id !== $page.params.id) return;
cancellationAccepted = true;
await load();
} catch (err) {
if (id === $page.params.id) {
cancelError = (err as Error).message;
await load();
}
} finally { if (id === $page.params.id) cancelling = false; }
}
$effect(() => { $page.params.id; detail = null; cancelError = ''; cancelling = false; cancellationAccepted = false; void load(); });
onMount(() => { const stop = pollVisible(load, 5000, false); return () => { stop(); requestNumber++; }; });
</script>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs fallback="/collections?view=active" items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
</SectionHeader>
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail}
<section class="summary" aria-label="Collection progress">
<div class="summary-heading">
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div>
<span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
</div>
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
<span class="progress-cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span>
</div>
<dl class="counts" class:has-cancellations={cancellationRequested || collection.cancelled > 0}>
<div class="ready-count"><dt>{exact ? 'Completed' : 'Ready'}</dt><dd>{collection.done}</dd></div>
<div><dt>{exact ? 'In progress' : 'Launching'}</dt><dd>{collection.provisioning + collection.verifying}</dd></div>
<div><dt>Queued</dt><dd>{collection.queued}</dd></div>
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
{#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
</dl>
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} {plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention.</p>
{#if cancellationRequested}
<p class="cancellation-note">Completed launches and retained servers are kept. Removals that already started or are shared with another collection may still finish. Follow each cancelled server's cleanup below until removal is confirmed.</p>
{/if}
{#if collection.cleanupActive || collection.cleanupFailed}
<div class="cleanup-summary" role="status">
{#if collection.cleanupActive}<p>{collection.cleanupActive} server cleanup{collection.cleanupActive === 1 ? '' : 's'} in progress. Keys and ports remain reserved until removal is confirmed.</p>{/if}
{#if collection.cleanupFailed}<p class="error">{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1 ? ' needs' : 's need'} attention. Open the cleanup operation below to review and recover.</p>{/if}
</div>
{/if}
{#if canOperate && detail.canCancel && !cancellationRequested}
<div class="cancel-action"><Button variant="danger" loading={cancelling} onclick={cancelLaunch}>Cancel collection launch</Button><p>Stops unfinished launches and removes their containers and instance files. Completed launches and retained servers are kept.</p></div>
{/if}
{#if cancelError}<p class="error" role="alert">{cancelError}</p>{/if}
</section>
{#if currentServers.length}
<section class="current-launches" aria-label="In progress">
<span class="eyebrow">In progress</span>
{#each currentServers as server (server.id)}
<a class="current-launch" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<span class="activity" aria-hidden="true"></span>
<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}
</section>
{:else if firstQueued}
<aside class="waiting-note">
<span class="waiting-icon" aria-hidden="true">◷</span>
<div><strong>{waitingForPort(firstQueued) ? 'Waiting for a removal to release capacity' : 'Your collection is queued'}</strong>
<p>{queueHint(firstQueued)} Provisioning starts each server when its host and capacity are ready. Progress updates automatically.</p>
{#if otherLaunches.length}
<p>Other servers currently launching:</p>
<div class="other-launches">{#each otherLaunches as active (active.id)}<a href={contextHref(`/provisioning/${active.id}`, $page.url)}><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div>
{/if}
</div>
</aside>
{/if}
{#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><HistoricalServerIdentity {name} deletedAt={detail.retainedServerDeletedAt?.[name]} /> <span class="muted">{name}</span></li>{/each}</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title">
<div class="queue-heading"><h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2><span class="muted">{collection.total} operation{collection.total === 1 ? '' : 's'}</span></div>
<ol class="servers">
{#each detail.servers as server, index (server.id)}
<li class:launching={launching(server)} class:attention={needsAttention(server) || (server.cancellation && cleanupNeedsAttention(server.cancellation))}>
<a class="server" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<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><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' ? 'Destroy server' : server.port === null ? 'Create server · Port not assigned yet' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
</div>
</a>
{#if server.cancellation}
<a class="cleanup-link" class:error={cleanupNeedsAttention(server.cancellation)} href={contextHref(`/provisioning/${server.cancellation.cleanupId}`, $page.url)}>
<strong>{server.cancellation.state === 'done' ? 'Cleanup completed' : cleanupNeedsAttention(server.cancellation) ? 'Cleanup needs attention' : 'Cleanup in progress'}</strong>
<span>{server.cancellation.error ?? server.cancellation.stage}</span>
<span class="cleanup-operation">{cleanupNeedsAttention(server.cancellation) ? 'Review cleanup' : 'View cleanup progress'} <span aria-hidden="true">→</span></span>
</a>
{/if}
</li>
{/each}
</ol>
</section>
{:else if !error}<p class="muted" role="status">Loading collection…</p>{/if}
</div>
<style>
.page { display: grid; gap: 28px; }
h2, p { margin: 0; }h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; color: var(--text-secondary); margin-bottom: 9px; }
a { color: var(--accent-primary-hover); }a:focus-visible, button:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }.muted { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }.error { color: var(--state-danger); }
.summary { display: grid; gap: 24px; padding: 28px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.summary-heading, .queue-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }.completion { color: var(--text-secondary); font-size: var(--text-sm); white-space: nowrap; font-variant-numeric: tabular-nums; }.completion strong { color: var(--text-primary); font-weight: 600; }
.progress { display: flex; height: 6px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.progress span { transition: width var(--dur-base) var(--ease-standard); }.progress-ready { background: var(--state-online); }.progress-failed { background: var(--state-danger); }.progress-cancelled { background: var(--text-tertiary); }
.counts.has-cancellations { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.cancellation-note, .cancel-action p, .cleanup-summary { font-size: var(--text-sm); line-height: 1.6; color: var(--text-secondary); }.cancel-action { display: grid; gap: 10px; justify-items: start; }.cleanup-summary { display: grid; gap: 8px; }
.cleanup-link { display: grid; gap: 6px; margin: 0 24px 20px 68px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.cleanup-link:hover { background: var(--surface-raised); }.cleanup-link strong { color: var(--text-primary); }.cleanup-link.error, .cleanup-link.error strong { color: var(--state-danger); }.cleanup-operation { color: var(--accent-primary-hover); }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; }.counts > div + div { padding-left: 24px; border-left: 1px solid var(--border-subtle); }dt { color: var(--text-secondary); font-size: var(--text-xs); }dd { font-variant-numeric: tabular-nums; font-size: var(--text-2xl); font-weight: 600; margin: 6px 0 0; letter-spacing: -.025em; }.ready-count dd { color: var(--state-online); }.attention-count dd { color: var(--state-danger); }
.current-launches { display: grid; gap: 12px; }.current-launches > .eyebrow { margin: 0; color: var(--accent-primary-hover); }.current-launch { display: flex; align-items: center; gap: 16px; color: inherit; text-decoration: none; padding: 20px 24px; border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface)); }.current-launch > div { min-width: 0; flex: 1; }.current-launch strong { font-size: var(--text-md); }.current-launch p { margin-top: 7px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }.current-launch:hover { border-color: var(--accent-primary); }.operation-link { font-size: var(--text-xs); color: var(--accent-primary-hover); white-space: nowrap; }.activity { flex: 0 0 9px; height: 9px; border-radius: 50%; background: var(--accent-primary-hover); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.waiting-note { display: flex; gap: 16px; padding: 0 4px; }.waiting-icon { color: var(--text-secondary); font-size: var(--text-xl); line-height: 1; }.waiting-note strong { font-size: var(--text-base); }.waiting-note p { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }.other-launches { display: grid; gap: 10px; margin-top: 12px; }.other-launches a { display: flex; align-items: baseline; gap: 10px; width: fit-content; font-size: var(--text-sm); text-decoration: none; }.other-launches a:hover { text-decoration: underline; }
.queue { display: grid; gap: 16px; }.queue-heading h2 { font-size: var(--text-md); }.servers { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); }.servers li + li { border-top: 1px solid var(--border-subtle); }.servers li { position: relative; }.servers li.launching::before, .servers li.attention::before { content: ''; width: 2px; position: absolute; inset: 0 auto 0 0; background: var(--accent-primary); pointer-events: none; }.servers li.attention::before { background: var(--state-danger); }.server { display: flex; gap: 20px; padding: 22px 24px; color: inherit; text-decoration: none; }.server:hover { background: var(--surface-raised); }.server:focus-visible { outline-offset: -3px; }.order { padding-top: 2px; color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); flex: 0 0 24px; font-variant-numeric: tabular-nums; }.order.ready { color: var(--state-online); font-family: var(--font-body); font-size: var(--text-md); padding-top: 0; }.server-content { flex: 1; min-width: 0; }.server-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }.server-heading strong { font-size: var(--text-base); }.server p { margin-top: 9px; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.server p.error { color: var(--state-danger); }.server-meta { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; color: var(--text-tertiary); font-size: var(--text-xs); }.server-meta span:last-child { color: var(--accent-primary-hover); }.screen-reader { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .page { gap: 24px; }.summary { padding: 20px; gap: 20px; }h2 { font-size: var(--text-lg); }.counts, .counts.has-cancellations { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }.counts > div + div { padding-left: 0; border-left: 0; }.counts > div:nth-child(even) { padding-left: 20px; border-left: 1px solid var(--border-subtle); }dd { font-size: var(--text-xl); }.current-launch { padding: 20px; flex-wrap: wrap; gap: 12px; }.current-launch > div { flex-basis: calc(100% - 24px); }.operation-link { margin-left: 21px; }.server { gap: 12px; padding: 20px 16px; }.order { flex-basis: 20px; font-variant-numeric: tabular-nums; }.server-heading { row-gap: 10px; }.server-heading strong { flex-basis: 100%; }.completion { font-size: var(--text-xs); }.cleanup-link { margin: 0 16px 16px; } }
</style>