Simplify collection launch review
This commit is contained in:
@@ -1,188 +1,339 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import NativeHostSelection from './NativeHostSelection.svelte';
|
||||
import { createCollectionLaunchController, type CollectionLaunchState } from '$ui/collection-launch.js';
|
||||
import { collectionMapGroups, collectionKeyCoverage, collectionCapacitySummary } from '$ui/collection-summary.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import type { BlueprintSummary, CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
|
||||
import type { BlueprintSummary, CollectionMode } from '$lib/deployment-plan.js';
|
||||
|
||||
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let preview = $state<CollectionPreview | null>(null);
|
||||
const id = $props.id();
|
||||
let mode = $state<CollectionMode>('fill');
|
||||
let modeInput = $state<HTMLInputElement>();
|
||||
let games = $state<ZombieGame[]>([]);
|
||||
let collectionName = $state('Launch collection');
|
||||
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||
let hostReady = $state(false);
|
||||
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
|
||||
let confirmRemovals = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let assignmentNotice = $state('');
|
||||
let view = $state<CollectionLaunchState>({ preview: null, checking: false, error: '', changeNotice: '' });
|
||||
let settledId: string | undefined;
|
||||
const controller = createCollectionLaunchController(state => {
|
||||
if (state.checking || state.preview?.id !== settledId) confirmRemovals = false;
|
||||
settledId = state.preview?.id;
|
||||
view = state;
|
||||
});
|
||||
let loaded = $state(false);
|
||||
let loadingError = $state('');
|
||||
let launchError = $state('');
|
||||
let launching = $state(false);
|
||||
let unconfirmedLaunch = $state(false);
|
||||
let refreshVersion = $state(0);
|
||||
let now = $state(Date.now());
|
||||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||||
const keysReady = $derived(preview?.servers.filter(server => server.name && server.hasSavedKey).length ?? 0);
|
||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
|
||||
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && server.hasSavedKey));
|
||||
let wasOpen = false;
|
||||
let activeBlueprint = '';
|
||||
let visit = 0;
|
||||
let initialization: AbortController | undefined;
|
||||
let submission: AbortController | undefined;
|
||||
const preview = $derived(view.preview);
|
||||
const mapGroups = $derived(preview ? collectionMapGroups(preview) : []);
|
||||
const keyCoverage = $derived(preview ? collectionKeyCoverage(preview) : []);
|
||||
const missingKeys = $derived(keyCoverage.filter(pool => pool.available < pool.required - pool.unassigned));
|
||||
const hasChanges = $derived(!!preview && (preview.servers.length > 0 || preview.removals.length > 0));
|
||||
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||
const checking = $derived((!loaded && !loadingError) || (loaded && (hostStatus.loading || view.checking)));
|
||||
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
|
||||
const currentReady = $derived(!!preview && preview.mode === mode && hostReady && !hostStatus.loading && !view.checking && !expired);
|
||||
const noChanges = $derived(currentReady && preview?.ready && !hasChanges);
|
||||
const canLaunch = $derived(!launching && !!preview && (unconfirmedLaunch || (currentReady && preview.ready && hasChanges
|
||||
&& preview.servers.every(server => server.name && server.hasSavedKey) && (!preview.removals.length || confirmRemovals))));
|
||||
const footerStatus = $derived(launching ? 'Queueing changes…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
|
||||
: loadingError ? 'Could not load collection.' : checking ? 'Checking fleet changes…' : noChanges ? 'Collection targets met'
|
||||
: missingKeys.length ? 'More game keys needed.' : currentReady && preview?.ready && preview.removals.length && !confirmRemovals
|
||||
? 'Confirm the listed removals.' : canLaunch ? 'Ready to queue' : 'Launch unavailable');
|
||||
const actionLabel = $derived(unconfirmedLaunch ? 'Retry launch' : preview?.removals.length
|
||||
? `Create ${preview.servers.length} · Remove ${preview.removals.length}`
|
||||
: `Queue ${preview?.servers.length ?? 0} server${preview?.servers.length === 1 ? '' : 's'}`);
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; preview = null; assignmentNotice = ''; dialog.showModal(); void initialize(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
if (open && (!wasOpen || activeBlueprint !== blueprintId)) {
|
||||
wasOpen = true; activeBlueprint = blueprintId; void initialize();
|
||||
} else if (!open && wasOpen) {
|
||||
wasOpen = false; visit++; initialization?.abort(); controller.clear();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
const input = open && loaded && hostReady && !hostStatus.loading ? {
|
||||
blueprintId, mode, image: selection.image,
|
||||
libraryIds: Object.fromEntries(games.map(game => [game, selection.libraryIds[game]]))
|
||||
} : null;
|
||||
if (!launching && !unconfirmedLaunch) controller.select(input);
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
const current = ++session; busy = true; error = ''; games = []; hostReady = false;
|
||||
hostSelection = { libraryIds: {}, image: '' };
|
||||
const current = ++visit;
|
||||
initialization?.abort(); initialization = new AbortController();
|
||||
const signal = initialization.signal;
|
||||
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
|
||||
mode = 'fill'; games = []; collectionName = 'Launch collection'; confirmRemovals = false;
|
||||
selection = { libraryIds: {}, image: '' }; hostReady = false;
|
||||
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
|
||||
try {
|
||||
const response = await fetch('/api/blueprints'); const body = await response.json();
|
||||
const response = await fetch('/api/blueprints', { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
||||
if (current !== session) return;
|
||||
if (current !== visit) return;
|
||||
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
|
||||
if (!blueprint) throw new Error('This collection is no longer available.');
|
||||
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
|
||||
} catch (cause) { if (current === session) error = (cause as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
loaded = true;
|
||||
await tick();
|
||||
if (current === visit && open) modeInput?.focus();
|
||||
} catch (error) {
|
||||
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load collection.';
|
||||
}
|
||||
}
|
||||
|
||||
async function review() {
|
||||
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
|
||||
const current = ++session;
|
||||
const previous = preview;
|
||||
busy = true; error = ''; assignmentNotice = ''; confirmRemovals = false;
|
||||
try {
|
||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||||
if (current === session) {
|
||||
const next = body as CollectionPreview;
|
||||
const names = new Set(next.servers.map(server => server.name));
|
||||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) assignmentNotice = 'Server assignments changed. Review the updated launch order before applying.';
|
||||
preview = next; now = Date.now();
|
||||
}
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
function selectionChanged() { launchError = ''; confirmRemovals = false; }
|
||||
function refreshChecks() {
|
||||
if (!open || launching || unconfirmedLaunch) return;
|
||||
if (!loaded) { void initialize(); return; }
|
||||
launchError = ''; confirmRemovals = false;
|
||||
controller.select(null); refreshVersion++;
|
||||
}
|
||||
onMount(() => {
|
||||
let returning: ReturnType<typeof setTimeout>;
|
||||
const refreshOnReturn = () => {
|
||||
clearTimeout(returning);
|
||||
if (document.visibilityState !== 'hidden') returning = setTimeout(refreshChecks, 100);
|
||||
};
|
||||
window.addEventListener('focus', refreshOnReturn);
|
||||
document.addEventListener('visibilitychange', refreshOnReturn);
|
||||
const timer = setInterval(() => { now = Date.now(); }, 1000);
|
||||
return () => {
|
||||
clearTimeout(returning); clearInterval(timer);
|
||||
window.removeEventListener('focus', refreshOnReturn);
|
||||
document.removeEventListener('visibilitychange', refreshOnReturn);
|
||||
};
|
||||
});
|
||||
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
|
||||
|
||||
async function launch() {
|
||||
if (!preview || !canLaunch || expired || busy) return;
|
||||
busy = true; error = '';
|
||||
if (!canLaunch || !preview) return;
|
||||
const current = visit;
|
||||
const reviewed = preview;
|
||||
controller.suspend(); launching = true; launchError = '';
|
||||
submission = new AbortController();
|
||||
try {
|
||||
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||||
const response = await fetch('/api/deployment-collections', { method: 'POST', signal: submission.signal,
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: reviewed.id }) });
|
||||
const body = await response.json();
|
||||
if (current !== visit) return;
|
||||
if (!response.ok) {
|
||||
unconfirmedLaunch = response.status >= 500;
|
||||
launchError = body.error ?? 'Could not queue this collection.';
|
||||
if (!unconfirmedLaunch) { controller.select(null); refreshVersion++; }
|
||||
return;
|
||||
}
|
||||
if (!body.ok) throw new Error('The launch response could not be confirmed.');
|
||||
open = false;
|
||||
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
} catch {
|
||||
if (current === visit) {
|
||||
// The queue may already be committed. Keep its preview ID for an idempotent retry.
|
||||
unconfirmedLaunch = true;
|
||||
launchError = 'Could not confirm the launch. Retry the same request, or check collection launches.';
|
||||
}
|
||||
} finally { if (current === visit) launching = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? collectionName}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||||
<label class="mode">Apply collection
|
||||
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; preview = null; confirmRemovals = false; }}>
|
||||
<option value="fill">Fill missing servers</option>
|
||||
<option value="exact">Match collection exactly</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
||||
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and game key availability. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
<Dialog bind:open title={collectionName} description="Launch collection" wide busy={launching}>
|
||||
<div class="launch-content">
|
||||
{#if loadingError}
|
||||
<p class="issue" role="alert">{loadingError}</p>
|
||||
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
|
||||
{:else if !loaded}
|
||||
<p class="muted" role="status">Loading collection…</p>
|
||||
{:else}
|
||||
{#if assignmentNotice}<p role="status" class="muted">{assignmentNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch to recheck capacity and key availability.</p>{/if}
|
||||
<div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection already satisfied'}</strong>
|
||||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
|
||||
{#if preview.mode === 'exact' && preview.retainedServers.length}<details class="coverage"><summary>Servers to keep ({preview.retainedServers.length})</summary><ul>{#each preview.retainedServers as name}<li><ServerIdentity {name} /><span>{name}</span></li>{/each}</ul></details>{/if}
|
||||
{#if preview.mode === 'fill' && preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
||||
<div class="mode-selection">
|
||||
<fieldset disabled={launching || unconfirmedLaunch}>
|
||||
<legend class="sr-only">Apply collection</legend>
|
||||
<label class:selected={mode === 'fill'}><input bind:this={modeInput} type="radio" name={`${id}-collection-mode`} value="fill" bind:group={mode} onchange={selectionChanged} />Fill missing</label>
|
||||
<label class:selected={mode === 'exact'}><input type="radio" name={`${id}-collection-mode`} value="exact" bind:group={mode} onchange={selectionChanged} />Match exactly</label>
|
||||
</fieldset>
|
||||
<p class="muted">{mode === 'exact' ? 'Match the entire fleet across both games. Excess lobbies and maps outside this collection will be removed.' : 'Create missing lobbies. All existing servers and their settings stay as they are.'}</p>
|
||||
</div>
|
||||
{#if preview.availability && preview.servers.length}
|
||||
<section class="availability" aria-label="Latest reported capacity">
|
||||
<h3>Latest reported capacity</h3>
|
||||
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
|
||||
<p class="muted">{preview.availability.estimatedAvailable === null ? 'An estimate is not available yet.' : `${preview.availability.estimatedAvailable} additional servers estimated to fit.`} {preview.availability.requested} requested.</p>
|
||||
<dl>
|
||||
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
|
||||
<div><dt>Memory available</dt><dd>{preview.availability.memoryBytesAvailable === null ? 'Unknown' : `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}</dd></div>
|
||||
<div><dt>Ports available</dt><dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd></div>
|
||||
<div><dt>Startup slots</dt><dd>{preview.availability.startupSlots ?? 'Unknown'}{#if preview.availability.startupLimit !== null} / {preview.availability.startupLimit}{/if}</dd></div>
|
||||
</dl>
|
||||
<p class="caption">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'}{#if preview.availability.queued !== null} {preview.availability.queued} servers already queued.{/if} Provisioning checks capacity separately for each server before it starts.</p>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="section-title"><h3>{preview.mode === 'exact' ? 'Review fleet changes' : preview.servers.length ? 'Your launch order' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
|
||||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
||||
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
|
||||
{#if preview.servers.length || preview.removals.length}
|
||||
{#if preview.servers.length}<p class="muted">Available keys are assigned automatically from each game’s pool. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
||||
{#if preview.servers.some(server => !server.hasSavedKey)}<p class="muted" role="status">There are not enough unused keys for these launches. Add keys to the affected game pools in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
|
||||
{#if preview.removals.length && preview.servers.some(server => !server.hasSavedKey)}<p class="muted">Keys held by servers awaiting removal are still in use. To reuse those keys, remove the servers first and refresh this preview after cleanup finishes.</p>{/if}
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
{#if preview.removals.length}
|
||||
<section class="removals" aria-label="Servers to remove">
|
||||
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
||||
<p class="muted">Containers, files, and registrations will be removed after a fresh zero-player check. Busy or unreachable servers wait. Unstarted creations are cancelled. Saved keys are kept. New servers reusing these ports wait for removal to finish.</p>
|
||||
<ul>{#each preview.removals as server}<li><strong><ServerIdentity name={server.name} /></strong><span class="server-id">{server.name}</span><span class="muted">{server.reason}</span></li>{/each}</ul>
|
||||
<label class="confirm-removals"><input type="checkbox" bind:checked={confirmRemovals} disabled={busy || preview.mode !== mode} />Remove the {preview.removals.length} listed servers and apply this collection.</label>
|
||||
</section>
|
||||
{/if}
|
||||
{#if preview.servers.length}
|
||||
<ol class="servers" aria-label="Server launch order">
|
||||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||||
<li class="server-row">
|
||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||||
{#if server.name}
|
||||
{#if server.hasSavedKey}<p class="key-status key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
||||
{:else}<p class="key-status key-missing">No unused {preview.entries[server.entryIndex].recipe.game.toUpperCase()} key available</p>{/if}
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<section class="change-summary" aria-label="Fleet changes" aria-busy={checking}>
|
||||
{#if preview}
|
||||
<strong>{noChanges ? 'Collection targets met' : `${preview.servers.length} to create · ${preview.removals.length} to remove`}</strong>
|
||||
{#if mode === 'exact' && preview.retainedServers.length}<p class="muted">{preview.retainedServers.length} existing or queued servers kept.</p>{/if}
|
||||
<div class="readiness">
|
||||
{#each keyCoverage as pool}<span class:ready={pool.available >= pool.required} class:warning={pool.available < pool.required}>{pool.game.toUpperCase()} keys: {pool.unassigned ? 'waiting for lobby availability' : `${pool.available} of ${pool.required} required available`}</span>{/each}
|
||||
{#if preview.servers.length}<span class:ready={hostStatus.filesReady}>{hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>{/if}
|
||||
</div>
|
||||
{#if preview.servers.length}<p class="muted">{collectionCapacitySummary(preview.availability)}</p>{/if}
|
||||
{#if noChanges}<p class="muted">{mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet meets or exceeds every target.'} Existing servers keep their current settings.</p>{/if}
|
||||
{:else}<p role="status">{checking ? 'Checking fleet changes…' : 'Resolve the issues below to check this collection.'}</p>{/if}
|
||||
</section>
|
||||
|
||||
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/collections?view=active" target="_blank" rel="noopener">View collection launches ↗</a>{/if}</div>{/if}
|
||||
{#if view.changeNotice}<p class="issue" role="status">{view.changeNotice}</p>{/if}
|
||||
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
|
||||
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload {hostStatus.missingGames.map(game => game.toUpperCase()).join(' and ')} game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
|
||||
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
|
||||
{:else if view.error}<p class="issue" role="alert">{view.error}</p>{/if}
|
||||
{#if missingKeys.length}<div class="issue" role="status">
|
||||
{#each missingKeys as pool}{@const shortfall = pool.required - pool.unassigned - pool.available}<p>{pool.game.toUpperCase()} needs {shortfall} more unused key{shortfall === 1 ? '' : 's'}. <a href={`/server-keys?game=${pool.game}`} target="_blank" rel="noopener">Manage keys ↗</a></p>{/each}
|
||||
{#if preview?.removals.length}<p>Keys assigned to removals stay in use until cleanup finishes. Complete those removals first to reuse their keys.</p>{/if}
|
||||
</div>{/if}
|
||||
{#each preview?.blockers ?? [] as blocker}<p class="issue" role="alert">{blocker}</p>{/each}
|
||||
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKeys.length || preview?.blockers.length)}<button class="text-button" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
|
||||
|
||||
{#if preview}
|
||||
<section class="lineup" aria-label="Collection map targets">
|
||||
{#each mapGroups as group}
|
||||
<table>
|
||||
<caption>{group.label}</caption>
|
||||
<thead><tr><th scope="col">Map</th><th scope="col">Target</th><th scope="col">Existing / queued</th><th scope="col">Create</th>{#if mode === 'exact'}<th scope="col">Remove</th>{/if}</tr></thead>
|
||||
<tbody>{#each group.rows as row}<tr><th scope="row">{row.mapName}</th><td>{row.target}</td><td>{row.existing}</td><td class:quantity={row.create > 0}>{row.create}</td>{#if mode === 'exact'}<td class:danger={row.remove > 0}>{row.remove}</td>{/if}</tr>{/each}</tbody>
|
||||
</table>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Pool availability is checked again when you apply. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p></div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
||||
</form>
|
||||
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
<p class="muted">Stopped and unhealthy servers also count toward targets. See Server details for health.{#if mode === 'exact'} Existing totals include the listed removals.{/if}</p>
|
||||
</section>
|
||||
|
||||
{#if preview.removals.length}<section class="removals" aria-label="Servers to remove">
|
||||
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
||||
<p>Containers, files, and registrations will be removed. Occupied or unreachable servers wait for a fresh empty-player check; unstarted creations are cancelled.</p>
|
||||
<ul>{#each preview.removals as server}<li><strong>{serverTitle(server.name)}</strong><code>{server.name}</code><span>{server.reason}</span></li>{/each}</ul>
|
||||
<label class="confirmation"><input type="checkbox" bind:checked={confirmRemovals} disabled={launching || unconfirmedLaunch || !currentReady} />Remove the {preview.removals.length} listed servers and apply this collection.</label>
|
||||
</section>{/if}
|
||||
|
||||
<details class="details">
|
||||
<summary>Server details</summary>
|
||||
<div class="detail-body">
|
||||
{#if preview.servers.length}<h3>Launch order</h3><p class="muted">Hosts and ports are assigned when provisioning starts.</p>
|
||||
<ol class="server-list">{#each preview.servers as server}<li><strong>{server.name ? serverTitle(server.name) : preview.entries[server.entryIndex].recipe.mapName}</strong><code>{server.name ?? 'No available lobby'}</code></li>{/each}</ol>
|
||||
{/if}
|
||||
{#if preview.entries.some(entry => entry.existing)}<h3>Existing and queued servers</h3>
|
||||
<ul class="existing-list">{#each preview.entries as entry}{#each entry.coverage ?? [] as server}<li><a href={server.href} target="_blank" rel="noopener">{serverTitle(server.name)} ↗</a><span>{server.health === 'online' ? 'Healthy' : server.health}{#if mode === 'exact'} · {preview.retainedServers.includes(server.name) ? 'Keep' : 'Remove'}{/if}</span></li>{/each}{/each}</ul>
|
||||
{/if}
|
||||
{#if !preview.servers.length && !preview.entries.some(entry => entry.existing)}<p class="muted">No new or matching servers.</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if preview.availability && preview.servers.length}<details class="details">
|
||||
<summary>Capacity details</summary>
|
||||
<div class="detail-body">
|
||||
<dl class="capacity">
|
||||
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
|
||||
<div><dt>Memory available</dt><dd>{preview.availability.memoryBytesAvailable === null ? 'Unknown' : `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}</dd></div>
|
||||
<div><dt>Ports available</dt><dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd></div>
|
||||
<div><dt>Startup slots</dt><dd>{preview.availability.startupSlots ?? 'Unknown'}{#if preview.availability.startupLimit !== null} / {preview.availability.startupLimit}{/if}</dd></div>
|
||||
</dl>
|
||||
{#each preview.warnings ?? [] as warning}<p class="muted">{warning}</p>{/each}
|
||||
<p class="muted">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'} Capacity is checked for each server when it starts.</p>
|
||||
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
|
||||
</div>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
|
||||
{#if open}<NativeHostSelection {games} bind:selection bind:ready={hostReady} bind:status={hostStatus}
|
||||
compact {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
|
||||
|
||||
{#if preview}<details class="details">
|
||||
<summary>Setup details</summary>
|
||||
<div class="detail-body">
|
||||
<p class="muted">New servers use the current production setup when provisioning starts. Existing servers keep their files and configuration.</p>
|
||||
<dl><div><dt>Runtime image</dt><dd><code>{selection.image}</code></dd></div>
|
||||
{#each games as game}{@const files = hostStatus.gameFiles?.[game]}
|
||||
{#if files?.uploadedAt}<div><dt>{game.toUpperCase()} game files uploaded</dt><dd>{$timeFormat.dateTime(files.uploadedAt)}</dd></div>{/if}
|
||||
{/each}
|
||||
</dl>
|
||||
{#each preview.entries as entry}<details class="map-setup">
|
||||
<summary>{entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</summary>
|
||||
<div class="detail-body"><p>{entry.recipe.configSource}</p><h3>{entry.recipe.files.length} managed files</h3>
|
||||
<ul class="file-list">{#each entry.recipe.files as file}<li><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span><code>{file.path}</code></li>{/each}</ul>
|
||||
</div>
|
||||
</details>{/each}
|
||||
</div>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
{#snippet actions()}
|
||||
<div class="launch-actions">
|
||||
<p class:ready={canLaunch && !unconfirmedLaunch || noChanges} role="status">{footerStatus}</p>
|
||||
<div>{#if noChanges}<Button variant="primary" onclick={() => open = false}>Done</Button>
|
||||
{:else}<Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant={preview?.removals.length ? 'danger' : 'primary'} loading={launching} disabled={!canLaunch} onclick={launch}>{actionLabel}</Button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.availability { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.availability dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 16px; margin: 0; }
|
||||
.availability dt { font-size: var(--text-xs); color: var(--text-secondary); }.availability dd { margin: 6px 0 0; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.warning { color: var(--state-warning); font-size: 13px; line-height: 1.6; }
|
||||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
.removals { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); }
|
||||
.removals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
|
||||
.removals li { display: grid; gap: 4px; font-size: 13px; }
|
||||
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
||||
.confirm-removals input { width: auto; margin-top: 2px; }
|
||||
.key-status { font-size: 13px; font-weight: 600; }
|
||||
.key-ready { color: var(--state-online); }
|
||||
.key-missing { color: var(--state-warning); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
@media (max-width: 600px) { .server-row .key-status { grid-column: 2; } }
|
||||
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
||||
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
||||
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
||||
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
||||
.launch-content { display: grid; gap: 16px; min-width: 0; }
|
||||
.mode-selection { display: grid; gap: 9px; }
|
||||
fieldset { display: flex; gap: 8px; border: 0; padding: 0; margin: 0; }
|
||||
fieldset label { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-control); border-radius: var(--radius-md); font-size: var(--text-sm); cursor: pointer; }
|
||||
fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); }
|
||||
fieldset input { margin: 0; }
|
||||
.change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.change-summary > strong { font-size: 17px; line-height: 1.4; }
|
||||
.readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.readiness:empty { display: none; }
|
||||
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.ready, .quantity { color: var(--state-online); }
|
||||
.warning { color: var(--state-warning); }
|
||||
.danger { color: var(--state-danger); }
|
||||
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
|
||||
.text-button { justify-self: start; border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
|
||||
.lineup { display: grid; gap: 10px; min-width: 0; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-secondary); font-size: var(--text-xs); font-weight: 600; padding-bottom: 8px; }
|
||||
th, td { padding: 8px 6px; text-align: center; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
th:first-child { width: 40%; text-align: left; }
|
||||
thead th { color: var(--text-secondary); font-weight: 500; }
|
||||
tbody th { font-weight: 500; }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
.quantity, .danger { font-weight: 600; }
|
||||
.details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
.detail-body { display: grid; gap: 12px; margin-top: 14px; min-width: 0; }
|
||||
h3 { font-size: var(--text-sm); margin: 0; }
|
||||
dl { display: grid; gap: 14px; margin: 0; }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
||||
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||
.capacity { grid-template-columns: repeat(4, 1fr); }
|
||||
.server-list { margin: 0; padding-left: 24px; }
|
||||
.server-list li { padding: 8px 0; font-size: var(--text-sm); }
|
||||
.server-list code { display: block; margin-top: 4px; color: var(--text-secondary); }
|
||||
.existing-list, .file-list, .removals ul { list-style: none; padding: 0; margin: 0; }
|
||||
.existing-list li { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.existing-list span, .file-list span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.file-list li { display: grid; gap: 5px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
.map-setup { padding: 10px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.removals { display: grid; gap: 12px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); padding: 14px; }
|
||||
.removals h3 { color: var(--state-danger); }
|
||||
.removals p, .removals li span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.removals li { display: grid; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||
.removals code { color: var(--text-secondary); }
|
||||
.confirmation { display: flex; align-items: flex-start; gap: 9px; font-size: var(--text-sm); line-height: 1.5; }
|
||||
.confirmation input { margin: 3px 0 0; flex-shrink: 0; }
|
||||
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.launch-actions > p.ready { color: var(--state-online); }
|
||||
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 600px) { .capacity { grid-template-columns: repeat(2, 1fr); } th:first-child { width: 34%; } th, td { padding: 8px 3px; } }
|
||||
@media (max-width: 440px) { fieldset label { flex: 1; padding: 10px; font-size: var(--text-xs); } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
missingGames: games.filter(game => !latest[game]?.id),
|
||||
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
||||
.filter((value): value is string => !!value).sort().at(-1) ?? null,
|
||||
gameFiles: Object.fromEntries(games.map(game => [game, { ready: cacheReady(game),
|
||||
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null }])),
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
|
||||
Reference in New Issue
Block a user