Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -5,31 +5,90 @@
|
||||
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(), ready = $bindable(false), 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('');
|
||||
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.runtimeImages?.includes(selection.image)));
|
||||
const eligible = $derived(
|
||||
hosts.filter(
|
||||
(host) =>
|
||||
host.readiness === 'ready' &&
|
||||
host.inventory.runtimeImages?.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); });
|
||||
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 }])),
|
||||
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(() => {
|
||||
@@ -52,14 +111,24 @@
|
||||
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()]);
|
||||
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 = '';
|
||||
hosts = hostBody.hosts;
|
||||
libraries = libraryBody.libraries;
|
||||
error = '';
|
||||
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
|
||||
} catch (cause) {
|
||||
if (version === requestVersion && !controller.signal.aborted) error = (cause as Error).message;
|
||||
if (version === requestVersion && !controller.signal.aborted)
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (version === requestVersion && !controller.signal.aborted) loading = false;
|
||||
@@ -67,7 +136,7 @@
|
||||
}
|
||||
$effect(() => {
|
||||
// Restart the poll for a parent refresh, sharing one initial request.
|
||||
refreshVersion;
|
||||
void refreshVersion;
|
||||
loading = true;
|
||||
const stop = pollVisible(load, 5000);
|
||||
return () => {
|
||||
@@ -79,14 +148,45 @@
|
||||
</script>
|
||||
|
||||
{#snippet runtimeFields()}
|
||||
<label>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input required bind:value={selection.image} disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label
|
||||
>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input
|
||||
required
|
||||
bind:value={selection.image}
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet networkFields()}
|
||||
<label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} oninput={() => onchange?.()} /></label>
|
||||
<label
|
||||
>Local bind address<input
|
||||
bind:value={selection.bindAddress}
|
||||
placeholder="Host default"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Public / NAT address<input
|
||||
bind:value={selection.publicAddress}
|
||||
placeholder="Host default"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Public / NAT port<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={selection.publicPort}
|
||||
placeholder="Same as local port"
|
||||
{disabled}
|
||||
oninput={() => onchange?.()}
|
||||
/></label
|
||||
>
|
||||
<p>Public mapping does not change the local Docker port reservation.</p>
|
||||
{/snippet}
|
||||
|
||||
@@ -95,39 +195,175 @@
|
||||
<summary>Advanced settings</summary>
|
||||
<div class="advanced-fields">
|
||||
{@render runtimeFields()}
|
||||
{#if network}<fieldset><legend>Network overrides</legend>{@render networkFields()}</fieldset>{/if}
|
||||
{#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}
|
||||
<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>
|
||||
{/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 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; } label { display:grid; gap:7px; font-size: var(--text-sm); } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size: 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:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size: var(--text-sm); } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
.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;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 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 label {
|
||||
margin-top: 14px;
|
||||
}
|
||||
details p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user