Queue native collection launches before async placement
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
|
||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
|
||||
&& (!preview.servers.length || hostReady) && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
async function review() {
|
||||
if (!hostReady) { error = 'Wait for an eligible host and wait for its latest game files and runtime to finish automatic setup.'; return; }
|
||||
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
|
||||
const current = ++session;
|
||||
const previous = preview;
|
||||
busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
|
||||
@@ -105,7 +105,7 @@
|
||||
{#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…' : 'Wait for an eligible host. Its latest game files sync automatically before you review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and server keys. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{:else}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||||
@@ -115,10 +115,24 @@
|
||||
{#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}
|
||||
{#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}
|
||||
</div>
|
||||
{#if preview.availability && preview.servers.length}
|
||||
<section class="availability" aria-label="Latest reported capacity">
|
||||
<h3>Latest reported capacity</h3>
|
||||
<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>
|
||||
<dl>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
||||
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
|
||||
{#if preview.servers.length || preview.removals.length}
|
||||
{#if preview.servers.length}<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Fleet counts and available slots are checked again when you queue the launch.</p>{/if}
|
||||
{#if preview.servers.length}<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
{#if preview.removals.length}
|
||||
<section class="removals" aria-label="Servers to remove">
|
||||
@@ -133,7 +147,7 @@
|
||||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||||
<li class="server-row">
|
||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
<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.port === null ? 'No available capacity' : server.native?.automatic ? 'Host and port assigned automatically' : `Port ${server.port}`}</span></div>
|
||||
<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-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||||
{:else}<label><span>Server key required</span><input aria-label={`Key for ${server.name}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste this server’s key" /></label>{/if}
|
||||
@@ -151,6 +165,10 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.availability { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.availability dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 16px; margin: 0; }
|
||||
.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; }
|
||||
.warning { color: var(--state-warning); font-size: 13px; line-height: 1.6; }
|
||||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
.removals { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); }
|
||||
.removals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
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 && games.length > 0 && games.every(game => selection.libraryIds[game] === latest[game]?.id && cacheReady(game)); });
|
||||
$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(() => {
|
||||
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
|
||||
if (!selection.image) selection.image = defaultRuntimeImage;
|
||||
@@ -42,14 +43,14 @@
|
||||
<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 automatically. We check available capacity before accepting a launch and choose the least loaded eligible host when provisioning starts.</p>
|
||||
<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 T5 and T6 files will be prepared automatically.</p>{/if}
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
|
||||
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Waiting for a host to prepare the runtime and latest files automatically.'}</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 {game.toUpperCase()} files</a> to prepare your hosts.</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user