Files
electric-cherry/src/lib/ui/components/DeployServerDialog.svelte
T

567 lines
20 KiB
Svelte

<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let {
open = $bindable(false),
initialGame = 't5',
initialMapSlug = ''
}: {
open?: boolean;
initialGame?: ZombieGame;
initialMapSlug?: string;
} = $props();
let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let gameSelect = $state<HTMLSelectElement>();
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 view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
const controller = createServerLaunchController((state) => {
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());
let wasOpen = false;
let visit = 0;
let initialization: AbortController | undefined;
let submission: AbortController | undefined;
const maps = $derived(catalogs[game]);
const preview = $derived(view.preview);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const checksBlocked = $derived(
preview?.checks.filter((check) => check.state === 'blocked') ?? []
);
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
const canLaunch = $derived(
!launching &&
!!preview?.name &&
(unconfirmedLaunch ||
(hostReady &&
!hostStatus.loading &&
!view.checking &&
!expired &&
preview.ready &&
preview.hasSavedKey))
);
const checking = $derived((!loaded && !loadingError) || hostStatus.loading || view.checking);
const footerStatus = $derived(
launching
? 'Queueing server…'
: unconfirmedLaunch
? 'Retry to confirm this launch.'
: loadingError
? 'Could not load launch options.'
: checking
? 'Checking availability…'
: missingKey
? 'No game key available.'
: canLaunch
? 'Ready to launch'
: 'Launch unavailable'
);
$effect(() => {
if (open && !wasOpen) {
wasOpen = true;
void initialize();
} else if (!open && wasOpen) {
wasOpen = false;
visit++;
initialization?.abort();
controller.clear();
}
});
$effect(() => {
// Selection changes invalidate the old preview before another launch can use it.
const input =
open && loaded && hostReady && !hostStatus.loading
? {
game,
mapSlug,
libraryId: selection.libraryIds[game]!,
image: selection.image,
bindAddress: selection.bindAddress || undefined,
publicAddress: selection.publicAddress || undefined,
publicPort: selection.publicPort
}
: null;
if (!launching && !unconfirmedLaunch) controller.select(input);
});
async function initialize() {
const current = ++visit;
initialization?.abort();
initialization = new AbortController();
const signal = initialization.signal;
controller.clear();
loaded = false;
loadingError = '';
launchError = '';
launching = false;
unconfirmedLaunch = false;
game = initialGame;
mapSlug = initialMapSlug;
selection = { libraryIds: {}, image: '' };
hostReady = false;
hostStatus = {
loading: true,
error: '',
hasHosts: false,
filesReady: false,
missingGames: [],
uploadedAt: null
};
try {
const response = await fetch('/api/maps', { signal });
if (!response.ok) throw new Error('Could not load maps. Try again.');
const body = await response.json();
if (current !== visit) return;
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (!catalogs[game].some((map) => map.slug === mapSlug))
mapSlug = catalogs[game][0]?.slug ?? '';
loaded = true;
await tick();
if (current === visit && open) gameSelect?.focus();
} catch (error) {
if (current === visit && !signal.aborted)
loadingError =
error instanceof Error ? error.message : 'Could not load launch options.';
}
}
function refreshChecks() {
if (!open || launching || unconfirmedLaunch) return;
if (!loaded) {
void initialize();
return;
}
launchError = '';
controller.clear();
refreshVersion++;
}
function selectionChanged() {
launchError = '';
}
onMount(() => {
const refreshOnReturn = () => {
if (document.visibilityState !== 'hidden') refreshChecks();
};
window.addEventListener('focus', refreshOnReturn);
document.addEventListener('visibilitychange', refreshOnReturn);
const timer = setInterval(() => {
now = Date.now();
}, 1000);
return () => {
window.removeEventListener('focus', refreshOnReturn);
document.removeEventListener('visibilitychange', refreshOnReturn);
clearInterval(timer);
};
});
onDestroy(() => {
visit++;
initialization?.abort();
submission?.abort();
controller.dispose();
});
async function launch() {
if (!canLaunch || !preview) return;
const current = visit;
const reviewed = preview;
controller.suspend();
launching = true;
launchError = '';
submission = new AbortController();
try {
const response = await fetch('/api/servers', {
method: 'POST',
signal: submission.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
game: reviewed.recipe.game,
gameMode: 'zombie',
mapSlug: reviewed.recipe.mapSlug,
expectedName: reviewed.name,
previewId: reviewed.id
})
});
const body = await response.json();
if (current !== visit) return;
if (!response.ok || !body.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this launch.';
if (!unconfirmedLaunch) {
controller.clear();
refreshVersion++;
}
return;
}
open = false;
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
} catch {
if (current === visit) {
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
unconfirmedLaunch = true;
launchError =
'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
}
} finally {
if (current === visit) launching = false;
}
}
</script>
<Dialog bind:open title="Create server" 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 maps…</p>
{:else}
<div class="fields">
<Field label="Game">
<Select
bind:element={gameSelect}
bind:value={game}
disabled={launching || unconfirmedLaunch}
onchange={(event) => {
game = event.currentTarget.value as ZombieGame;
mapSlug = catalogs[game][0]?.slug ?? '';
selectionChanged();
}}
>
<option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option
>
</Select></Field
>
<Field label="Map">
<Select
bind:value={mapSlug}
disabled={launching || unconfirmedLaunch}
onchange={selectionChanged}
>
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</Select></Field
>
</div>
<section class="summary" aria-label="Server summary" aria-busy={checking}>
<strong
>{preview?.name
? serverTitle(preview.name)
: (maps.find((map) => map.slug === mapSlug)?.friendly ??
'Choose a map')}</strong
>
<p class="muted">Uses the current production setup when the server starts.</p>
<div class="readiness" aria-label="Launch readiness">
{#if checking}<span role="status">Checking availability…</span>
{:else}
<span class:ready={hostStatus.filesReady}
>{hostStatus.missingGames.length
? 'Game files missing'
: hostStatus.filesReady
? 'Game files ready'
: 'Game files preparing'}</span
>
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
<span>Automatic host &amp; port</span>
{/if}
</div>
</section>
{#if launchError}<div class="issue" role="alert">
<p>{launchError}</p>
{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener"
>View Provisioning ↗</a
>{/if}
</div>{/if}
{#if missingKey}<div class="issue" role="status">
<p>
No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.
</p>
<a href={`/server-keys?game=${game}`} target="_blank" rel="noopener"
>Manage keys ↗</a
>
</div>{/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 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 && hostStatus.hasHosts}<p class="issue" role="alert">
{view.error}
</p>{/if}
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div
class="issue"
role="status"
>
<p>Add a host to run your server.</p>
<a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a>
</div>{/if}
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button
class="text-button retry"
disabled={launching}
onclick={refreshChecks}>Retry checks</button
>{/if}
{#if open}<NativeHostSelection
games={[game]}
bind:selection
bind:ready={hostReady}
bind:status={hostStatus}
compact
network
{refreshVersion}
disabled={launching || unconfirmedLaunch}
onchange={selectionChanged}
/>{/if}
{#if preview}<details class="setup-details">
<summary>Setup details</summary>
<dl>
<div>
<dt>Server ID</dt>
<dd><code>{preview.name ?? 'No lobby available'}</code></dd>
</div>
<div>
<dt>Configuration</dt>
<dd>{preview.recipe.configSource}</dd>
</div>
<div>
<dt>Container image</dt>
<dd><code>{preview.recipe.image}</code></dd>
</div>
{#if hostStatus.uploadedAt}<div>
<dt>Game files uploaded</dt>
<dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd>
</div>{/if}
</dl>
<h3>{preview.recipe.files.length} managed files</h3>
<ul class="files">
{#each preview.recipe.files as file}<li>
<div>
<strong>{file.name}</strong><span
>{launchFileVersionLabel(file)} ·
{file.kind}</span
>
</div>
<code>{file.path}</code>
</li>{/each}
</ul>
</details>{/if}
{/if}
</div>
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
<div>
<Button variant="ghost" disabled={launching} onclick={() => (open = false)}
>Cancel</Button
><Button
variant="primary"
loading={launching}
disabled={!canLaunch}
onclick={launch}
>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button
>
</div>
</div>
{/snippet}
</Dialog>
<style>
.launch-content {
display: grid;
gap: 18px;
}
.fields {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 14px;
}
.summary {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-sunken);
}
.summary > strong {
font-size: var(--text-md);
line-height: 1.4;
}
p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.readiness {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.ready {
color: var(--state-online);
}
.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 {
border: 0;
padding: 0;
background: none;
text-align: left;
cursor: pointer;
}
.retry {
justify-self: start;
}
.setup-details {
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
min-width: 0;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
dl {
display: grid;
gap: 14px;
margin: 16px 0;
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
margin: 5px 0 0;
overflow-wrap: anywhere;
font-size: var(--text-sm);
line-height: 1.5;
}
code {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
h3 {
font-size: var(--text-sm);
margin: 18px 0 8px;
}
.files {
list-style: none;
padding: 0;
margin: 0;
}
.files li {
display: grid;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.files li > div {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.files strong {
font-size: var(--text-sm);
font-weight: 500;
}
.files span,
.files code {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.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: 440px) {
.fields {
grid-template-columns: 1fr;
}
.launch-actions {
align-items: stretch;
flex-direction: column;
}
.launch-actions > div {
justify-content: flex-end;
}
}
</style>