359 lines
12 KiB
Svelte
359 lines
12 KiB
Svelte
<script lang="ts">
|
|
import Input from '$ui/components/primitives/Input.svelte';
|
|
import Field from '$ui/components/primitives/Field.svelte';
|
|
import { gameTitle } from '$ui/server-labels.js';
|
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
|
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
|
import type { GameLibrary } from '$lib/game-files.js';
|
|
import type { ZombieGame } from '$lib/map-aliases.js';
|
|
import { pollVisible } from '$ui/poll.js';
|
|
import {
|
|
latestGameFiles,
|
|
selectLatestGameFiles,
|
|
type NativeHostSelectionStatus
|
|
} from '$ui/native-game-files.js';
|
|
let {
|
|
games,
|
|
selection = $bindable(),
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
|
|
ready = $bindable(false),
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
|
|
status = $bindable(),
|
|
disabled = false,
|
|
network = false,
|
|
compact = false,
|
|
refreshVersion = 0,
|
|
onchange
|
|
}: {
|
|
games: ZombieGame[];
|
|
selection: NativeDeploymentSelection;
|
|
ready?: boolean;
|
|
status?: NativeHostSelectionStatus;
|
|
disabled?: boolean;
|
|
network?: boolean;
|
|
compact?: boolean;
|
|
refreshVersion?: number;
|
|
onchange?: () => void;
|
|
} = $props();
|
|
let hosts = $state<Host[]>([]);
|
|
let libraries = $state<GameLibrary[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let defaultRuntimeImage = $state('');
|
|
let runtimeSelection: NativeDeploymentSelection | null = null;
|
|
let requestVersion = 0;
|
|
let activeController: AbortController | undefined;
|
|
const eligible = $derived(
|
|
hosts.filter(
|
|
(host) =>
|
|
host.readiness === 'ready' &&
|
|
host.inventory.preparedRuntimeImages?.includes(selection.image)
|
|
)
|
|
);
|
|
const latest = $derived(latestGameFiles(libraries));
|
|
|
|
function cacheReady(game: ZombieGame) {
|
|
return !!latest[game]?.caches.some(
|
|
(cache) =>
|
|
eligible.some((host) => host.id === cache.hostId) &&
|
|
['ready', 'outdated'].includes(cache.status)
|
|
);
|
|
}
|
|
$effect(() => {
|
|
ready =
|
|
!loading &&
|
|
!error &&
|
|
/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image) &&
|
|
games.length > 0 &&
|
|
games.every(
|
|
(game) => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id
|
|
);
|
|
});
|
|
$effect(() => {
|
|
status = {
|
|
loading,
|
|
error,
|
|
hasHosts: hosts.length > 0,
|
|
filesReady: games.length > 0 && games.every(cacheReady),
|
|
missingGames: games.filter((game) => !latest[game]?.id),
|
|
uploadedAt:
|
|
games
|
|
.map((game) => latest[game]?.publishedAt ?? latest[game]?.createdAt)
|
|
.filter((value): value is string => !!value)
|
|
.sort()
|
|
.at(-1) ?? null,
|
|
gameFiles: Object.fromEntries(
|
|
games.map((game) => [
|
|
game,
|
|
{
|
|
ready: cacheReady(game),
|
|
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null
|
|
}
|
|
])
|
|
)
|
|
};
|
|
});
|
|
$effect(() => {
|
|
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
|
|
if (!selection.image) selection.image = defaultRuntimeImage;
|
|
runtimeSelection = selection;
|
|
});
|
|
$effect(() => {
|
|
// Keep an in-flight preview/launch request stable. Reconcile as soon as
|
|
// it finishes, so a newly uploaded folder invalidates its old preview.
|
|
if (loading || error || disabled) return;
|
|
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
|
|
});
|
|
async function load(signal?: AbortSignal) {
|
|
const version = ++requestVersion;
|
|
activeController?.abort();
|
|
const controller = new AbortController();
|
|
activeController = controller;
|
|
const abort = () => controller.abort();
|
|
if (signal?.aborted) controller.abort();
|
|
else signal?.addEventListener('abort', abort, { once: true });
|
|
try {
|
|
const [hostResponse, libraryResponse] = await Promise.all([
|
|
fetch('/api/hosts', { signal: controller.signal }),
|
|
fetch('/api/game-files', { signal: controller.signal })
|
|
]);
|
|
if (!hostResponse.ok || !libraryResponse.ok)
|
|
throw new Error('Could not load hosts and game files.');
|
|
const [hostBody, libraryBody] = await Promise.all([
|
|
hostResponse.json(),
|
|
libraryResponse.json()
|
|
]);
|
|
if (version !== requestVersion || controller.signal.aborted) return;
|
|
hosts = hostBody.hosts;
|
|
libraries = libraryBody.libraries;
|
|
error = '';
|
|
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
|
} catch (cause) {
|
|
if (version === requestVersion && !controller.signal.aborted)
|
|
error = (cause as Error).message;
|
|
} finally {
|
|
signal?.removeEventListener('abort', abort);
|
|
if (version === requestVersion && !controller.signal.aborted) loading = false;
|
|
}
|
|
}
|
|
$effect(() => {
|
|
// Restart the poll for a parent refresh, sharing one initial request.
|
|
void refreshVersion;
|
|
loading = true;
|
|
const stop = pollVisible(load, 5000);
|
|
return () => {
|
|
stop();
|
|
requestVersion++;
|
|
activeController?.abort();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
{#snippet runtimeFields()}
|
|
<Field label={compact ? 'Runtime image' : 'Plutainer runtime image'}>
|
|
<Input
|
|
required
|
|
bind:value={selection.image}
|
|
{disabled}
|
|
oninput={() => onchange?.()}
|
|
/></Field
|
|
>
|
|
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
|
{/snippet}
|
|
|
|
{#snippet networkFields()}
|
|
<Field label="Local bind address">
|
|
<Input
|
|
bind:value={selection.bindAddress}
|
|
placeholder="Host default"
|
|
{disabled}
|
|
oninput={() => onchange?.()}
|
|
/></Field
|
|
>
|
|
<Field label="Public / NAT address">
|
|
<Input
|
|
bind:value={selection.publicAddress}
|
|
placeholder="Host default"
|
|
{disabled}
|
|
oninput={() => onchange?.()}
|
|
/></Field
|
|
>
|
|
<Field label="Public / NAT port">
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
bind:value={selection.publicPort}
|
|
placeholder="Same as local port"
|
|
{disabled}
|
|
oninput={() => onchange?.()}
|
|
/></Field
|
|
>
|
|
<p>Public mapping does not change the local Docker port reservation.</p>
|
|
{/snippet}
|
|
|
|
{#if compact}
|
|
<details class="advanced">
|
|
<summary>Advanced settings</summary>
|
|
<div class="advanced-fields">
|
|
{@render runtimeFields()}
|
|
{#if network}<fieldset>
|
|
<legend>Network overrides</legend>{@render networkFields()}
|
|
</fieldset>{/if}
|
|
</div>
|
|
</details>
|
|
{:else}
|
|
<section class="selection" aria-label="Deployment host and game files">
|
|
<div class="title">
|
|
<h3>Host and game files</h3>
|
|
<button
|
|
type="button"
|
|
disabled={disabled || loading}
|
|
onclick={() => {
|
|
onchange?.();
|
|
void load();
|
|
}}>Refresh hosts</button
|
|
>
|
|
</div>
|
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
|
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
|
<p>
|
|
Hosts are assigned when provisioning starts. You can queue launches while hosts are busy
|
|
or preparing; each server waits for an eligible host and available capacity.
|
|
</p>
|
|
{#if !loading && !hosts.length}<p>
|
|
<a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and
|
|
latest Black Ops I and Black Ops II files will be prepared automatically.
|
|
</p>{/if}
|
|
{#if !loading}{#each games as game}
|
|
<div class="game-files">
|
|
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
|
|
{#if latest[game]}
|
|
<p>
|
|
Latest upload · <RelativeTime
|
|
value={latest[game]?.publishedAt ?? latest[game]?.createdAt}
|
|
/>
|
|
</p>
|
|
<p role="status">
|
|
{cacheReady(game)
|
|
? 'Latest files ready on an eligible host.'
|
|
: 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}
|
|
</p>
|
|
{:else}
|
|
<p>
|
|
No files uploaded yet. <a
|
|
href="/game-files"
|
|
target="_blank"
|
|
rel="noopener">Upload {gameTitle(game, true)} files</a
|
|
> to prepare your hosts.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/each}{/if}
|
|
<p>
|
|
Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a
|
|
href="/game-files"
|
|
target="_blank"
|
|
rel="noopener">Upload game files</a
|
|
> when they change. Existing servers keep their current files.
|
|
</p>
|
|
<details>
|
|
<summary>Advanced: runtime override</summary>
|
|
<p>
|
|
The configured default runtime is prepared automatically. Custom images must first
|
|
be prepared by an admin from the destination host page.
|
|
</p>
|
|
{@render runtimeFields()}
|
|
</details>
|
|
{#if network}<details>
|
|
<summary>Network overrides</summary>{@render networkFields()}
|
|
</details>{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.game-files {
|
|
display: grid;
|
|
gap: 5px;
|
|
}
|
|
.game-files strong {
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
}
|
|
.advanced {
|
|
border-top: 1px solid var(--border-subtle);
|
|
padding-top: 16px;
|
|
}
|
|
.advanced summary {
|
|
color: var(--text-secondary);
|
|
}
|
|
.advanced-fields {
|
|
padding-top: 2px;
|
|
}
|
|
fieldset {
|
|
margin: 18px 0 0;
|
|
padding: 0;
|
|
border: 0;
|
|
min-width: 0;
|
|
}
|
|
legend {
|
|
padding: 0;
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
}
|
|
.selection {
|
|
display: grid;
|
|
gap: 14px;
|
|
padding: 20px;
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface-sunken);
|
|
}
|
|
.title {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
h3 {
|
|
font-size: var(--text-sm);
|
|
margin: 0;
|
|
}
|
|
|
|
p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
}
|
|
a,
|
|
button {
|
|
color: var(--accent-primary-hover);
|
|
}
|
|
button {
|
|
background: none;
|
|
border: 0;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
font-size: var(--text-xs);
|
|
}
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
.error {
|
|
color: var(--state-danger);
|
|
}
|
|
summary {
|
|
cursor: pointer;
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
details :global(.field) {
|
|
margin-top: 14px;
|
|
}
|
|
details p {
|
|
margin-top: 12px;
|
|
}
|
|
</style>
|