Simplify single-server launch flow
This commit is contained in:
+32
-23
@@ -139,22 +139,28 @@ Every server links to its Provisioning operation timeline and recovery options,
|
|||||||
and each child operation links back to its collection launch. Individual operations
|
and each child operation links back to its collection launch. Individual operations
|
||||||
remain visible in Provisioning activity and history.
|
remain visible in Provisioning activity and history.
|
||||||
|
|
||||||
## Create and review a single-server launch
|
## Create a single server
|
||||||
|
|
||||||
1. Choose **Single server** on Provisioning (or the deploy action on the fleet).
|
1. Open **Create server** from the fleet or **Single server** from Provisioning.
|
||||||
2. Choose the current promoted setup and select a game/map, or select a collection.
|
2. Choose a game and map. The dialog automatically checks the current promoted
|
||||||
3. Choose **Preview launch**. Review the image reference, configuration sources,
|
setup, latest game files, available lobby, and game key pool as selections change.
|
||||||
managed files and versions, selected map, lobby, port, and prerequisite checks.
|
3. Review the readiness summary and resolve any displayed blockers. When no game
|
||||||
4. The launch automatically uses an available key from the game pool. If none is
|
key is available, open **Administration → Keys** and add one or wait for a key
|
||||||
available, manage the pool under **Administration → Keys**, then refresh the
|
to be released. Returning to the launch dialog rechecks availability; **Retry checks**
|
||||||
preview.
|
also runs the checks again.
|
||||||
5. Choose **Launch server** and follow the operation to verified health.
|
4. If needed, expand **Advanced settings** for runtime and network overrides.
|
||||||
|
Expand **Setup details** to inspect the image, configuration, managed files,
|
||||||
|
game-file upload time, and internal server ID.
|
||||||
|
5. Choose **Launch server** in the persistent footer and follow the operation to
|
||||||
|
verified health. **Cancel** closes the dialog without creating a server.
|
||||||
|
|
||||||
A preview lasts 30 minutes. It reserves neither capacity nor a key. If
|
The single-server dialog uses the current promoted setup. Create and launch
|
||||||
another launch takes the lobby, submission stops before reserving a key;
|
collections separately from **Fleet → Collections**.
|
||||||
refresh the preview and review the newly assigned ID. Pool availability is checked again at
|
|
||||||
submission, and keys are reserved atomically so concurrent launches cannot share
|
Automatic checks reserve neither capacity nor a key. The reviewed preview lasts
|
||||||
a credential.
|
30 minutes. If another launch takes the lobby or key, submission stops; retry the
|
||||||
|
checks before launching. Pool availability is checked again at submission, and
|
||||||
|
keys are reserved atomically so concurrent launches cannot share a credential.
|
||||||
|
|
||||||
Host selection, game-file versions, runtime configuration and lobby availability
|
Host selection, game-file versions, runtime configuration and lobby availability
|
||||||
are checked before queueing. Provisioning assigns host capacity and physical
|
are checked before queueing. Provisioning assigns host capacity and physical
|
||||||
@@ -165,9 +171,11 @@ verify the engine map, not the variant's start location.
|
|||||||
|
|
||||||
## Save and reuse a collection
|
## Save and reuse a collection
|
||||||
|
|
||||||
Expand **Make this a reusable collection** in a launch preview and give it a name
|
Choose **Create collection** under **Fleet → Collections**, give it a name and
|
||||||
and optional description. You can save the map even when host configuration or
|
optional description, and add maps with their desired lobby counts. A collection
|
||||||
available capacity blocks launching. Names are unique without regard to case.
|
can contain a single map with a target of one lobby. You can save it even when
|
||||||
|
host configuration or available capacity blocks launching. Names are unique
|
||||||
|
without regard to case.
|
||||||
|
|
||||||
Each map in a collection retains the game, exact map variant, and desired lobby
|
Each map in a collection retains the game, exact map variant, and desired lobby
|
||||||
count. Image references, managed files, and composed configuration are resolved
|
count. Image references, managed files, and composed configuration are resolved
|
||||||
@@ -178,11 +186,12 @@ Use **Conform fleet** on a collection card to review missing servers. Remove a
|
|||||||
collection from the library when it is no longer needed; its existing servers,
|
collection from the library when it is no longer needed; its existing servers,
|
||||||
reviewed previews, and deployment history remain intact.
|
reviewed previews, and deployment history remain intact.
|
||||||
|
|
||||||
On a creation detail page, **Launch again** selects the original game and map
|
On a creation detail page, **Launch another server** opens the single-server
|
||||||
with the next available lobby and port, using the latest promoted setup. The
|
dialog with the original game and map selected. You can change either selection
|
||||||
preview also lets you save that map as a collection with a target of one lobby.
|
before launching. Checks use the current promoted setup and latest game files,
|
||||||
The recorded files remain visible as deployment history. Older deployments
|
and provisioning assigns a fresh lobby and port. The recorded files remain
|
||||||
without a recorded recipe retain their history but cannot offer this action.
|
visible as deployment history. Older deployments without a recorded recipe
|
||||||
|
retain their history but cannot offer this action.
|
||||||
|
|
||||||
## Promotions and deployment history
|
## Promotions and deployment history
|
||||||
|
|
||||||
@@ -190,7 +199,7 @@ A preview shows the currently promoted managed files, configuration, and image
|
|||||||
reference for the selected map. It does not pin those versions. New launches
|
reference for the selected map. It does not pin those versions. New launches
|
||||||
resolve the promoted setup again when provisioning begins, so
|
resolve the promoted setup again when provisioning begins, so
|
||||||
promotions made after a preview or while a job is queued apply to that server.
|
promotions made after a preview or while a job is queued apply to that server.
|
||||||
This applies to collections, single-server launches, and **Launch again**.
|
This applies to collections, single-server launches, and **Launch another server**.
|
||||||
|
|
||||||
Previews and queued jobs store file metadata without copying script or plugin
|
Previews and queued jobs store file metadata without copying script or plugin
|
||||||
contents for every lobby. Provisioning captures the bytes when each job starts.
|
contents for every lobby. Provisioning captures the bytes when each job starts.
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ base game files; each instance has its own writable data. See
|
|||||||
## Launch your first server
|
## Launch your first server
|
||||||
|
|
||||||
1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**.
|
1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**.
|
||||||
2. Create a server, or build a reusable map lineup in **Fleet → Collections**.
|
2. Open **Create server** and choose a game and map. Readiness checks run
|
||||||
3. Review the launch and apply it. Provisioning queues the work, then assigns a
|
automatically. For a reusable lineup, use **Fleet → Collections** instead.
|
||||||
host and port when capacity is available.
|
3. Resolve any launch blockers and choose **Launch server**. Provisioning queues
|
||||||
|
the work, then assigns a host and port when capacity is available.
|
||||||
4. Follow the operation until readiness checks pass, then verify that players
|
4. Follow the operation until readiness checks pass, then verify that players
|
||||||
can find and join the server.
|
can find and join the server.
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ the agent owns local execution and its durable operation journal.
|
|||||||
progress and errors. Wait for verification before launching. Hosts report
|
progress and errors. Wait for verification before launching. Hosts report
|
||||||
connection and readiness separately; deployment also checks the particular
|
connection and readiness separately; deployment also checks the particular
|
||||||
game files, image, capacity and ports required for the launch.
|
game files, image, capacity and ports required for the launch.
|
||||||
6. Launch a server or collection. The latest game files and default runtime
|
6. Open **Create server**, choose a game and map, and review the automatic
|
||||||
are selected automatically. An advanced runtime override is
|
readiness checks. Launch collections separately from **Fleet → Collections**.
|
||||||
available for images an admin has already prepared on the destination host.
|
The latest game files and default runtime are selected automatically.
|
||||||
If the latest files change after previewing, refresh the preview
|
**Advanced settings** in the single-server dialog includes a runtime override
|
||||||
before launching.
|
for images an admin has already prepared on the destination host, along with
|
||||||
Apply saves the requested lobby IDs. Provisioning selects a host and atomically
|
optional network overrides. If the latest files change after the checks run,
|
||||||
|
let the dialog recheck the setup before launching; **Retry checks** also rechecks it.
|
||||||
|
Launching saves the requested lobby IDs. Provisioning selects a host and atomically
|
||||||
reserves UDP/TCP ports when capacity is available.
|
reserves UDP/TCP ports when capacity is available.
|
||||||
An available Plutonium key from the game pool is reserved for each launch.
|
An available Plutonium key from the game pool is reserved for each launch.
|
||||||
Its assignment survives stops and rebuilds and ends after confirmed removal.
|
Its assignment survives stops and rebuilds and ends after confirmed removal.
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ Provisioning owns the queues, safety checks, execution, and
|
|||||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||||
server, choose **Manage server** or open its **Server actions** menu.
|
server, choose **Manage server** or open its **Server actions** menu.
|
||||||
|
|
||||||
|
**Create server** opens one dialog with game and map choices followed by automatic
|
||||||
|
readiness checks. Blockers explain what needs attention before launch. Runtime
|
||||||
|
and network overrides are under **Advanced settings**; image, configuration,
|
||||||
|
managed files, upload time, and internal server ID are under **Setup details**.
|
||||||
|
The footer keeps **Cancel** and **Launch server** available while reviewing the
|
||||||
|
setup. Create and launch reusable lineups separately in **Fleet → Collections**.
|
||||||
|
|
||||||
| Action | Behavior |
|
| Action | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. |
|
| Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. |
|
||||||
@@ -164,8 +171,9 @@ Create and rebuild capture a setup when queued and select the latest promoted
|
|||||||
files again when creation begins. The page distinguishes **Setup captured when
|
files again when creation begins. The page distinguishes **Setup captured when
|
||||||
queued** from **Files selected for this attempt**. A queued capture does not pin
|
queued** from **Files selected for this attempt**. A queued capture does not pin
|
||||||
versions; an attempted selection is not proof that every file was installed.
|
versions; an attempted selection is not proof that every file was installed.
|
||||||
**Launch another server** uses the latest promoted setup with a fresh identity and
|
**Launch another server** opens the creation dialog with the recorded game and
|
||||||
port, rather than repairing or replaying the recorded job.
|
map as editable starting values. It checks the latest promoted setup and creates
|
||||||
|
a separate server with a fresh identity and port.
|
||||||
|
|
||||||
### History and refresh behavior
|
### History and refresh behavior
|
||||||
|
|
||||||
|
|||||||
@@ -1,198 +1,249 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { onMount, tick } from 'svelte';
|
|
||||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import Dialog from './primitives/Dialog.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
|
||||||
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
|
|
||||||
import NativeHostSelection from './NativeHostSelection.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 type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
|
||||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||||
|
|
||||||
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
|
let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
|
||||||
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
|
{ open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
|
||||||
let dialog: HTMLDialogElement | undefined = $state();
|
|
||||||
let game = $state<ZombieGame>('t5');
|
let game = $state<ZombieGame>('t5');
|
||||||
let mapSlug = $state('');
|
let mapSlug = $state('');
|
||||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||||
let blueprints = $state<BlueprintSummary[]>([]);
|
let gameSelect = $state<HTMLSelectElement>();
|
||||||
let blueprintId = $state('');
|
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||||
let collectionOpen = $state(false);
|
|
||||||
let maps = $derived(catalogs[game]);
|
|
||||||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
|
||||||
let preview = $state<LaunchPreview | null>(null);
|
|
||||||
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
|
||||||
let hostReady = $state(false);
|
let hostReady = $state(false);
|
||||||
let busy = $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 loaded = $state(false);
|
||||||
let error = $state('');
|
let loadingError = $state('');
|
||||||
let blueprintName = $state('');
|
let launchError = $state('');
|
||||||
let description = $state('');
|
let launching = $state(false);
|
||||||
let savedName = $state('');
|
let unconfirmedLaunch = $state(false);
|
||||||
let saving = $state(false);
|
let refreshVersion = $state(0);
|
||||||
let wasOpen = false;
|
|
||||||
let session = 0;
|
|
||||||
let lobbyNotice = $state('');
|
|
||||||
let selectionNotice = $state('');
|
|
||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
let heading: HTMLHeadingElement;
|
let wasOpen = false;
|
||||||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
let visit = 0;
|
||||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
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(() => {
|
$effect(() => {
|
||||||
if (!dialog) return;
|
if (open && !wasOpen) { wasOpen = true; void initialize(); }
|
||||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
|
else if (!open && wasOpen) {
|
||||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
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() {
|
async function initialize() {
|
||||||
const current = ++session;
|
const current = ++visit;
|
||||||
preview = null; error = ''; loaded = false; busy = false; lobbyNotice = ''; selectionNotice = '';
|
initialization?.abort(); initialization = new AbortController();
|
||||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
const signal = initialization.signal;
|
||||||
blueprintId = initialBlueprintId;
|
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
|
||||||
game = initialGame;
|
game = initialGame; mapSlug = initialMapSlug;
|
||||||
mapSlug = initialMapSlug;
|
selection = { libraryIds: {}, image: '' }; hostReady = false;
|
||||||
hostSelection = { libraryIds: {}, image: '' }; hostReady = false;
|
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
|
||||||
try {
|
try {
|
||||||
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
|
const response = await fetch('/api/maps', { signal });
|
||||||
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
|
if (!response.ok) throw new Error('Could not load maps. Try again.');
|
||||||
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
|
const body = await response.json();
|
||||||
if (current !== session) return;
|
if (current !== visit) return;
|
||||||
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
|
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||||
blueprints = blueprintBody.blueprints;
|
|
||||||
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
|
|
||||||
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
|
|
||||||
if (sourceDeploymentId) {
|
if (sourceDeploymentId) {
|
||||||
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
|
const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
|
||||||
if (current !== session) return;
|
const source = await sourceResponse.json();
|
||||||
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
|
if (current !== visit) return;
|
||||||
|
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|
||||||
|
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
|
||||||
|
throw new Error('The original launch map is unavailable.');
|
||||||
|
}
|
||||||
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
||||||
}
|
}
|
||||||
|
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
|
||||||
loaded = true;
|
loaded = true;
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
await tick();
|
||||||
}
|
if (current === visit && open) gameSelect?.focus();
|
||||||
|
} catch (error) {
|
||||||
function selectionChanged() {
|
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
|
||||||
if (preview) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.';
|
|
||||||
preview = null; lobbyNotice = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function review() {
|
|
||||||
if (busy) return;
|
|
||||||
if (!sourceDeploymentId && selectedBlueprint) {
|
|
||||||
open = false; collectionOpen = true; return;
|
|
||||||
}
|
}
|
||||||
if (!hostReady) { error = 'Wait for a host to finish preparing the latest game files and runtime.'; return; }
|
|
||||||
const current = session;
|
|
||||||
const previousName = preview?.name;
|
|
||||||
busy = true; error = ''; savedName = ''; lobbyNotice = ''; selectionNotice = '';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
|
|
||||||
libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
|
|
||||||
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
|
||||||
if (current === session) {
|
|
||||||
if (previousName && previousName !== body.name) lobbyNotice = 'The available lobby changed. Review the updated server before launching.';
|
|
||||||
preview = body; now = Date.now();
|
|
||||||
await tick(); heading?.focus();
|
|
||||||
}
|
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
|
||||||
finally { if (current === session) busy = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function launch() {
|
||||||
if (!preview || !preview.ready || !preview.hasSavedKey || !hostReady || expired || busy || saving) return;
|
if (!canLaunch || !preview) return;
|
||||||
busy = true; error = '';
|
const current = visit;
|
||||||
|
const reviewed = preview;
|
||||||
|
controller.suspend(); launching = true; launchError = '';
|
||||||
|
submission = new AbortController();
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
|
||||||
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||||
expectedName: preview.name, previewId: preview.id }) });
|
game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
|
||||||
const body = await res.json();
|
expectedName: reviewed.name, previewId: reviewed.id
|
||||||
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
|
}) });
|
||||||
|
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;
|
open = false;
|
||||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch (error) {
|
||||||
finally { busy = false; }
|
if (current === visit) {
|
||||||
}
|
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
|
||||||
|
unconfirmedLaunch = true;
|
||||||
async function save() {
|
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
|
||||||
if (!preview || saving) return;
|
}
|
||||||
const current = session;
|
} finally { if (current === visit) launching = false; }
|
||||||
saving = true; error = '';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
|
|
||||||
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
|
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
|
||||||
finally { if (current === session) saving = false; }
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
<Dialog bind:open title="Create server" busy={launching}>
|
||||||
<div class="composer">
|
<div class="launch-content">
|
||||||
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
|
{#if loadingError}
|
||||||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
<p class="issue" role="alert">{loadingError}</p>
|
||||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
|
||||||
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
|
{:else if !loaded}
|
||||||
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
|
<p class="muted" role="status">Loading maps…</p>
|
||||||
{#if !loaded}
|
|
||||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
|
|
||||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
|
||||||
{:else if preview}
|
|
||||||
{#if lobbyNotice}<p role="status" class="muted">{lobbyNotice}</p>{/if}
|
|
||||||
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity and key availability.</p>{/if}
|
|
||||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
|
|
||||||
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
|
||||||
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
|
||||||
</section>
|
|
||||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
|
||||||
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>An available key from this game’s pool is assigned when you launch.</p></div>
|
|
||||||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
|
||||||
{:else}<p class="muted" role="status">No unused {preview.recipe.game.toUpperCase()} keys are available. Add a key in <a href={`/server-keys?game=${preview.recipe.game}`} target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
|
|
||||||
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || !preview.hasSavedKey}>Launch server</Button></div>
|
|
||||||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity and key availability are checked again at launch. The host and port are assigned automatically.</p>
|
|
||||||
</form>
|
|
||||||
<details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
|
||||||
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
|
|
||||||
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Collection name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save collection</Button></form>
|
|
||||||
{/if}
|
|
||||||
</details>
|
|
||||||
{:else if sourceDeploymentId}
|
|
||||||
<p class="muted">Launch the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={event => { event.preventDefault(); void review(); }}>
|
<div class="fields">
|
||||||
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
|
<label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
|
||||||
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and game key availability together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
|
||||||
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
|
<option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
|
||||||
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
|
</select></label>
|
||||||
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
|
<label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
|
||||||
</form>
|
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||||
|
</select></label>
|
||||||
|
</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 & 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>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
|
||||||
|
</details>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
{#snippet actions()}
|
||||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
<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 launch' : 'Launch server'}</Button></div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.key-ready { color: var(--state-online); font-weight: 600; }
|
.launch-content { display: grid; gap: 18px; }
|
||||||
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
|
.fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
|
||||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
select { width: 100%; }
|
||||||
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
|
||||||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
.summary > strong { font-size: 17px; line-height: 1.4; }
|
||||||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||||
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
.ready { color: var(--state-online); }
|
||||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
.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); }
|
||||||
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
|
||||||
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
|
.text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
|
||||||
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); }
|
.retry { justify-self: start; }
|
||||||
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
|
.setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
|
||||||
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
import type { GameLibrary } from '$lib/game-files.js';
|
import type { GameLibrary } from '$lib/game-files.js';
|
||||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import { latestGameFiles, selectLatestGameFiles } from '$ui/native-game-files.js';
|
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
|
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
|
||||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
|
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
|
||||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||||
let defaultRuntimeImage = $state('');
|
let defaultRuntimeImage = $state('');
|
||||||
let runtimeSelection: NativeDeploymentSelection | null = null;
|
let runtimeSelection: NativeDeploymentSelection | null = null;
|
||||||
|
let requestVersion = 0;
|
||||||
|
let activeController: AbortController | undefined;
|
||||||
const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image)));
|
const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image)));
|
||||||
const latest = $derived(latestGameFiles(libraries));
|
const latest = $derived(latestGameFiles(libraries));
|
||||||
|
|
||||||
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); }
|
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); }
|
||||||
$effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image)
|
$effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image)
|
||||||
&& games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); });
|
&& games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); });
|
||||||
|
$effect(() => {
|
||||||
|
status = {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasHosts: hosts.length > 0,
|
||||||
|
filesReady: games.length > 0 && games.every(cacheReady),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
|
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
|
||||||
if (!selection.image) selection.image = defaultRuntimeImage;
|
if (!selection.image) selection.image = defaultRuntimeImage;
|
||||||
@@ -28,17 +40,62 @@
|
|||||||
if (loading || error || disabled) return;
|
if (loading || error || disabled) return;
|
||||||
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
|
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
|
||||||
});
|
});
|
||||||
async function load() {
|
async function load(signal?: AbortSignal) {
|
||||||
|
const version = ++requestVersion;
|
||||||
|
activeController?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
const abort = () => controller.abort();
|
||||||
|
if (signal?.aborted) controller.abort();
|
||||||
|
else signal?.addEventListener('abort', abort, { once: true });
|
||||||
try {
|
try {
|
||||||
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
|
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts', { signal: controller.signal }), fetch('/api/game-files', { signal: controller.signal })]);
|
||||||
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
|
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
|
||||||
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
|
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]);
|
||||||
|
if (version !== requestVersion || controller.signal.aborted) return;
|
||||||
|
hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
|
||||||
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
||||||
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
} catch (cause) {
|
||||||
|
if (version === requestVersion && !controller.signal.aborted) error = (cause as Error).message;
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
if (version === requestVersion && !controller.signal.aborted) loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onMount(() => pollVisible(load, 5000));
|
$effect(() => {
|
||||||
|
// Restart the poll for a parent refresh, sharing one initial request.
|
||||||
|
refreshVersion;
|
||||||
|
loading = true;
|
||||||
|
const stop = pollVisible(load, 5000);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
requestVersion++;
|
||||||
|
activeController?.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet runtimeFields()}
|
||||||
|
<label>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input required bind:value={selection.image} disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||||
|
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet networkFields()}
|
||||||
|
<label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||||
|
<label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||||
|
<label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||||
|
<p>Public mapping does not change the local Docker port reservation.</p>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if compact}
|
||||||
|
<details class="advanced">
|
||||||
|
<summary>Advanced settings</summary>
|
||||||
|
<div class="advanced-fields">
|
||||||
|
{@render runtimeFields()}
|
||||||
|
{#if network}<fieldset><legend>Network overrides</legend>{@render networkFields()}</fieldset>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
<section class="selection" aria-label="Deployment host and game files">
|
<section class="selection" aria-label="Deployment host and game files">
|
||||||
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
|
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
|
||||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||||
@@ -57,11 +114,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}{/if}
|
{/each}{/if}
|
||||||
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
|
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
|
||||||
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
|
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
|
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
|
||||||
|
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
|
||||||
|
.advanced summary { color:var(--text-secondary); }
|
||||||
|
.advanced-fields { padding-top:2px; }
|
||||||
|
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
|
||||||
|
legend { padding:0; font-size:13px; font-weight:600; }
|
||||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
|
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
|
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
|
||||||
|
import type { ZombieGame } from '../map-aliases.js';
|
||||||
|
|
||||||
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
|
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
|
||||||
|
|
||||||
|
export interface NativeHostSelectionStatus {
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
hasHosts: boolean;
|
||||||
|
filesReady: boolean;
|
||||||
|
missingGames: ZombieGame[];
|
||||||
|
uploadedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Use the controller's latest upload, even while that upload is still syncing to a host. */
|
/** Use the controller's latest upload, even while that upload is still syncing to a host. */
|
||||||
export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles {
|
export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles {
|
||||||
const latest: LatestGameFiles = {};
|
const latest: LatestGameFiles = {};
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { LaunchPreview } from '../deployment-plan.js';
|
||||||
|
import type { ZombieGame } from '../map-aliases.js';
|
||||||
|
|
||||||
|
export interface ServerLaunchInput {
|
||||||
|
game: ZombieGame;
|
||||||
|
mapSlug: string;
|
||||||
|
libraryId?: string;
|
||||||
|
image: string;
|
||||||
|
bindAddress?: string;
|
||||||
|
publicAddress?: string;
|
||||||
|
publicPort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerLaunchState {
|
||||||
|
preview: LaunchPreview | null;
|
||||||
|
checking: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only a current preview available while launch selections and prerequisites change. */
|
||||||
|
export function createServerLaunchController(
|
||||||
|
onChange: (state: ServerLaunchState) => void,
|
||||||
|
dependencies: { fetch?: typeof fetch; debounceMs?: number } = {},
|
||||||
|
) {
|
||||||
|
const fetchPreview = dependencies.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
||||||
|
const debounceMs = dependencies.debounceMs ?? 200;
|
||||||
|
let selection: string | null = null;
|
||||||
|
let state: ServerLaunchState = { preview: null, checking: false, error: '' };
|
||||||
|
let revision = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let request: AbortController | undefined;
|
||||||
|
let suspended = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function publish(next: ServerLaunchState) {
|
||||||
|
if (state.preview === next.preview && state.checking === next.checking && state.error === next.error) return;
|
||||||
|
state = next;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
revision++;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
request?.abort();
|
||||||
|
request = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check(current: number) {
|
||||||
|
if (disposed || suspended || current !== revision || !selection) return;
|
||||||
|
timer = undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
request = controller;
|
||||||
|
try {
|
||||||
|
const response = await fetchPreview('/api/provisioning/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: selection,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
if (disposed || suspended || current !== revision) return;
|
||||||
|
if (!response.ok) throw new Error(body.error ?? 'Could not check launch readiness. Try again.');
|
||||||
|
const preview = body as LaunchPreview;
|
||||||
|
const expiresAt = new Date(preview.expiresAt).getTime();
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
||||||
|
throw new Error('The launch check expired. Try again.');
|
||||||
|
}
|
||||||
|
publish({ preview, checking: false, error: '' });
|
||||||
|
if (!disposed && !suspended && current === revision) {
|
||||||
|
timer = setTimeout(refresh, Math.min(expiresAt - Date.now(), 2_147_483_647));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed && !suspended && current === revision) {
|
||||||
|
publish({ preview: null, checking: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Could not check launch readiness. Try again.' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === revision) request = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (disposed || !selection || (state.checking && !suspended)) return;
|
||||||
|
suspended = false;
|
||||||
|
cancel();
|
||||||
|
publish({ preview: null, checking: true, error: '' });
|
||||||
|
void check(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (disposed) return;
|
||||||
|
cancel();
|
||||||
|
selection = null;
|
||||||
|
suspended = false;
|
||||||
|
publish({ preview: null, checking: false, error: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select(input: ServerLaunchInput | null) {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!input) { clear(); return; }
|
||||||
|
// Explicit fields keep identity stable and exclude unrelated launch or collection metadata.
|
||||||
|
const next = JSON.stringify({ game: input.game, mapSlug: input.mapSlug, libraryId: input.libraryId,
|
||||||
|
image: input.image, bindAddress: input.bindAddress || undefined,
|
||||||
|
publicAddress: input.publicAddress || undefined, publicPort: input.publicPort });
|
||||||
|
if (selection === next) return;
|
||||||
|
cancel();
|
||||||
|
selection = next;
|
||||||
|
suspended = false;
|
||||||
|
publish({ preview: null, checking: true, error: '' });
|
||||||
|
const current = revision;
|
||||||
|
timer = setTimeout(() => void check(current), debounceMs);
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
clear,
|
||||||
|
/** Freeze a settled preview during submission. refresh() resumes checks after a failed launch. */
|
||||||
|
suspend() {
|
||||||
|
if (disposed) return;
|
||||||
|
suspended = true;
|
||||||
|
cancel();
|
||||||
|
publish({ ...state, checking: false });
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
cancel();
|
||||||
|
selection = null;
|
||||||
|
disposed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { LaunchPreview } from '../../src/lib/deployment-plan.js';
|
||||||
|
import { createServerLaunchController, type ServerLaunchInput, type ServerLaunchState } from '../../src/lib/ui/server-launch.js';
|
||||||
|
|
||||||
|
const selection: ServerLaunchInput = { game: 't5', mapSlug: 'kino', libraryId: 'library-1', image: 'runtime:latest' };
|
||||||
|
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||||
|
const preview = (id = 'preview-1', expiresIn = 60_000): LaunchPreview => ({
|
||||||
|
id, expiresAt: new Date(Date.now() + expiresIn).toISOString(), name: 't5_zombie_theater_1',
|
||||||
|
port: null, hasSavedKey: true, blueprintName: null, checks: [], ready: true,
|
||||||
|
recipe: { game: 't5', mapSlug: 'kino', mapName: 'Kino Der Toten', expectedMap: 'zombie_theater',
|
||||||
|
capturedAt: new Date().toISOString(), image: 'runtime:latest', configFile: 'server.cfg',
|
||||||
|
configSource: 'T5 Zombies', files: [], fingerprint: 'recipe-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller: ReturnType<typeof createServerLaunchController>;
|
||||||
|
let state: ServerLaunchState;
|
||||||
|
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||||
|
let changes: ReturnType<typeof vi.fn<(state: ServerLaunchState) => void>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
|
||||||
|
state = { preview: null, checking: false, error: '' };
|
||||||
|
fetchPreview = vi.fn<typeof fetch>();
|
||||||
|
changes = vi.fn((next: ServerLaunchState) => { state = next; });
|
||||||
|
controller = createServerLaunchController(changes, { fetch: fetchPreview });
|
||||||
|
});
|
||||||
|
afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
describe('automatic server launch checks', () => {
|
||||||
|
it('debounces rapid selections and sends only the final game, map, and native overrides', async () => {
|
||||||
|
fetchPreview.mockResolvedValue(response(preview()));
|
||||||
|
controller.select(selection);
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '' });
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
controller.select({ ...selection, mapSlug: 'five', publicPort: 30100 });
|
||||||
|
await vi.advanceTimersByTimeAsync(199);
|
||||||
|
expect(fetchPreview).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPreview.mock.calls[0][0]).toBe('/api/provisioning/preview');
|
||||||
|
expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({ ...selection, mapSlug: 'five', publicPort: 30100 });
|
||||||
|
expect(state.preview?.id).toBe('preview-1');
|
||||||
|
expect(state.checking).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates a settled preview synchronously when the selection changes', async () => {
|
||||||
|
fetchPreview.mockResolvedValue(response(preview()));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(state.preview).not.toBeNull();
|
||||||
|
controller.select({ ...selection, image: 'runtime:other' });
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate requests for the same selection or overlapping refreshes', async () => {
|
||||||
|
const request = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValue(request.promise);
|
||||||
|
controller.select(selection);
|
||||||
|
controller.select({ ...selection });
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
controller.refresh();
|
||||||
|
controller.refresh();
|
||||||
|
controller.select({ ...selection, publicAddress: '' });
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
request.resolve(response(preview()));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
controller.select({ ...selection });
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.preview?.id).toBe('preview-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['success', 'error'] as const)('aborts changed requests and ignores stale %s responses', async (result) => {
|
||||||
|
const first = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('new-preview')));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
const signal = fetchPreview.mock.calls[0][1]!.signal!;
|
||||||
|
controller.select({ ...selection, mapSlug: 'five' });
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
if (result === 'success') first.resolve(response(preview('old-preview')));
|
||||||
|
else first.reject(new Error('Old network error'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('new-preview');
|
||||||
|
expect(state.error).toBe('');
|
||||||
|
expect(state.checking).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a response from a dismissed session after reopening the same selection', async () => {
|
||||||
|
const first = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('reopened-preview')));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
controller.clear();
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: '' });
|
||||||
|
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
first.resolve(response(preview('dismissed-preview')));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('reopened-preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending debounce when prerequisite data becomes unavailable', async () => {
|
||||||
|
controller.select(selection);
|
||||||
|
controller.select(null);
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(fetchPreview).not.toHaveBeenCalled();
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes immediately and never retains an old preview if the refresh fails', async () => {
|
||||||
|
const refresh = deferred<Response>();
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview())).mockReturnValueOnce(refresh.promise);
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
controller.refresh();
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '' });
|
||||||
|
refresh.resolve(response({ error: 'No runtime is available.' }, 409));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: 'No runtime is available.' });
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview('retry-preview')));
|
||||||
|
controller.refresh();
|
||||||
|
expect(state.error).toBe('');
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('retry-preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically renews an expired preview and removes it while checking', async () => {
|
||||||
|
const renewal = deferred<Response>();
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview('initial', 1000))).mockReturnValueOnce(renewal.promise);
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(999);
|
||||||
|
expect(state.preview?.id).toBe('initial');
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '' });
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(2);
|
||||||
|
renewal.resolve(response(preview('renewed')));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('renewed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already expired response without entering a refresh loop', async () => {
|
||||||
|
fetchPreview.mockResolvedValue(response(preview('expired', 100)));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: 'The launch check expired. Try again.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes a preview during submission and resumes checks after a failed launch', async () => {
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview('submitted', 1000)));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
controller.suspend();
|
||||||
|
controller.select({ ...selection });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(state.preview?.id).toBe('submitted');
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview('retry')));
|
||||||
|
controller.refresh();
|
||||||
|
expect(state.preview).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores in-flight responses and future input after disposal', async () => {
|
||||||
|
const request = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(request.promise);
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
const previousChanges = changes.mock.calls.length;
|
||||||
|
controller.dispose();
|
||||||
|
request.resolve(response(preview()));
|
||||||
|
controller.select({ ...selection, mapSlug: 'five' });
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(changes).toHaveBeenCalledTimes(previousChanges);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user