Simplify collection launch review
This commit is contained in:
+39
-18
@@ -14,7 +14,9 @@ when provisioning begins.
|
|||||||
|
|
||||||
For example, a collection targeting four Kino and two Der Riese lobbies, with
|
For example, a collection targeting four Kino and two Der Riese lobbies, with
|
||||||
two Kino already registered, creates two Kino and two Der Riese lobbies.
|
two Kino already registered, creates two Kino and two Der Riese lobbies.
|
||||||
The preview shows **Target / Existing or queued / To create** for each map.
|
The preview groups maps by game and shows **Target / Existing or queued /
|
||||||
|
Create** for each map, with **Remove** in exact mode. Existing totals in exact
|
||||||
|
mode include the listed removals, including those already awaiting cleanup.
|
||||||
Matching is by game and exact map variant; T6 Town and Farm are distinct even
|
Matching is by game and exact map variant; T6 Town and Farm are distinct even
|
||||||
though they share an engine map. Provisioning identities are counted once when
|
though they share an engine map. Provisioning identities are counted once when
|
||||||
they appear in both the server registry and active creation queue.
|
they appear in both the server registry and active creation queue.
|
||||||
@@ -30,15 +32,24 @@ It fills deficits without changing existing servers' files.
|
|||||||
1. Choose **Create collection** on **Fleet → Collections** and name the lineup.
|
1. Choose **Create collection** on **Fleet → Collections** and name the lineup.
|
||||||
2. Add maps from Black Ops I or II and set the desired total lobbies for each.
|
2. Add maps from Black Ops I or II and set the desired total lobbies for each.
|
||||||
3. Choose **Save & preview** (or **Preview fleet changes** on a saved collection).
|
3. Choose **Save & preview** (or **Preview fleet changes** on a saved collection).
|
||||||
Choose **Fill missing servers** or **Match collection exactly**, then review
|
The dialog prepares its preview automatically. Both **Fill missing**
|
||||||
the server IDs and estimated capacity. Native host ports are assigned later.
|
and **Match exactly** are visible; selecting a mode rechecks the
|
||||||
4. Launches covered by unused keys show **Game key available**. Each game’s
|
plan. Review the change summary and the map table grouped by game.
|
||||||
available keys are counted once across the full lineup. If more keys are
|
4. Check the required key coverage for each game. Available keys count once
|
||||||
needed, add them under **Administration → Keys**, then refresh the preview.
|
across the full lineup, and every new server needs a key. If a game is short,
|
||||||
Launch is enabled when every new server has an available game key.
|
open **Administration → Keys** to add keys or wait for assigned keys to be
|
||||||
5. Choose **Queue N servers** (or confirm removals and choose **Apply** in exact
|
released. Returning to the dialog rechecks the pools and fleet.
|
||||||
mode) and follow the collection progress page.
|
5. Review the capacity summary. Expand its details for CPU, memory, ports, and
|
||||||
If every target is already met, choose **Done**; no operation is created.
|
startup slots. Capacity estimates are advisory: queued launches can wait for
|
||||||
|
hosts or resources. Native host ports are assigned during provisioning.
|
||||||
|
6. Expand **Server details** to inspect server IDs, launch order, retained servers,
|
||||||
|
and existing-server health. **Advanced settings** holds the runtime override;
|
||||||
|
**Setup details** shows configuration, managed files, and game-file upload
|
||||||
|
information.
|
||||||
|
7. Use **Queue N servers** in the persistent footer, or review and confirm
|
||||||
|
the removal list before choosing **Create N · Remove M** when removals are
|
||||||
|
needed. Follow the collection progress page after submission. If no changes are needed,
|
||||||
|
choose **Done**; no operation is created.
|
||||||
|
|
||||||
### Match collection exactly
|
### Match collection exactly
|
||||||
|
|
||||||
@@ -49,9 +60,10 @@ variant. It removes excess lobbies and maps outside the collection. Retained
|
|||||||
servers keep their containers, files, ports, and configuration without a restart.
|
servers keep their containers, files, ports, and configuration without a restart.
|
||||||
The option is chosen per launch; opening another preview defaults to fill mode.
|
The option is chosen per launch; opening another preview defaults to fill mode.
|
||||||
|
|
||||||
Review the removal list and check its confirmation before applying. Removed
|
The removal list stays visible in exact mode. Review it and check its confirmation
|
||||||
servers lose their containers, files, and registrations; their keys return to the
|
before applying. Every refreshed preview clears that confirmation so it must be
|
||||||
game pool only after confirmed removal.
|
reviewed again. Removed servers lose their containers, files, and registrations;
|
||||||
|
their keys return to the game pool only after confirmed removal.
|
||||||
Unstarted creations are cancelled without booting a container. Running servers
|
Unstarted creations are cancelled without booting a container. Running servers
|
||||||
use the normal removal queue and require a fresh zero-player RCON response.
|
use the normal removal queue and require a fresh zero-player RCON response.
|
||||||
Occupied or unreachable servers wait. Already pending removals are reused.
|
Occupied or unreachable servers wait. Already pending removals are reused.
|
||||||
@@ -94,6 +106,11 @@ when preparing new launches.
|
|||||||
|
|
||||||
### Preview and submission
|
### Preview and submission
|
||||||
|
|
||||||
|
A preview runs automatically when the dialog opens, the mode or launch settings
|
||||||
|
change, the user returns from managing keys, or the current preview expires.
|
||||||
|
Refreshing also resets removal confirmation. The preview lasts 30 minutes;
|
||||||
|
checks and displayed counts describe the fleet when it was prepared.
|
||||||
|
|
||||||
A native collection preview reads a shared capacity snapshot, maintained from host
|
A native collection preview reads a shared capacity snapshot, maintained from host
|
||||||
heartbeats and fleet changes. It estimates available ports, CPU, memory, and startup
|
heartbeats and fleet changes. It estimates available ports, CPU, memory, and startup
|
||||||
slots without running placement for each server. Missing or insufficient capacity
|
slots without running placement for each server. Missing or insufficient capacity
|
||||||
@@ -107,11 +124,15 @@ no jobs or key reservations from that submission are committed. Legacy submissio
|
|||||||
also revalidate their reviewed ports. Refreshing the preview rechecks pool
|
also revalidate their reviewed ports. Refreshing the preview rechecks pool
|
||||||
availability and resets removal confirmation.
|
availability and resets removal confirmation.
|
||||||
|
|
||||||
Submitting the same preview again returns the same collection, including after
|
If a submission response is lost or cannot be confirmed, retry that same
|
||||||
a lost response or an application restart. Reusing the collection with a new
|
submission. The dialog keeps the reviewed preview while the outcome is uncertain
|
||||||
preview recomputes the shortfall, including servers queued by earlier launches.
|
instead of automatically replacing it. If the original request committed,
|
||||||
It does not add another full copy of the lineup. If the shortfall or reviewed
|
submitting the same preview again returns the same collection, including after
|
||||||
identities change before submission, the request is rejected without changes and
|
its original expiry or an application restart, without repeating operations.
|
||||||
|
|
||||||
|
Reusing the collection with a new preview recomputes the shortfall, including
|
||||||
|
servers queued by earlier launches. It does not add another full copy of the
|
||||||
|
lineup. If the shortfall or reviewed identities change before submission, the request is rejected without changes and
|
||||||
requires a fresh preview. Previously saved additive previews are checked against
|
requires a fresh preview. Previously saved additive previews are checked against
|
||||||
the current shortfall too.
|
the current shortfall too.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ managed files, upload time, and internal server ID are under **Setup details**.
|
|||||||
The footer keeps **Cancel** and **Launch server** available while reviewing the
|
The footer keeps **Cancel** and **Launch server** available while reviewing the
|
||||||
setup. Create and launch reusable lineups separately in **Fleet → Collections**.
|
setup. Create and launch reusable lineups separately in **Fleet → Collections**.
|
||||||
|
|
||||||
|
Collection launches prepare their fleet preview automatically and show fill and
|
||||||
|
exact modes together. Review changes and game key coverage, then use the
|
||||||
|
persistent footer to queue work. Capacity estimates are advisory; jobs can wait
|
||||||
|
for hosts and resources. Exact mode covers both games and requires confirmation
|
||||||
|
of its explicit removal list, which resets whenever the preview is refreshed.
|
||||||
|
See [Collections](collections.md) for the review details and safe submission
|
||||||
|
retries when a response is lost.
|
||||||
|
|
||||||
| 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. |
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { CollectionMode, CollectionPreview } from '../deployment-plan.js';
|
||||||
|
import type { ZombieGame } from '../map-aliases.js';
|
||||||
|
|
||||||
|
export interface CollectionLaunchInput {
|
||||||
|
blueprintId: string;
|
||||||
|
mode: CollectionMode;
|
||||||
|
libraryIds: Partial<Record<ZombieGame, string>>;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionLaunchState {
|
||||||
|
preview: CollectionPreview | null;
|
||||||
|
checking: boolean;
|
||||||
|
error: string;
|
||||||
|
changeNotice: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changesSince(previous: CollectionPreview | null, next: CollectionPreview): string {
|
||||||
|
if (!previous) return '';
|
||||||
|
const sorted = (values: unknown[]) => JSON.stringify(values.map(value => JSON.stringify(value)).sort());
|
||||||
|
const removals = (preview: CollectionPreview) => sorted(preview.removals.map(server => [server.name, server.reason]));
|
||||||
|
const creations = (preview: CollectionPreview) => sorted(preview.servers.map(server => {
|
||||||
|
const recipe = preview.entries[server.entryIndex]?.recipe;
|
||||||
|
return [server.name, recipe?.game, recipe?.mapSlug];
|
||||||
|
}));
|
||||||
|
const retained = (preview: CollectionPreview) => sorted([...new Set(preview.retainedServers)]);
|
||||||
|
if (removals(previous) !== removals(next)) {
|
||||||
|
return 'Servers to remove changed. Review the updated removal list before applying this collection.';
|
||||||
|
}
|
||||||
|
const createsChanged = creations(previous) !== creations(next);
|
||||||
|
const keepsChanged = retained(previous) !== retained(next);
|
||||||
|
if (createsChanged && keepsChanged) return 'Servers to create and keep changed. Review the updated fleet changes before applying.';
|
||||||
|
if (createsChanged) return 'Servers to create changed. Review the updated launch list before applying.';
|
||||||
|
if (keepsChanged) return 'Servers to keep changed. Review the updated fleet changes before applying.';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh collection plans without retaining a launchable preview while its inputs are changing. */
|
||||||
|
export function createCollectionLaunchController(
|
||||||
|
onChange: (state: CollectionLaunchState) => 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 context: string | null = null;
|
||||||
|
let previous: CollectionPreview | null = null;
|
||||||
|
let state: CollectionLaunchState = { preview: null, checking: false, error: '', changeNotice: '' };
|
||||||
|
let revision = 0;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let request: AbortController | undefined;
|
||||||
|
let suspended = false;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function publish(next: CollectionLaunchState) {
|
||||||
|
if (state.preview === next.preview && state.checking === next.checking
|
||||||
|
&& state.error === next.error && state.changeNotice === next.changeNotice) 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/deployment-collections/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 collection readiness. Try again.');
|
||||||
|
const preview = body as CollectionPreview;
|
||||||
|
const expiresAt = Date.parse(preview.expiresAt);
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new Error('The collection check expired. Try again.');
|
||||||
|
const changeNotice = changesSince(previous, preview);
|
||||||
|
previous = preview;
|
||||||
|
publish({ preview, checking: false, error: '', changeNotice });
|
||||||
|
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, changeNotice: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Could not check collection readiness. Try again.' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === revision) request = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (disposed || !selection || (request && !suspended)) return;
|
||||||
|
suspended = false;
|
||||||
|
cancel();
|
||||||
|
publish({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||||
|
void check(revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
cancel();
|
||||||
|
selection = null;
|
||||||
|
suspended = false;
|
||||||
|
publish({ preview: null, checking: false, error: '', changeNotice: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** A null selection pauses checks during host reloads while preserving the last plan for comparison. */
|
||||||
|
select(input: CollectionLaunchInput | null) {
|
||||||
|
if (disposed) return;
|
||||||
|
if (!input) { invalidate(); return; }
|
||||||
|
const nextContext = JSON.stringify([input.blueprintId, input.mode]);
|
||||||
|
const next = JSON.stringify({ blueprintId: input.blueprintId, mode: input.mode,
|
||||||
|
libraryIds: { t5: input.libraryIds.t5, t6: input.libraryIds.t6 }, image: input.image });
|
||||||
|
if (selection === next) return;
|
||||||
|
cancel();
|
||||||
|
if (context !== nextContext) previous = null;
|
||||||
|
context = nextContext;
|
||||||
|
selection = next;
|
||||||
|
suspended = false;
|
||||||
|
publish({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||||
|
const current = revision;
|
||||||
|
timer = setTimeout(() => void check(current), debounceMs);
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
/** Start a fresh dialog session without comparing it with any earlier plan. */
|
||||||
|
clear() {
|
||||||
|
if (disposed) return;
|
||||||
|
previous = null;
|
||||||
|
context = null;
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
/** Preserve the reviewed ID during submission; refresh() resumes after a definite rejection. */
|
||||||
|
suspend() {
|
||||||
|
if (disposed) return;
|
||||||
|
suspended = true;
|
||||||
|
cancel();
|
||||||
|
publish({ ...state, checking: false });
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
cancel();
|
||||||
|
selection = null;
|
||||||
|
previous = null;
|
||||||
|
context = null;
|
||||||
|
disposed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { CollectionPreview, LaunchAvailability } from '../deployment-plan.js';
|
||||||
|
import { parseServerName, selectedZombieMap, type ZombieGame } from '../map-aliases.js';
|
||||||
|
|
||||||
|
const gameLabels: Record<ZombieGame, string> = { t5: 'Black Ops I', t6: 'Black Ops II' };
|
||||||
|
|
||||||
|
export interface CollectionMapRow {
|
||||||
|
key: string;
|
||||||
|
mapName: string;
|
||||||
|
target: number;
|
||||||
|
existing: number;
|
||||||
|
create: number;
|
||||||
|
remove: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionMapGroup {
|
||||||
|
game: ZombieGame | null;
|
||||||
|
label: string;
|
||||||
|
rows: CollectionMapRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionKeyCoverage {
|
||||||
|
game: ZombieGame;
|
||||||
|
label: string;
|
||||||
|
required: number;
|
||||||
|
/** Reviewed creations covered by their game pool, not the pool's total size. */
|
||||||
|
available: number;
|
||||||
|
/** Creations without lobby identities have not been checked against the pool. */
|
||||||
|
unassigned: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep pool coverage separate for each game, including creations without a free lobby. */
|
||||||
|
export function collectionKeyCoverage(preview: CollectionPreview): CollectionKeyCoverage[] {
|
||||||
|
const coverage = new Map<ZombieGame, CollectionKeyCoverage>();
|
||||||
|
for (const server of preview.servers) {
|
||||||
|
const game = preview.entries[server.entryIndex].recipe.game;
|
||||||
|
const pool = coverage.get(game) ?? { game, label: gameLabels[game], required: 0, available: 0, unassigned: 0 };
|
||||||
|
pool.required++;
|
||||||
|
if (!server.name) pool.unassigned++;
|
||||||
|
else if (server.hasSavedKey) pool.available++;
|
||||||
|
coverage.set(game, pool);
|
||||||
|
}
|
||||||
|
return [...coverage.values()].sort((a, b) => a.game.localeCompare(b.game));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removalMap(name: string): { game: ZombieGame | null; key: string; mapName: string } {
|
||||||
|
const parsed = parseServerName(name);
|
||||||
|
const game = parsed?.game.toLowerCase();
|
||||||
|
const supportedGame = game === 't5' || game === 't6' ? game : null;
|
||||||
|
if (parsed && supportedGame && (!parsed.gameMode || parsed.gameMode === 'zombie')) {
|
||||||
|
// Resolve friendly legacy aliases without merging T6 variants that share an engine map.
|
||||||
|
const map = selectedZombieMap(supportedGame, parsed.mapCanonical, parsed.mapSlug);
|
||||||
|
if (map) return { game: supportedGame, key: `${supportedGame}:${map.slug}`, mapName: map.friendly };
|
||||||
|
}
|
||||||
|
// A raw identity is preferable to assigning an unknown server to the wrong map.
|
||||||
|
return { game: supportedGame, key: `${supportedGame ?? 'other'}:server:${name}`, mapName: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summarize affected inventory, including exact-mode removals outside the saved collection. */
|
||||||
|
export function collectionMapGroups(preview: CollectionPreview): CollectionMapGroup[] {
|
||||||
|
const groups = new Map<ZombieGame | null, CollectionMapGroup>();
|
||||||
|
const rows = new Map<string, CollectionMapRow>();
|
||||||
|
const existingNames = new Map<string, Set<string>>();
|
||||||
|
const serverRows = new Map<string, CollectionMapRow>();
|
||||||
|
const addRow = (game: ZombieGame | null, row: CollectionMapRow) => {
|
||||||
|
const group = groups.get(game) ?? { game, label: game ? gameLabels[game] : 'Other servers', rows: [] };
|
||||||
|
group.rows.push(row);
|
||||||
|
groups.set(game, group);
|
||||||
|
rows.set(row.key, row);
|
||||||
|
existingNames.set(row.key, new Set());
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of preview.entries) {
|
||||||
|
const { game, mapSlug, mapName } = entry.recipe;
|
||||||
|
const row = addRow(game, { key: `${game}:${mapSlug}`, mapName, target: entry.count,
|
||||||
|
existing: entry.existing, create: entry.missing, remove: 0 });
|
||||||
|
for (const name of entry.existingServers) {
|
||||||
|
existingNames.get(row.key)!.add(name);
|
||||||
|
// Backend coverage knows stored map selections even when a name is nonstandard.
|
||||||
|
serverRows.set(name, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.mode === 'exact') {
|
||||||
|
for (const { name } of preview.removals) {
|
||||||
|
const identity = removalMap(name);
|
||||||
|
const row = serverRows.get(name) ?? rows.get(identity.key) ?? addRow(identity.game, {
|
||||||
|
key: identity.key, mapName: identity.mapName, target: 0, existing: 0, create: 0, remove: 0,
|
||||||
|
});
|
||||||
|
row.remove++;
|
||||||
|
const counted = existingNames.get(row.key)!;
|
||||||
|
// Coverage excludes pending removals. Include those identities in displayed inventory
|
||||||
|
// while counting ordinary excess servers only once.
|
||||||
|
if (!counted.has(name)) { row.existing++; counted.add(name); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()].sort((a, b) => (a.game ?? 'z').localeCompare(b.game ?? 'z'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capacity remains an estimate: insufficient capacity can wait in the startup queue. */
|
||||||
|
export function collectionCapacitySummary(availability?: LaunchAvailability): string {
|
||||||
|
if (availability?.requested === 0) return 'No new server capacity needed.';
|
||||||
|
if (!availability || availability.estimatedAvailable === null) return 'Capacity estimate unavailable; launches can wait for capacity.';
|
||||||
|
if (availability.estimatedAvailable >= availability.requested) {
|
||||||
|
return availability.requested === 1 ? 'Estimated capacity for 1 server.' : `Estimated capacity for all ${availability.requested} servers.`;
|
||||||
|
}
|
||||||
|
return `Estimated capacity for ${availability.estimatedAvailable} of ${availability.requested} server${availability.requested === 1 ? '' : 's'}; launches can wait for capacity.`;
|
||||||
|
}
|
||||||
@@ -1,188 +1,339 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { onMount } 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 ServerIdentity from './ServerIdentity.svelte';
|
|
||||||
import NativeHostSelection from './NativeHostSelection.svelte';
|
import NativeHostSelection from './NativeHostSelection.svelte';
|
||||||
|
import { createCollectionLaunchController, type CollectionLaunchState } from '$ui/collection-launch.js';
|
||||||
|
import { collectionMapGroups, collectionKeyCoverage, collectionCapacitySummary } from '$ui/collection-summary.js';
|
||||||
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||||
import type { BlueprintSummary, CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
|
import type { BlueprintSummary, CollectionMode } from '$lib/deployment-plan.js';
|
||||||
|
|
||||||
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
||||||
let dialog: HTMLDialogElement | undefined = $state();
|
const id = $props.id();
|
||||||
let preview = $state<CollectionPreview | null>(null);
|
|
||||||
let mode = $state<CollectionMode>('fill');
|
let mode = $state<CollectionMode>('fill');
|
||||||
|
let modeInput = $state<HTMLInputElement>();
|
||||||
let games = $state<ZombieGame[]>([]);
|
let games = $state<ZombieGame[]>([]);
|
||||||
let collectionName = $state('Launch collection');
|
let collectionName = $state('Launch collection');
|
||||||
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||||
let hostReady = $state(false);
|
let hostReady = $state(false);
|
||||||
|
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
|
||||||
let confirmRemovals = $state(false);
|
let confirmRemovals = $state(false);
|
||||||
let busy = $state(false);
|
let view = $state<CollectionLaunchState>({ preview: null, checking: false, error: '', changeNotice: '' });
|
||||||
let error = $state('');
|
let settledId: string | undefined;
|
||||||
let wasOpen = false;
|
const controller = createCollectionLaunchController(state => {
|
||||||
let session = 0;
|
if (state.checking || state.preview?.id !== settledId) confirmRemovals = false;
|
||||||
let assignmentNotice = $state('');
|
settledId = state.preview?.id;
|
||||||
|
view = state;
|
||||||
|
});
|
||||||
|
let loaded = $state(false);
|
||||||
|
let loadingError = $state('');
|
||||||
|
let launchError = $state('');
|
||||||
|
let launching = $state(false);
|
||||||
|
let unconfirmedLaunch = $state(false);
|
||||||
|
let refreshVersion = $state(0);
|
||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
let wasOpen = false;
|
||||||
const keysReady = $derived(preview?.servers.filter(server => server.name && server.hasSavedKey).length ?? 0);
|
let activeBlueprint = '';
|
||||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
let visit = 0;
|
||||||
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
|
let initialization: AbortController | undefined;
|
||||||
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && server.hasSavedKey));
|
let submission: AbortController | undefined;
|
||||||
|
const preview = $derived(view.preview);
|
||||||
|
const mapGroups = $derived(preview ? collectionMapGroups(preview) : []);
|
||||||
|
const keyCoverage = $derived(preview ? collectionKeyCoverage(preview) : []);
|
||||||
|
const missingKeys = $derived(keyCoverage.filter(pool => pool.available < pool.required - pool.unassigned));
|
||||||
|
const hasChanges = $derived(!!preview && (preview.servers.length > 0 || preview.removals.length > 0));
|
||||||
|
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||||
|
const checking = $derived((!loaded && !loadingError) || (loaded && (hostStatus.loading || view.checking)));
|
||||||
|
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
|
||||||
|
const currentReady = $derived(!!preview && preview.mode === mode && hostReady && !hostStatus.loading && !view.checking && !expired);
|
||||||
|
const noChanges = $derived(currentReady && preview?.ready && !hasChanges);
|
||||||
|
const canLaunch = $derived(!launching && !!preview && (unconfirmedLaunch || (currentReady && preview.ready && hasChanges
|
||||||
|
&& preview.servers.every(server => server.name && server.hasSavedKey) && (!preview.removals.length || confirmRemovals))));
|
||||||
|
const footerStatus = $derived(launching ? 'Queueing changes…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
|
||||||
|
: loadingError ? 'Could not load collection.' : checking ? 'Checking fleet changes…' : noChanges ? 'Collection targets met'
|
||||||
|
: missingKeys.length ? 'More game keys needed.' : currentReady && preview?.ready && preview.removals.length && !confirmRemovals
|
||||||
|
? 'Confirm the listed removals.' : canLaunch ? 'Ready to queue' : 'Launch unavailable');
|
||||||
|
const actionLabel = $derived(unconfirmedLaunch ? 'Retry launch' : preview?.removals.length
|
||||||
|
? `Create ${preview.servers.length} · Remove ${preview.removals.length}`
|
||||||
|
: `Queue ${preview?.servers.length ?? 0} server${preview?.servers.length === 1 ? '' : 's'}`);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!dialog) return;
|
if (open && (!wasOpen || activeBlueprint !== blueprintId)) {
|
||||||
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; preview = null; assignmentNotice = ''; dialog.showModal(); void initialize(); }
|
wasOpen = true; activeBlueprint = blueprintId; void initialize();
|
||||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
} else if (!open && wasOpen) {
|
||||||
|
wasOpen = false; visit++; initialization?.abort(); controller.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
const input = open && loaded && hostReady && !hostStatus.loading ? {
|
||||||
|
blueprintId, mode, image: selection.image,
|
||||||
|
libraryIds: Object.fromEntries(games.map(game => [game, selection.libraryIds[game]]))
|
||||||
|
} : null;
|
||||||
|
if (!launching && !unconfirmedLaunch) controller.select(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const current = ++session; busy = true; error = ''; games = []; hostReady = false;
|
const current = ++visit;
|
||||||
hostSelection = { libraryIds: {}, image: '' };
|
initialization?.abort(); initialization = new AbortController();
|
||||||
|
const signal = initialization.signal;
|
||||||
|
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
|
||||||
|
mode = 'fill'; games = []; collectionName = 'Launch collection'; confirmRemovals = false;
|
||||||
|
selection = { libraryIds: {}, image: '' }; hostReady = false;
|
||||||
|
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/blueprints'); const body = await response.json();
|
const response = await fetch('/api/blueprints', { signal });
|
||||||
|
const body = await response.json();
|
||||||
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
||||||
if (current !== session) return;
|
if (current !== visit) return;
|
||||||
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
|
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
|
||||||
if (!blueprint) throw new Error('This collection is no longer available.');
|
if (!blueprint) throw new Error('This collection is no longer available.');
|
||||||
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
|
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
|
||||||
} catch (cause) { if (current === session) error = (cause as Error).message; }
|
loaded = true;
|
||||||
finally { if (current === session) busy = false; }
|
await tick();
|
||||||
|
if (current === visit && open) modeInput?.focus();
|
||||||
|
} catch (error) {
|
||||||
|
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load collection.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function review() {
|
function selectionChanged() { launchError = ''; confirmRemovals = false; }
|
||||||
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
|
function refreshChecks() {
|
||||||
const current = ++session;
|
if (!open || launching || unconfirmedLaunch) return;
|
||||||
const previous = preview;
|
if (!loaded) { void initialize(); return; }
|
||||||
busy = true; error = ''; assignmentNotice = ''; confirmRemovals = false;
|
launchError = ''; confirmRemovals = false;
|
||||||
try {
|
controller.select(null); refreshVersion++;
|
||||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
|
|
||||||
const body = await res.json();
|
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
|
||||||
if (current === session) {
|
|
||||||
const next = body as CollectionPreview;
|
|
||||||
const names = new Set(next.servers.map(server => server.name));
|
|
||||||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) assignmentNotice = 'Server assignments changed. Review the updated launch order before applying.';
|
|
||||||
preview = next; now = Date.now();
|
|
||||||
}
|
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
|
||||||
finally { if (current === session) busy = false; }
|
|
||||||
}
|
}
|
||||||
|
onMount(() => {
|
||||||
|
let returning: ReturnType<typeof setTimeout>;
|
||||||
|
const refreshOnReturn = () => {
|
||||||
|
clearTimeout(returning);
|
||||||
|
if (document.visibilityState !== 'hidden') returning = setTimeout(refreshChecks, 100);
|
||||||
|
};
|
||||||
|
window.addEventListener('focus', refreshOnReturn);
|
||||||
|
document.addEventListener('visibilitychange', refreshOnReturn);
|
||||||
|
const timer = setInterval(() => { now = Date.now(); }, 1000);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(returning); clearInterval(timer);
|
||||||
|
window.removeEventListener('focus', refreshOnReturn);
|
||||||
|
document.removeEventListener('visibilitychange', refreshOnReturn);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
|
||||||
|
|
||||||
async function launch() {
|
async function launch() {
|
||||||
if (!preview || !canLaunch || expired || busy) 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/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id }) });
|
const response = await fetch('/api/deployment-collections', { method: 'POST', signal: submission.signal,
|
||||||
const body = await res.json();
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: reviewed.id }) });
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
const body = await response.json();
|
||||||
|
if (current !== visit) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
unconfirmedLaunch = response.status >= 500;
|
||||||
|
launchError = body.error ?? 'Could not queue this collection.';
|
||||||
|
if (!unconfirmedLaunch) { controller.select(null); refreshVersion++; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.ok) throw new Error('The launch response could not be confirmed.');
|
||||||
open = false;
|
open = false;
|
||||||
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch {
|
||||||
finally { busy = false; }
|
if (current === visit) {
|
||||||
|
// The queue may already be committed. Keep its preview ID for an idempotent retry.
|
||||||
|
unconfirmedLaunch = true;
|
||||||
|
launchError = 'Could not confirm the launch. Retry the same request, or check collection launches.';
|
||||||
|
}
|
||||||
|
} finally { if (current === visit) launching = false; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
<Dialog bind:open title={collectionName} description="Launch collection" wide busy={launching}>
|
||||||
<div class="composer">
|
<div class="launch-content">
|
||||||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? collectionName}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
{#if loadingError}
|
||||||
<label class="mode">Apply collection
|
<p class="issue" role="alert">{loadingError}</p>
|
||||||
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; preview = null; confirmRemovals = false; }}>
|
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
|
||||||
<option value="fill">Fill missing servers</option>
|
{:else if !loaded}
|
||||||
<option value="exact">Match collection exactly</option>
|
<p class="muted" role="status">Loading collection…</p>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
|
||||||
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
|
|
||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
||||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and game key availability. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if assignmentNotice}<p role="status" class="muted">{assignmentNotice}</p>{/if}
|
<div class="mode-selection">
|
||||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch to recheck capacity and key availability.</p>{/if}
|
<fieldset disabled={launching || unconfirmedLaunch}>
|
||||||
<div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection already satisfied'}</strong>
|
<legend class="sr-only">Apply collection</legend>
|
||||||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
<label class:selected={mode === 'fill'}><input bind:this={modeInput} type="radio" name={`${id}-collection-mode`} value="fill" bind:group={mode} onchange={selectionChanged} />Fill missing</label>
|
||||||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
|
<label class:selected={mode === 'exact'}><input type="radio" name={`${id}-collection-mode`} value="exact" bind:group={mode} onchange={selectionChanged} />Match exactly</label>
|
||||||
{#if preview.mode === 'exact' && preview.retainedServers.length}<details class="coverage"><summary>Servers to keep ({preview.retainedServers.length})</summary><ul>{#each preview.retainedServers as name}<li><ServerIdentity {name} /><span>{name}</span></li>{/each}</ul></details>{/if}
|
</fieldset>
|
||||||
{#if preview.mode === 'fill' && preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
<p class="muted">{mode === 'exact' ? 'Match the entire fleet across both games. Excess lobbies and maps outside this collection will be removed.' : 'Create missing lobbies. All existing servers and their settings stay as they are.'}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if preview.availability && preview.servers.length}
|
|
||||||
<section class="availability" aria-label="Latest reported capacity">
|
<section class="change-summary" aria-label="Fleet changes" aria-busy={checking}>
|
||||||
<h3>Latest reported capacity</h3>
|
{#if preview}
|
||||||
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
|
<strong>{noChanges ? 'Collection targets met' : `${preview.servers.length} to create · ${preview.removals.length} to remove`}</strong>
|
||||||
<p class="muted">{preview.availability.estimatedAvailable === null ? 'An estimate is not available yet.' : `${preview.availability.estimatedAvailable} additional servers estimated to fit.`} {preview.availability.requested} requested.</p>
|
{#if mode === 'exact' && preview.retainedServers.length}<p class="muted">{preview.retainedServers.length} existing or queued servers kept.</p>{/if}
|
||||||
<dl>
|
<div class="readiness">
|
||||||
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
|
{#each keyCoverage as pool}<span class:ready={pool.available >= pool.required} class:warning={pool.available < pool.required}>{pool.game.toUpperCase()} keys: {pool.unassigned ? 'waiting for lobby availability' : `${pool.available} of ${pool.required} required available`}</span>{/each}
|
||||||
<div><dt>Memory available</dt><dd>{preview.availability.memoryBytesAvailable === null ? 'Unknown' : `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}</dd></div>
|
{#if preview.servers.length}<span class:ready={hostStatus.filesReady}>{hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>{/if}
|
||||||
<div><dt>Ports available</dt><dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd></div>
|
</div>
|
||||||
<div><dt>Startup slots</dt><dd>{preview.availability.startupSlots ?? 'Unknown'}{#if preview.availability.startupLimit !== null} / {preview.availability.startupLimit}{/if}</dd></div>
|
{#if preview.servers.length}<p class="muted">{collectionCapacitySummary(preview.availability)}</p>{/if}
|
||||||
</dl>
|
{#if noChanges}<p class="muted">{mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet meets or exceeds every target.'} Existing servers keep their current settings.</p>{/if}
|
||||||
<p class="caption">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'}{#if preview.availability.queued !== null} {preview.availability.queued} servers already queued.{/if} Provisioning checks capacity separately for each server before it starts.</p>
|
{:else}<p role="status">{checking ? 'Checking fleet changes…' : 'Resolve the issues below to check this collection.'}</p>{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
<div class="section-title"><h3>{preview.mode === 'exact' ? 'Review fleet changes' : preview.servers.length ? 'Your launch order' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
|
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/collections?view=active" target="_blank" rel="noopener">View collection launches ↗</a>{/if}</div>{/if}
|
||||||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
{#if view.changeNotice}<p class="issue" role="status">{view.changeNotice}</p>{/if}
|
||||||
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
|
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
|
||||||
{#if preview.servers.length || preview.removals.length}
|
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload {hostStatus.missingGames.map(game => game.toUpperCase()).join(' and ')} game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
|
||||||
{#if preview.servers.length}<p class="muted">Available keys are assigned automatically from each game’s pool. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
|
||||||
{#if preview.servers.some(server => !server.hasSavedKey)}<p class="muted" role="status">There are not enough unused keys for these launches. Add keys to the affected game pools in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
|
{:else if view.error}<p class="issue" role="alert">{view.error}</p>{/if}
|
||||||
{#if preview.removals.length && preview.servers.some(server => !server.hasSavedKey)}<p class="muted">Keys held by servers awaiting removal are still in use. To reuse those keys, remove the servers first and refresh this preview after cleanup finishes.</p>{/if}
|
{#if missingKeys.length}<div class="issue" role="status">
|
||||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
{#each missingKeys as pool}{@const shortfall = pool.required - pool.unassigned - pool.available}<p>{pool.game.toUpperCase()} needs {shortfall} more unused key{shortfall === 1 ? '' : 's'}. <a href={`/server-keys?game=${pool.game}`} target="_blank" rel="noopener">Manage keys ↗</a></p>{/each}
|
||||||
{#if preview.removals.length}
|
{#if preview?.removals.length}<p>Keys assigned to removals stay in use until cleanup finishes. Complete those removals first to reuse their keys.</p>{/if}
|
||||||
<section class="removals" aria-label="Servers to remove">
|
</div>{/if}
|
||||||
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
{#each preview?.blockers ?? [] as blocker}<p class="issue" role="alert">{blocker}</p>{/each}
|
||||||
<p class="muted">Containers, files, and registrations will be removed after a fresh zero-player check. Busy or unreachable servers wait. Unstarted creations are cancelled. Saved keys are kept. New servers reusing these ports wait for removal to finish.</p>
|
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKeys.length || preview?.blockers.length)}<button class="text-button" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
|
||||||
<ul>{#each preview.removals as server}<li><strong><ServerIdentity name={server.name} /></strong><span class="server-id">{server.name}</span><span class="muted">{server.reason}</span></li>{/each}</ul>
|
|
||||||
<label class="confirm-removals"><input type="checkbox" bind:checked={confirmRemovals} disabled={busy || preview.mode !== mode} />Remove the {preview.removals.length} listed servers and apply this collection.</label>
|
{#if preview}
|
||||||
</section>
|
<section class="lineup" aria-label="Collection map targets">
|
||||||
{/if}
|
{#each mapGroups as group}
|
||||||
{#if preview.servers.length}
|
<table>
|
||||||
<ol class="servers" aria-label="Server launch order">
|
<caption>{group.label}</caption>
|
||||||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
<thead><tr><th scope="col">Map</th><th scope="col">Target</th><th scope="col">Existing / queued</th><th scope="col">Create</th>{#if mode === 'exact'}<th scope="col">Remove</th>{/if}</tr></thead>
|
||||||
<li class="server-row">
|
<tbody>{#each group.rows as row}<tr><th scope="row">{row.mapName}</th><td>{row.target}</td><td>{row.existing}</td><td class:quantity={row.create > 0}>{row.create}</td>{#if mode === 'exact'}<td class:danger={row.remove > 0}>{row.remove}</td>{/if}</tr>{/each}</tbody>
|
||||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
</table>
|
||||||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
|
||||||
{#if server.name}
|
|
||||||
{#if server.hasSavedKey}<p class="key-status key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
|
||||||
{:else}<p class="key-status key-missing">No unused {preview.entries[server.entryIndex].recipe.game.toUpperCase()} key available</p>{/if}
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
<p class="muted">Stopped and unhealthy servers also count toward targets. See Server details for health.{#if mode === 'exact'} Existing totals include the listed removals.{/if}</p>
|
||||||
{/if}
|
</section>
|
||||||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Pool availability is checked again when you apply. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p></div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
|
||||||
</form>
|
{#if preview.removals.length}<section class="removals" aria-label="Servers to remove">
|
||||||
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
||||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
<p>Containers, files, and registrations will be removed. Occupied or unreachable servers wait for a fresh empty-player check; unstarted creations are cancelled.</p>
|
||||||
|
<ul>{#each preview.removals as server}<li><strong>{serverTitle(server.name)}</strong><code>{server.name}</code><span>{server.reason}</span></li>{/each}</ul>
|
||||||
|
<label class="confirmation"><input type="checkbox" bind:checked={confirmRemovals} disabled={launching || unconfirmedLaunch || !currentReady} />Remove the {preview.removals.length} listed servers and apply this collection.</label>
|
||||||
|
</section>{/if}
|
||||||
|
|
||||||
|
<details class="details">
|
||||||
|
<summary>Server details</summary>
|
||||||
|
<div class="detail-body">
|
||||||
|
{#if preview.servers.length}<h3>Launch order</h3><p class="muted">Hosts and ports are assigned when provisioning starts.</p>
|
||||||
|
<ol class="server-list">{#each preview.servers as server}<li><strong>{server.name ? serverTitle(server.name) : preview.entries[server.entryIndex].recipe.mapName}</strong><code>{server.name ?? 'No available lobby'}</code></li>{/each}</ol>
|
||||||
|
{/if}
|
||||||
|
{#if preview.entries.some(entry => entry.existing)}<h3>Existing and queued servers</h3>
|
||||||
|
<ul class="existing-list">{#each preview.entries as entry}{#each entry.coverage ?? [] as server}<li><a href={server.href} target="_blank" rel="noopener">{serverTitle(server.name)} ↗</a><span>{server.health === 'online' ? 'Healthy' : server.health}{#if mode === 'exact'} · {preview.retainedServers.includes(server.name) ? 'Keep' : 'Remove'}{/if}</span></li>{/each}{/each}</ul>
|
||||||
|
{/if}
|
||||||
|
{#if !preview.servers.length && !preview.entries.some(entry => entry.existing)}<p class="muted">No new or matching servers.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{#if preview.availability && preview.servers.length}<details class="details">
|
||||||
|
<summary>Capacity details</summary>
|
||||||
|
<div class="detail-body">
|
||||||
|
<dl class="capacity">
|
||||||
|
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
|
||||||
|
<div><dt>Memory available</dt><dd>{preview.availability.memoryBytesAvailable === null ? 'Unknown' : `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}</dd></div>
|
||||||
|
<div><dt>Ports available</dt><dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd></div>
|
||||||
|
<div><dt>Startup slots</dt><dd>{preview.availability.startupSlots ?? 'Unknown'}{#if preview.availability.startupLimit !== null} / {preview.availability.startupLimit}{/if}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{#each preview.warnings ?? [] as warning}<p class="muted">{warning}</p>{/each}
|
||||||
|
<p class="muted">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'} Capacity is checked for each server when it starts.</p>
|
||||||
|
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
|
||||||
|
</div>
|
||||||
|
</details>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open}<NativeHostSelection {games} bind:selection bind:ready={hostReady} bind:status={hostStatus}
|
||||||
|
compact {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
|
||||||
|
|
||||||
|
{#if preview}<details class="details">
|
||||||
|
<summary>Setup details</summary>
|
||||||
|
<div class="detail-body">
|
||||||
|
<p class="muted">New servers use the current production setup when provisioning starts. Existing servers keep their files and configuration.</p>
|
||||||
|
<dl><div><dt>Runtime image</dt><dd><code>{selection.image}</code></dd></div>
|
||||||
|
{#each games as game}{@const files = hostStatus.gameFiles?.[game]}
|
||||||
|
{#if files?.uploadedAt}<div><dt>{game.toUpperCase()} game files uploaded</dt><dd>{$timeFormat.dateTime(files.uploadedAt)}</dd></div>{/if}
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{#each preview.entries as entry}<details class="map-setup">
|
||||||
|
<summary>{entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</summary>
|
||||||
|
<div class="detail-body"><p>{entry.recipe.configSource}</p><h3>{entry.recipe.files.length} managed files</h3>
|
||||||
|
<ul class="file-list">{#each entry.recipe.files as file}<li><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span><code>{file.path}</code></li>{/each}</ul>
|
||||||
|
</div>
|
||||||
|
</details>{/each}
|
||||||
|
</div>
|
||||||
|
</details>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
{#snippet actions()}
|
||||||
|
<div class="launch-actions">
|
||||||
|
<p class:ready={canLaunch && !unconfirmedLaunch || noChanges} role="status">{footerStatus}</p>
|
||||||
|
<div>{#if noChanges}<Button variant="primary" onclick={() => open = false}>Done</Button>
|
||||||
|
{:else}<Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant={preview?.removals.length ? 'danger' : 'primary'} loading={launching} disabled={!canLaunch} onclick={launch}>{actionLabel}</Button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.availability { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
.launch-content { display: grid; gap: 16px; min-width: 0; }
|
||||||
.availability dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 16px; margin: 0; }
|
.mode-selection { display: grid; gap: 9px; }
|
||||||
.availability dt { font-size: var(--text-xs); color: var(--text-secondary); }.availability dd { margin: 6px 0 0; font-size: 14px; font-variant-numeric: tabular-nums; }
|
fieldset { display: flex; gap: 8px; border: 0; padding: 0; margin: 0; }
|
||||||
.warning { color: var(--state-warning); font-size: 13px; line-height: 1.6; }
|
fieldset label { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-control); border-radius: var(--radius-md); font-size: var(--text-sm); cursor: pointer; }
|
||||||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); }
|
||||||
.removals { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); }
|
fieldset input { margin: 0; }
|
||||||
.removals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
|
.change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||||
.removals li { display: grid; gap: 4px; font-size: 13px; }
|
.change-summary > strong { font-size: 17px; line-height: 1.4; }
|
||||||
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
.readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
.confirm-removals input { width: auto; margin-top: 2px; }
|
.readiness:empty { display: none; }
|
||||||
.key-status { font-size: 13px; font-weight: 600; }
|
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||||
.key-ready { color: var(--state-online); }
|
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
.key-missing { color: var(--state-warning); }
|
.ready, .quantity { color: var(--state-online); }
|
||||||
a { color: var(--accent-primary-hover); }
|
.warning { color: var(--state-warning); }
|
||||||
@media (max-width: 600px) { .server-row .key-status { grid-column: 2; } }
|
.danger { color: var(--state-danger); }
|
||||||
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
.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); }
|
||||||
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
|
||||||
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
.text-button { justify-self: start; border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
|
||||||
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
.lineup { display: grid; gap: 10px; min-width: 0; }
|
||||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: var(--text-xs); }
|
||||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
caption { text-align: left; color: var(--text-secondary); font-size: var(--text-xs); font-weight: 600; padding-bottom: 8px; }
|
||||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
th, td { padding: 8px 6px; text-align: center; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
th:first-child { width: 40%; text-align: left; }
|
||||||
.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
thead th { color: var(--text-secondary); font-weight: 500; }
|
||||||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
tbody th { font-weight: 500; }
|
||||||
|
td { font-variant-numeric: tabular-nums; }
|
||||||
|
.quantity, .danger { font-weight: 600; }
|
||||||
|
.details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
|
||||||
|
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||||
|
.detail-body { display: grid; gap: 12px; margin-top: 14px; min-width: 0; }
|
||||||
|
h3 { font-size: var(--text-sm); margin: 0; }
|
||||||
|
dl { display: grid; gap: 14px; margin: 0; }
|
||||||
|
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
||||||
|
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||||
|
.capacity { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.server-list { margin: 0; padding-left: 24px; }
|
||||||
|
.server-list li { padding: 8px 0; font-size: var(--text-sm); }
|
||||||
|
.server-list code { display: block; margin-top: 4px; color: var(--text-secondary); }
|
||||||
|
.existing-list, .file-list, .removals ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.existing-list li { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.existing-list span, .file-list span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.file-list li { display: grid; gap: 5px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||||
|
.map-setup { padding: 10px 0; border-top: 1px solid var(--border-subtle); }
|
||||||
|
.removals { display: grid; gap: 12px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); padding: 14px; }
|
||||||
|
.removals h3 { color: var(--state-danger); }
|
||||||
|
.removals p, .removals li span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.removals li { display: grid; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
|
||||||
|
.removals code { color: var(--text-secondary); }
|
||||||
|
.confirmation { display: flex; align-items: flex-start; gap: 9px; font-size: var(--text-sm); line-height: 1.5; }
|
||||||
|
.confirmation input { margin: 3px 0 0; flex-shrink: 0; }
|
||||||
|
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.launch-actions > p.ready { color: var(--state-online); }
|
||||||
|
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
|
||||||
|
@media (max-width: 600px) { .capacity { grid-template-columns: repeat(2, 1fr); } th:first-child { width: 34%; } th, td { padding: 8px 3px; } }
|
||||||
|
@media (max-width: 440px) { fieldset label { flex: 1; padding: 10px; font-size: var(--text-xs); } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
missingGames: games.filter(game => !latest[game]?.id),
|
missingGames: games.filter(game => !latest[game]?.id),
|
||||||
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
||||||
.filter((value): value is string => !!value).sort().at(-1) ?? null,
|
.filter((value): value is string => !!value).sort().at(-1) ?? null,
|
||||||
|
gameFiles: Object.fromEntries(games.map(game => [game, { ready: cacheReady(game),
|
||||||
|
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null }])),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface NativeHostSelectionStatus {
|
|||||||
filesReady: boolean;
|
filesReady: boolean;
|
||||||
missingGames: ZombieGame[];
|
missingGames: ZombieGame[];
|
||||||
uploadedAt: string | null;
|
uploadedAt: string | null;
|
||||||
|
gameFiles?: Partial<Record<ZombieGame, { ready: boolean; 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. */
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { CollectionPreview } from '../../src/lib/deployment-plan.js';
|
||||||
|
import { createCollectionLaunchController, type CollectionLaunchInput, type CollectionLaunchState } from '../../src/lib/ui/collection-launch.js';
|
||||||
|
|
||||||
|
const selection: CollectionLaunchInput = { blueprintId: 'collection-1', mode: 'exact', libraryIds: { t5: 'library-1' }, image: 'runtime:latest' };
|
||||||
|
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||||
|
const preview = (overrides: Partial<CollectionPreview> = {}): CollectionPreview => ({
|
||||||
|
id: 'preview-1', expiresAt: new Date(Date.now() + 60_000).toISOString(), blueprintName: 'Zombies',
|
||||||
|
mode: 'exact', launchConcurrency: 2, retainedServers: ['t5_zombie_theater_1'],
|
||||||
|
removals: [{ name: 't5_zombie_theater_3', reason: 'Exceeds the collection target.' }],
|
||||||
|
servers: [{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: true }], blockers: [], ready: true,
|
||||||
|
entries: [{ count: 2, existing: 1, missing: 1, existingServers: ['t5_zombie_theater_1'], coverage: [],
|
||||||
|
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' } }],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 createCollectionLaunchController>;
|
||||||
|
let state: CollectionLaunchState;
|
||||||
|
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||||
|
let changes: ReturnType<typeof vi.fn<(state: CollectionLaunchState) => void>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
|
||||||
|
state = { preview: null, checking: false, error: '', changeNotice: '' };
|
||||||
|
fetchPreview = vi.fn<typeof fetch>();
|
||||||
|
changes = vi.fn((next: CollectionLaunchState) => { state = next; });
|
||||||
|
controller = createCollectionLaunchController(changes, { fetch: fetchPreview });
|
||||||
|
});
|
||||||
|
afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function settle(value = preview(), input = selection) {
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(value));
|
||||||
|
controller.select(input);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('automatic collection launch checks', () => {
|
||||||
|
it('debounces selections and checks only the final collection mode and host inputs', async () => {
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({ mode: 'fill' })));
|
||||||
|
controller.select(selection);
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
controller.select({ ...selection, mode: 'fill', libraryIds: { t6: 'library-2', t5: 'library-1' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(199);
|
||||||
|
expect(fetchPreview).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPreview.mock.calls[0][0]).toBe('/api/deployment-collections/preview');
|
||||||
|
expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({
|
||||||
|
...selection, mode: 'fill', libraryIds: { t5: 'library-1', t6: 'library-2' },
|
||||||
|
});
|
||||||
|
expect(state.preview?.mode).toBe('fill');
|
||||||
|
expect(state.checking).toBe(false);
|
||||||
|
expect(state.changeNotice).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates a settled preview synchronously when the runtime changes', async () => {
|
||||||
|
await settle();
|
||||||
|
expect(state.preview).not.toBeNull();
|
||||||
|
controller.select({ ...selection, image: 'runtime:other' });
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates identical selections and active checks, and refreshes a debounce immediately', async () => {
|
||||||
|
const request = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(request.promise);
|
||||||
|
const libraries = { t5: 'library-1', t6: 'library-2' };
|
||||||
|
controller.select({ ...selection, libraryIds: libraries });
|
||||||
|
controller.refresh();
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
controller.select({ ...selection, libraryIds: { t6: 'library-2', t5: 'library-1' } });
|
||||||
|
controller.refresh();
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
request.resolve(response(preview()));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
controller.select({ ...selection, libraryIds: libraries });
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(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({ id: 'new', mode: 'fill' })));
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
const signal = fetchPreview.mock.calls[0][1]!.signal!;
|
||||||
|
controller.select({ ...selection, mode: 'fill' });
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
if (result === 'success') first.resolve(response(preview({ id: 'old' })));
|
||||||
|
else first.reject(new Error('Old request failed'));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('new');
|
||||||
|
expect(state.error).toBe('');
|
||||||
|
expect(state.changeNotice).toBe('');
|
||||||
|
expect(state.checking).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ name: 't5_zombie_theater_4', reason: 'Exceeds the collection target.' },
|
||||||
|
{ name: 't5_zombie_theater_3', reason: 'Map is outside this collection.' },
|
||||||
|
])('warns when a removal name or reason changes across a host refresh: %j', async (removal) => {
|
||||||
|
await settle();
|
||||||
|
controller.select(null);
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: '', changeNotice: '' });
|
||||||
|
await settle(preview({ id: 'refreshed', removals: [removal] }));
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||||
|
expect(state.preview?.id).toBe('refreshed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when creation identities change without changing the number of servers', async () => {
|
||||||
|
await settle();
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({
|
||||||
|
servers: [{ name: 't5_zombie_theater_4', port: null, entryIndex: 0, hasSavedKey: true }],
|
||||||
|
})));
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to create changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when retained identities change and gives removal changes priority', async () => {
|
||||||
|
await settle();
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({ retainedServers: ['t5_zombie_theater_5'] })));
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to keep changed/);
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report changed plans for reordering or changes in readiness alone', async () => {
|
||||||
|
const original = preview({
|
||||||
|
retainedServers: ['t5_zombie_theater_1', 't5_zombie_five_1'],
|
||||||
|
removals: [{ name: 't5_zombie_theater_3', reason: 'Excess.' }, { name: 't5_zombie_theater_4', reason: 'Excess.' }],
|
||||||
|
servers: [{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: false },
|
||||||
|
{ name: 't5_zombie_theater_5', port: null, entryIndex: 0, hasSavedKey: false }],
|
||||||
|
});
|
||||||
|
await settle(original);
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({
|
||||||
|
retainedServers: [...original.retainedServers].reverse(), removals: [...original.removals].reverse(),
|
||||||
|
servers: [...original.servers].reverse().map(server => ({ ...server, hasSavedKey: true })),
|
||||||
|
})));
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.changeNotice).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['blueprint', 'mode'] as const)('starts a fresh comparison when the %s changes', async (change) => {
|
||||||
|
await settle();
|
||||||
|
const input = change === 'mode' ? { ...selection, mode: 'fill' as const } : { ...selection, blueprintId: 'other' };
|
||||||
|
await settle(preview({ mode: input.mode, removals: [], retainedServers: [], servers: [] }), input);
|
||||||
|
expect(state.changeNotice).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears history and ignores a dismissed response when reopening the same collection', async () => {
|
||||||
|
await settle();
|
||||||
|
const dismissed = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(dismissed.promise);
|
||||||
|
controller.refresh();
|
||||||
|
controller.clear();
|
||||||
|
expect(fetchPreview.mock.calls[1][1]!.signal!.aborted).toBe(true);
|
||||||
|
await settle(preview({ id: 'reopened', removals: [], retainedServers: [], servers: [] }));
|
||||||
|
dismissed.resolve(response(preview({ id: 'dismissed' })));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('reopened');
|
||||||
|
expect(state.changeNotice).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps failed refreshes unlaunchable and compares a successful retry with the last settled plan', async () => {
|
||||||
|
await settle();
|
||||||
|
fetchPreview.mockResolvedValueOnce(response({ error: 'Host information unavailable.' }, 503));
|
||||||
|
controller.refresh();
|
||||||
|
expect(state.preview).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: 'Host information unavailable.', changeNotice: '' });
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
||||||
|
controller.refresh();
|
||||||
|
expect(state.error).toBe('');
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('automatically renews expired plans and compares the updated removal list', async () => {
|
||||||
|
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
||||||
|
const renewal = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(renewal.promise);
|
||||||
|
await vi.advanceTimersByTimeAsync(799);
|
||||||
|
expect(state.preview).not.toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||||
|
renewal.resolve(response(preview({ id: 'renewed', removals: [] })));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('renewed');
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired responses without repeatedly retrying them', async () => {
|
||||||
|
await settle(preview({ expiresAt: new Date(Date.now() + 100).toISOString() }));
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state).toEqual({ preview: null, checking: false, error: 'The collection check expired. Try again.', changeNotice: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the same preview ID while an apply is unconfirmed and resumes after definite rejection', async () => {
|
||||||
|
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
||||||
|
controller.suspend();
|
||||||
|
controller.select({ ...selection });
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(state.preview?.id).toBe('preview-1');
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
fetchPreview.mockResolvedValueOnce(response(preview({ id: 'retry', removals: [] })));
|
||||||
|
controller.refresh();
|
||||||
|
expect(state.preview).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(state.preview?.id).toBe('retry');
|
||||||
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels pending selections and prevents disposed responses from publishing', async () => {
|
||||||
|
controller.select(selection);
|
||||||
|
controller.select(null);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
expect(fetchPreview).not.toHaveBeenCalled();
|
||||||
|
const request = deferred<Response>();
|
||||||
|
fetchPreview.mockReturnValueOnce(request.promise);
|
||||||
|
controller.select(selection);
|
||||||
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
|
const count = changes.mock.calls.length;
|
||||||
|
controller.dispose();
|
||||||
|
request.resolve(response(preview()));
|
||||||
|
controller.select(selection);
|
||||||
|
controller.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(changes).toHaveBeenCalledTimes(count);
|
||||||
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { CollectionPreview, LaunchAvailability } from '../../src/lib/deployment-plan.js';
|
||||||
|
import { zombieMapBySlug, type ZombieGame } from '../../src/lib/map-aliases.js';
|
||||||
|
import { collectionCapacitySummary, collectionKeyCoverage, collectionMapGroups } from '../../src/lib/ui/collection-summary.js';
|
||||||
|
|
||||||
|
function entry(game: ZombieGame, mapSlug: string, count: number, existingServers: string[] = []): CollectionPreview['entries'][number] {
|
||||||
|
const map = zombieMapBySlug(game, mapSlug)!;
|
||||||
|
return { count, existing: existingServers.length, missing: Math.max(0, count - existingServers.length), existingServers,
|
||||||
|
coverage: existingServers.map(name => ({ name, health: 'stopped', href: `/servers/${name}` })),
|
||||||
|
recipe: { game, mapSlug, mapName: map.friendly, expectedMap: map.canonical, capturedAt: '', image: 'runtime:latest',
|
||||||
|
configFile: 'server.cfg', configSource: 'Current setup', files: [], fingerprint: '' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(overrides: Partial<CollectionPreview> = {}): CollectionPreview {
|
||||||
|
return { id: 'preview', expiresAt: '2030-01-01T00:00:00Z', blueprintName: 'Collection', launchConcurrency: 1,
|
||||||
|
mode: 'fill', retainedServers: [], removals: [], entries: [], servers: [], blockers: [], ready: true, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacity(overrides: Partial<LaunchAvailability> = {}): LaunchAvailability {
|
||||||
|
return { requested: 5, estimatedAvailable: null, updatedAt: null, portsAvailable: null, cpuCoresAvailable: null,
|
||||||
|
memoryBytesAvailable: null, startupSlots: null, startupLimit: null, queued: null, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collection key coverage', () => {
|
||||||
|
it('counts coverage separately by game without treating removals as available keys', () => {
|
||||||
|
const plan = preview({ mode: 'exact', entries: [entry('t5', 'theater', 2), entry('t6', 'town', 1)],
|
||||||
|
removals: [{ name: 't5_zombie_moon_1', reason: 'Outside collection' }], servers: [
|
||||||
|
{ entryIndex: 0, name: 't5_zombie_theater_1', hasSavedKey: true, port: null },
|
||||||
|
{ entryIndex: 0, name: 't5_zombie_theater_2', hasSavedKey: false, port: null },
|
||||||
|
{ entryIndex: 1, name: 't6_zombie_town_1', hasSavedKey: true, port: null },
|
||||||
|
] });
|
||||||
|
expect(collectionKeyCoverage(plan)).toEqual([
|
||||||
|
{ game: 't5', label: 'Black Ops I', required: 2, available: 1, unassigned: 0 },
|
||||||
|
{ game: 't6', label: 'Black Ops II', required: 1, available: 1, unassigned: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts an unavailable lobby as required but never covered, and omits games without creations', () => {
|
||||||
|
expect(collectionKeyCoverage(preview({ entries: [entry('t5', 'theater', 1), entry('t6', 'town', 1)],
|
||||||
|
servers: [{ entryIndex: 1, name: null, hasSavedKey: true, port: null }] }))).toEqual([
|
||||||
|
{ game: 't6', label: 'Black Ops II', required: 1, available: 0, unassigned: 1 },
|
||||||
|
]);
|
||||||
|
expect(collectionKeyCoverage(preview({ mode: 'exact', removals: [{ name: 't5_zombie_theater_1', reason: 'Remove' }] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes known key shortages from creations waiting for a lobby', () => {
|
||||||
|
const [pool] = collectionKeyCoverage(preview({ entries: [entry('t5', 'theater', 3)], servers: [
|
||||||
|
{ entryIndex: 0, name: 't5_zombie_theater_19', hasSavedKey: true, port: null },
|
||||||
|
{ entryIndex: 0, name: 't5_zombie_theater_20', hasSavedKey: false, port: null },
|
||||||
|
{ entryIndex: 0, name: null, hasSavedKey: false, port: null },
|
||||||
|
] }));
|
||||||
|
expect(pool).toMatchObject({ required: 3, available: 1, unassigned: 1 });
|
||||||
|
expect(pool.required - pool.unassigned - pool.available).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collection map summaries', () => {
|
||||||
|
it('preserves full existing counts in fill mode, including stopped inventory and excess lobbies', () => {
|
||||||
|
const plan = preview({ entries: [entry('t5', 'theater', 1, ['t5_zombie_theater_1', 't5_zombie_theater_2']), entry('t5', 'moon', 2)] });
|
||||||
|
expect(collectionMapGroups(plan)).toEqual([{ game: 't5', label: 'Black Ops I', rows: [
|
||||||
|
{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 2, create: 0, remove: 0 },
|
||||||
|
{ key: 't5:moon', mapName: 'Moon', target: 2, existing: 0, create: 2, remove: 0 },
|
||||||
|
] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts ordinary excess once and adds already-pending removals missing from backend coverage', () => {
|
||||||
|
const plan = preview({ mode: 'exact', entries: [entry('t5', 'theater', 1, ['t5_zombie_theater_1', 't5_zombie_theater_2'])],
|
||||||
|
retainedServers: ['t5_zombie_theater_1'], removals: [
|
||||||
|
{ name: 't5_zombie_theater_2', reason: 'Excess' },
|
||||||
|
{ name: 't5_zombie_theater_3', reason: 'Already awaiting removal' },
|
||||||
|
] });
|
||||||
|
expect(collectionMapGroups(plan)[0].rows).toEqual([
|
||||||
|
{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 3, create: 0, remove: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows outside maps with target zero across both games without combining T6 variants', () => {
|
||||||
|
const plan = preview({ mode: 'exact', entries: [entry('t6', 'town', 1, ['t6_zombie_town_1'])],
|
||||||
|
retainedServers: ['t6_zombie_town_1'], removals: [
|
||||||
|
{ name: 't5_zombie_moon_1', reason: 'Outside collection' },
|
||||||
|
{ name: 't6_zombie_farm_1', reason: 'Outside collection' },
|
||||||
|
{ name: 't6_zombie_town_grief_1', reason: 'Outside collection' },
|
||||||
|
] });
|
||||||
|
expect(collectionMapGroups(plan)).toEqual([
|
||||||
|
{ game: 't5', label: 'Black Ops I', rows: [{ key: 't5:moon', mapName: 'Moon', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||||
|
{ game: 't6', label: 'Black Ops II', rows: [
|
||||||
|
{ key: 't6:town', mapName: 'Town Survival', target: 1, existing: 1, create: 0, remove: 0 },
|
||||||
|
{ key: 't6:farm', mapName: 'Farm Survival', target: 0, existing: 1, create: 0, remove: 1 },
|
||||||
|
{ key: 't6:towngrief', mapName: 'Town Grief', target: 0, existing: 1, create: 0, remove: 1 },
|
||||||
|
] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses authoritative coverage before names and resolves friendly legacy aliases', () => {
|
||||||
|
const plan = preview({ mode: 'exact', entries: [entry('t6', 'town', 1, ['custom-town', 't6_zombie_town_1']), entry('t5', 'theater', 1, ['t5_zombie_theater_1'])],
|
||||||
|
removals: [{ name: 'custom-town', reason: 'Excess' }, { name: 'T5_ZOMBIE_KINO_DER_TOTEN_2', reason: 'Awaiting removal' }] });
|
||||||
|
expect(collectionMapGroups(plan).map(group => group.rows)).toEqual([
|
||||||
|
[{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 2, create: 0, remove: 1 }],
|
||||||
|
[{ key: 't6:town', mapName: 'Town Survival', target: 1, existing: 2, create: 0, remove: 1 }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unknown removals visible without guessing a shared engine-map variant', () => {
|
||||||
|
const plan = preview({ mode: 'exact', removals: [
|
||||||
|
{ name: 't6_zombie_zm_transit_1', reason: 'Unknown variant' },
|
||||||
|
{ name: 'custom-instance', reason: 'Outside collection' },
|
||||||
|
] });
|
||||||
|
expect(collectionMapGroups(plan)).toEqual([
|
||||||
|
{ game: 't6', label: 'Black Ops II', rows: [{ key: 't6:server:t6_zombie_zm_transit_1', mapName: 't6_zombie_zm_transit_1', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||||
|
{ game: null, label: 'Other servers', rows: [{ key: 'other:server:custom-instance', mapName: 'custom-instance', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collection capacity summary', () => {
|
||||||
|
it('keeps unknown and insufficient capacity queueable', () => {
|
||||||
|
expect(collectionCapacitySummary()).toBe('Capacity estimate unavailable; launches can wait for capacity.');
|
||||||
|
expect(collectionCapacitySummary(capacity())).toBe('Capacity estimate unavailable; launches can wait for capacity.');
|
||||||
|
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 0 }))).toBe('Estimated capacity for 0 of 5 servers; launches can wait for capacity.');
|
||||||
|
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 2 }))).toBe('Estimated capacity for 2 of 5 servers; launches can wait for capacity.');
|
||||||
|
expect(collectionCapacitySummary(capacity({ requested: 1, estimatedAvailable: 0 }))).toBe('Estimated capacity for 0 of 1 server; launches can wait for capacity.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses requested counts when enough capacity exists and handles plans without creations', () => {
|
||||||
|
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 20 }))).toBe('Estimated capacity for all 5 servers.');
|
||||||
|
expect(collectionCapacitySummary(capacity({ requested: 1, estimatedAvailable: 1 }))).toBe('Estimated capacity for 1 server.');
|
||||||
|
expect(collectionCapacitySummary(capacity({ requested: 0 }))).toBe('No new server capacity needed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user