951 lines
36 KiB
Svelte
951 lines
36 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import Dialog from './primitives/Dialog.svelte';
|
|
import Button from './primitives/Button.svelte';
|
|
import NativeHostSelection from './NativeHostSelection.svelte';
|
|
import {
|
|
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 { launchFileVersionLabel } from '$lib/artifact-version.js';
|
|
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
|
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
|
import type { ZombieGame } from '$lib/map-aliases.js';
|
|
import type { BlueprintSummary, CollectionMode } from '$lib/deployment-plan.js';
|
|
|
|
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } =
|
|
$props();
|
|
const id = $props.id();
|
|
let mode = $state<CollectionMode>('fill');
|
|
let modeInput = $state<HTMLInputElement>();
|
|
let games = $state<ZombieGame[]>([]);
|
|
let collectionName = $state('Launch collection');
|
|
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
|
let hostReady = $state(false);
|
|
let hostStatus = $state<NativeHostSelectionStatus>({
|
|
loading: true,
|
|
error: '',
|
|
hasHosts: false,
|
|
filesReady: false,
|
|
missingGames: [],
|
|
uploadedAt: null
|
|
});
|
|
let confirmRemovals = $state(false);
|
|
let view = $state<CollectionLaunchState>({
|
|
preview: null,
|
|
checking: false,
|
|
error: '',
|
|
changeNotice: ''
|
|
});
|
|
let settledId: string | undefined;
|
|
const controller = createCollectionLaunchController((state) => {
|
|
if (state.checking || state.preview?.id !== settledId) confirmRemovals = false;
|
|
settledId = state.preview?.id;
|
|
view = state;
|
|
});
|
|
let loaded = $state(false);
|
|
let loadingError = $state('');
|
|
let launchError = $state('');
|
|
let launching = $state(false);
|
|
let unconfirmedLaunch = $state(false);
|
|
let refreshVersion = $state(0);
|
|
let now = $state(Date.now());
|
|
let wasOpen = false;
|
|
let activeBlueprint = '';
|
|
let visit = 0;
|
|
let initialization: AbortController | undefined;
|
|
let submission: AbortController | undefined;
|
|
const preview = $derived(view.preview);
|
|
const mapGroups = $derived(preview ? collectionMapGroups(preview) : []);
|
|
const keyCoverage = $derived(preview ? collectionKeyCoverage(preview) : []);
|
|
const missingKeys = $derived(
|
|
keyCoverage.filter((pool) => pool.available < pool.required - pool.unassigned)
|
|
);
|
|
const hasChanges = $derived(
|
|
!!preview && (preview.servers.length > 0 || preview.removals.length > 0)
|
|
);
|
|
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
|
const checking = $derived(
|
|
(!loaded && !loadingError) || (loaded && (hostStatus.loading || view.checking))
|
|
);
|
|
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
|
|
const currentReady = $derived(
|
|
!!preview &&
|
|
preview.mode === mode &&
|
|
hostReady &&
|
|
!hostStatus.loading &&
|
|
!view.checking &&
|
|
!expired
|
|
);
|
|
const noChanges = $derived(currentReady && preview?.ready && !hasChanges);
|
|
const canLaunch = $derived(
|
|
!launching &&
|
|
!!preview &&
|
|
(unconfirmedLaunch ||
|
|
(currentReady &&
|
|
preview.ready &&
|
|
hasChanges &&
|
|
preview.servers.every((server) => server.name && server.hasSavedKey) &&
|
|
(!preview.removals.length || confirmRemovals)))
|
|
);
|
|
const footerStatus = $derived(
|
|
launching
|
|
? 'Queueing changes…'
|
|
: unconfirmedLaunch
|
|
? 'Retry to confirm this launch.'
|
|
: loadingError
|
|
? 'Could not load collection.'
|
|
: checking
|
|
? 'Checking fleet changes…'
|
|
: noChanges
|
|
? 'Collection targets met'
|
|
: missingKeys.length
|
|
? 'More game keys needed.'
|
|
: currentReady &&
|
|
preview?.ready &&
|
|
preview.removals.length &&
|
|
!confirmRemovals
|
|
? 'Confirm the listed removals.'
|
|
: canLaunch
|
|
? 'Ready to queue'
|
|
: 'Launch unavailable'
|
|
);
|
|
const actionLabel = $derived(
|
|
unconfirmedLaunch
|
|
? 'Retry launch'
|
|
: preview?.removals.length
|
|
? `Create ${preview.servers.length} · Remove ${preview.removals.length}`
|
|
: `Queue ${preview?.servers.length ?? 0} server${preview?.servers.length === 1 ? '' : 's'}`
|
|
);
|
|
|
|
$effect(() => {
|
|
if (open && (!wasOpen || activeBlueprint !== blueprintId)) {
|
|
wasOpen = true;
|
|
activeBlueprint = blueprintId;
|
|
void initialize();
|
|
} else if (!open && wasOpen) {
|
|
wasOpen = false;
|
|
visit++;
|
|
initialization?.abort();
|
|
controller.clear();
|
|
}
|
|
});
|
|
$effect(() => {
|
|
const input =
|
|
open && loaded && hostReady && !hostStatus.loading
|
|
? {
|
|
blueprintId,
|
|
mode,
|
|
image: selection.image,
|
|
libraryIds: Object.fromEntries(
|
|
games.map((game) => [game, selection.libraryIds[game]])
|
|
)
|
|
}
|
|
: null;
|
|
if (!launching && !unconfirmedLaunch) controller.select(input);
|
|
});
|
|
|
|
async function initialize() {
|
|
const current = ++visit;
|
|
initialization?.abort();
|
|
initialization = new AbortController();
|
|
const signal = initialization.signal;
|
|
controller.clear();
|
|
loaded = false;
|
|
loadingError = '';
|
|
launchError = '';
|
|
launching = false;
|
|
unconfirmedLaunch = false;
|
|
mode = 'fill';
|
|
games = [];
|
|
collectionName = 'Launch collection';
|
|
confirmRemovals = false;
|
|
selection = { libraryIds: {}, image: '' };
|
|
hostReady = false;
|
|
hostStatus = {
|
|
loading: true,
|
|
error: '',
|
|
hasHosts: false,
|
|
filesReady: false,
|
|
missingGames: [],
|
|
uploadedAt: null
|
|
};
|
|
try {
|
|
const response = await fetch('/api/blueprints', { signal });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
|
if (current !== visit) return;
|
|
const blueprint = (body.blueprints as BlueprintSummary[]).find(
|
|
(item) => item.id === blueprintId
|
|
);
|
|
if (!blueprint) throw new Error('This collection is no longer available.');
|
|
games = [...new Set(blueprint.entries.map((entry) => entry.game))];
|
|
collectionName = blueprint.name;
|
|
loaded = true;
|
|
await tick();
|
|
if (current === visit && open) modeInput?.focus();
|
|
} catch (error) {
|
|
if (current === visit && !signal.aborted)
|
|
loadingError =
|
|
error instanceof Error ? error.message : 'Could not load collection.';
|
|
}
|
|
}
|
|
|
|
function selectionChanged() {
|
|
launchError = '';
|
|
confirmRemovals = false;
|
|
}
|
|
function refreshChecks() {
|
|
if (!open || launching || unconfirmedLaunch) return;
|
|
if (!loaded) {
|
|
void initialize();
|
|
return;
|
|
}
|
|
launchError = '';
|
|
confirmRemovals = false;
|
|
controller.select(null);
|
|
refreshVersion++;
|
|
}
|
|
onMount(() => {
|
|
let returning: ReturnType<typeof setTimeout>;
|
|
const refreshOnReturn = () => {
|
|
clearTimeout(returning);
|
|
if (document.visibilityState !== 'hidden') returning = setTimeout(refreshChecks, 100);
|
|
};
|
|
window.addEventListener('focus', refreshOnReturn);
|
|
document.addEventListener('visibilitychange', refreshOnReturn);
|
|
const timer = setInterval(() => {
|
|
now = Date.now();
|
|
}, 1000);
|
|
return () => {
|
|
clearTimeout(returning);
|
|
clearInterval(timer);
|
|
window.removeEventListener('focus', refreshOnReturn);
|
|
document.removeEventListener('visibilitychange', refreshOnReturn);
|
|
};
|
|
});
|
|
onDestroy(() => {
|
|
visit++;
|
|
initialization?.abort();
|
|
submission?.abort();
|
|
controller.dispose();
|
|
});
|
|
|
|
async function launch() {
|
|
if (!canLaunch || !preview) return;
|
|
const current = visit;
|
|
const reviewed = preview;
|
|
controller.suspend();
|
|
launching = true;
|
|
launchError = '';
|
|
submission = new AbortController();
|
|
try {
|
|
const response = await fetch('/api/deployment-collections', {
|
|
method: 'POST',
|
|
signal: submission.signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ previewId: reviewed.id })
|
|
});
|
|
const body = await response.json();
|
|
if (current !== visit) return;
|
|
if (!response.ok) {
|
|
unconfirmedLaunch = response.status >= 500;
|
|
launchError = body.error ?? 'Could not queue this collection.';
|
|
if (!unconfirmedLaunch) {
|
|
controller.select(null);
|
|
refreshVersion++;
|
|
}
|
|
return;
|
|
}
|
|
if (!body.ok) throw new Error('The launch response could not be confirmed.');
|
|
open = false;
|
|
if (body.collectionId)
|
|
await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
|
} catch {
|
|
if (current === visit) {
|
|
// The queue may already be committed. Keep its preview ID for an idempotent retry.
|
|
unconfirmedLaunch = true;
|
|
launchError =
|
|
'Could not confirm the launch. Retry the same request, or check collection launches.';
|
|
}
|
|
} finally {
|
|
if (current === visit) launching = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Dialog bind:open title={collectionName} description="Launch collection" wide busy={launching}>
|
|
<div class="launch-content">
|
|
{#if loadingError}
|
|
<p class="issue" role="alert">{loadingError}</p>
|
|
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
|
|
{:else if !loaded}
|
|
<p class="muted" role="status">Loading collection…</p>
|
|
{:else}
|
|
<div class="mode-selection">
|
|
<fieldset disabled={launching || unconfirmedLaunch}>
|
|
<legend class="sr-only">Apply collection</legend>
|
|
<label class:selected={mode === 'fill'}
|
|
><input
|
|
bind:this={modeInput}
|
|
type="radio"
|
|
name={`${id}-collection-mode`}
|
|
value="fill"
|
|
bind:group={mode}
|
|
onchange={selectionChanged}
|
|
/>Fill missing</label
|
|
>
|
|
<label class:selected={mode === 'exact'}
|
|
><input
|
|
type="radio"
|
|
name={`${id}-collection-mode`}
|
|
value="exact"
|
|
bind:group={mode}
|
|
onchange={selectionChanged}
|
|
/>Match exactly</label
|
|
>
|
|
</fieldset>
|
|
<p class="muted">
|
|
{mode === 'exact'
|
|
? 'Match the entire fleet across both games. Excess lobbies and maps outside this collection will be removed.'
|
|
: 'Create missing lobbies. All existing servers and their settings stay as they are.'}
|
|
</p>
|
|
</div>
|
|
|
|
<section class="change-summary" aria-label="Fleet changes" aria-busy={checking}>
|
|
{#if preview}
|
|
<strong
|
|
>{noChanges
|
|
? 'Collection targets met'
|
|
: `${preview.servers.length} to create · ${preview.removals.length} to remove`}</strong
|
|
>
|
|
{#if mode === 'exact' && preview.retainedServers.length}<p class="muted">
|
|
{preview.retainedServers.length} existing or queued servers kept.
|
|
</p>{/if}
|
|
<div class="readiness">
|
|
{#each keyCoverage as pool}<span
|
|
class:ready={pool.available >= pool.required}
|
|
class:warning={pool.available < pool.required}
|
|
>{pool.game.toUpperCase()} keys: {pool.unassigned
|
|
? 'waiting for lobby availability'
|
|
: `${pool.available} of ${pool.required} required available`}</span
|
|
>{/each}
|
|
{#if preview.servers.length}<span class:ready={hostStatus.filesReady}
|
|
>{hostStatus.filesReady
|
|
? 'Game files ready'
|
|
: 'Game files preparing'}</span
|
|
>{/if}
|
|
</div>
|
|
{#if preview.servers.length}<p class="muted">
|
|
{collectionCapacitySummary(preview.availability)}
|
|
</p>{/if}
|
|
{#if noChanges}<p class="muted">
|
|
{mode === 'exact'
|
|
? 'Your fleet already matches this collection.'
|
|
: 'Your fleet meets or exceeds every target.'} Existing servers keep their
|
|
current settings.
|
|
</p>{/if}
|
|
{:else}<p role="status">
|
|
{checking
|
|
? 'Checking fleet changes…'
|
|
: 'Resolve the issues below to check this collection.'}
|
|
</p>{/if}
|
|
</section>
|
|
|
|
{#if launchError}<div class="issue" role="alert">
|
|
<p>{launchError}</p>
|
|
{#if unconfirmedLaunch}<a
|
|
href="/collections?view=active"
|
|
target="_blank"
|
|
rel="noopener">View collection launches ↗</a
|
|
>{/if}
|
|
</div>{/if}
|
|
{#if view.changeNotice}<p class="issue" role="status">{view.changeNotice}</p>{/if}
|
|
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
|
|
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div
|
|
class="issue"
|
|
role="status"
|
|
>
|
|
<p>
|
|
Upload {hostStatus.missingGames
|
|
.map((game) => game.toUpperCase())
|
|
.join(' and ')} game files before launching.
|
|
</p>
|
|
<a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a>
|
|
</div>
|
|
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">
|
|
Enter a valid runtime image in Advanced settings.
|
|
</p>
|
|
{:else if view.error}<p class="issue" role="alert">{view.error}</p>{/if}
|
|
{#if missingKeys.length}<div class="issue" role="status">
|
|
{#each missingKeys as pool}{@const shortfall =
|
|
pool.required - pool.unassigned - pool.available}
|
|
<p>
|
|
{pool.game.toUpperCase()} needs {shortfall} more unused key{shortfall ===
|
|
1
|
|
? ''
|
|
: 's'}.
|
|
<a
|
|
href={`/server-keys?game=${pool.game}`}
|
|
target="_blank"
|
|
rel="noopener">Manage keys ↗</a
|
|
>
|
|
</p>{/each}
|
|
{#if preview?.removals.length}<p>
|
|
Keys assigned to removals stay in use until cleanup finishes. Complete
|
|
those removals first to reuse their keys.
|
|
</p>{/if}
|
|
</div>{/if}
|
|
{#each preview?.blockers ?? [] as blocker}<p class="issue" role="alert">
|
|
{blocker}
|
|
</p>{/each}
|
|
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKeys.length || preview?.blockers.length)}<button
|
|
class="text-button"
|
|
disabled={launching}
|
|
onclick={refreshChecks}>Retry checks</button
|
|
>{/if}
|
|
|
|
{#if preview}
|
|
<section class="lineup" aria-label="Collection map targets">
|
|
{#each mapGroups as group}
|
|
<table>
|
|
<caption>{group.label}</caption>
|
|
<thead
|
|
><tr
|
|
><th scope="col">Map</th><th scope="col">Target</th><th
|
|
scope="col">Existing / queued</th
|
|
><th scope="col">Create</th>{#if mode === 'exact'}<th
|
|
scope="col">Remove</th
|
|
>{/if}</tr
|
|
></thead
|
|
>
|
|
<tbody
|
|
>{#each group.rows as row}<tr
|
|
><th scope="row">{row.mapName}</th><td>{row.target}</td><td
|
|
>{row.existing}</td
|
|
><td class:quantity={row.create > 0}>{row.create}</td
|
|
>{#if mode === 'exact'}<td class:danger={row.remove > 0}
|
|
>{row.remove}</td
|
|
>{/if}</tr
|
|
>{/each}</tbody
|
|
>
|
|
</table>
|
|
{/each}
|
|
<p class="muted">
|
|
Stopped and unhealthy servers also count toward targets. See Server details
|
|
for health.{#if mode === 'exact'}
|
|
Existing totals include the listed removals.{/if}
|
|
</p>
|
|
</section>
|
|
|
|
{#if preview.removals.length}<section
|
|
class="removals"
|
|
aria-label="Servers to remove"
|
|
>
|
|
<h3>
|
|
Destroy {preview.removals.length} server{preview.removals.length === 1
|
|
? ''
|
|
: 's'}
|
|
</h3>
|
|
<p>
|
|
Containers, files, and registrations will be removed. Occupied or
|
|
unreachable servers wait for a fresh empty-player check; unstarted
|
|
creations are cancelled.
|
|
</p>
|
|
<ul>
|
|
{#each preview.removals as server}<li>
|
|
<strong>{serverTitle(server.name)}</strong><code
|
|
>{server.name}</code
|
|
><span>{server.reason}</span>
|
|
</li>{/each}
|
|
</ul>
|
|
<label class="confirmation"
|
|
><input
|
|
type="checkbox"
|
|
bind:checked={confirmRemovals}
|
|
disabled={launching || unconfirmedLaunch || !currentReady}
|
|
/>Destroy 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
|
|
>{launchFileVersionLabel(file)} · {file.kind}</span
|
|
><code>{file.path}</code>
|
|
</li>{/each}
|
|
</ul>
|
|
</div>
|
|
</details>{/each}
|
|
</div>
|
|
</details>{/if}
|
|
{/if}
|
|
</div>
|
|
{#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>
|
|
.launch-content {
|
|
display: grid;
|
|
gap: 16px;
|
|
min-width: 0;
|
|
}
|
|
.mode-selection {
|
|
display: grid;
|
|
gap: 9px;
|
|
}
|
|
fieldset {
|
|
display: flex;
|
|
gap: 8px;
|
|
border: 0;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
fieldset label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--border-control);
|
|
border-radius: var(--radius-md);
|
|
font-size: var(--text-sm);
|
|
cursor: pointer;
|
|
}
|
|
fieldset label.selected {
|
|
background: var(--surface-raised);
|
|
border-color: var(--accent-primary);
|
|
}
|
|
fieldset input {
|
|
margin: 0;
|
|
}
|
|
.change-summary {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 14px 16px;
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.change-summary > strong {
|
|
font-size: var(--text-md);
|
|
line-height: 1.4;
|
|
}
|
|
.readiness {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px 16px;
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.readiness:empty {
|
|
display: none;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
font-size: var(--text-sm);
|
|
line-height: 1.5;
|
|
}
|
|
.muted {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.ready,
|
|
.quantity {
|
|
color: var(--state-online);
|
|
}
|
|
.warning {
|
|
color: var(--state-warning);
|
|
}
|
|
.danger {
|
|
color: var(--state-danger);
|
|
}
|
|
.issue {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 12px;
|
|
border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent);
|
|
border-radius: var(--radius-md);
|
|
color: var(--state-warning);
|
|
font-size: var(--text-sm);
|
|
}
|
|
a,
|
|
.text-button {
|
|
color: var(--accent-primary-hover);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.text-button {
|
|
justify-self: start;
|
|
border: 0;
|
|
padding: 0;
|
|
background: none;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.lineup {
|
|
display: grid;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
font-size: var(--text-xs);
|
|
}
|
|
caption {
|
|
text-align: left;
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
padding-bottom: 8px;
|
|
}
|
|
th,
|
|
td {
|
|
padding: 8px 6px;
|
|
text-align: center;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
th:first-child {
|
|
width: 40%;
|
|
text-align: left;
|
|
}
|
|
thead th {
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
tbody th {
|
|
font-weight: 500;
|
|
}
|
|
td {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.quantity,
|
|
.danger {
|
|
font-weight: 600;
|
|
}
|
|
.details {
|
|
border-top: 1px solid var(--border-subtle);
|
|
padding-top: 14px;
|
|
min-width: 0;
|
|
}
|
|
summary {
|
|
cursor: pointer;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.detail-body {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-top: 14px;
|
|
min-width: 0;
|
|
}
|
|
h3 {
|
|
font-size: var(--text-sm);
|
|
margin: 0;
|
|
}
|
|
dl {
|
|
display: grid;
|
|
gap: 14px;
|
|
margin: 0;
|
|
}
|
|
dt {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
dd {
|
|
margin: 5px 0 0;
|
|
overflow-wrap: anywhere;
|
|
font-size: var(--text-sm);
|
|
}
|
|
code {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.capacity {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
.server-list {
|
|
margin: 0;
|
|
padding-left: 24px;
|
|
}
|
|
.server-list li {
|
|
padding: 8px 0;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.server-list code {
|
|
display: block;
|
|
margin-top: 4px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.existing-list,
|
|
.file-list,
|
|
.removals ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.existing-list li {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
.existing-list span,
|
|
.file-list span {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.file-list li {
|
|
display: grid;
|
|
gap: 5px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.map-setup {
|
|
padding: 10px 0;
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
.removals {
|
|
display: grid;
|
|
gap: 12px;
|
|
border: 1px solid var(--state-danger);
|
|
border-radius: var(--radius-md);
|
|
padding: 14px;
|
|
}
|
|
.removals h3 {
|
|
color: var(--state-danger);
|
|
}
|
|
.removals p,
|
|
.removals li span {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.removals li {
|
|
display: grid;
|
|
gap: 4px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.removals code {
|
|
color: var(--text-secondary);
|
|
}
|
|
.confirmation {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 9px;
|
|
font-size: var(--text-sm);
|
|
line-height: 1.5;
|
|
}
|
|
.confirmation input {
|
|
margin: 3px 0 0;
|
|
flex-shrink: 0;
|
|
}
|
|
.launch-actions {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.launch-actions > p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.launch-actions > p.ready {
|
|
color: var(--state-online);
|
|
}
|
|
.launch-actions > div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
@media (max-width: 600px) {
|
|
.capacity {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
th:first-child {
|
|
width: 34%;
|
|
}
|
|
th,
|
|
td {
|
|
padding: 8px 3px;
|
|
}
|
|
}
|
|
@media (max-width: 440px) {
|
|
fieldset label {
|
|
flex: 1;
|
|
padding: 10px;
|
|
font-size: var(--text-xs);
|
|
}
|
|
.launch-actions {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
.launch-actions > div {
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|