Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+73 -23
View File
@@ -7,7 +7,7 @@
import { onMount, tick } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
import { betaDraftKey, readBetaDraft, compatibleBetaServers, type BetaDraft } from '$lib/beta-draft.js';
import { betaDraftKey, readBetaDraft, compatibleBetaServers, betaScopeMatches, betaCandidateServers, betaApplicableGames, betaInstanceCounts, type BetaDraft } from '$lib/beta-draft.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
@@ -28,6 +28,8 @@
let scripts = $state<FileRow[]>([]), detailById = $state<Record<number,FileDetail>>({}), nextKey=0;
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
let selectionMode = $state<BetaDraft['selectionMode']>('automatic');
let instancesPerGame = $state<BetaDraft['instancesPerGame']>({});
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state('');
let availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
@@ -41,10 +43,29 @@
const fleetServers = $derived($sharedFleet);
const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null));
const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId)));
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
const candidateServers = $derived.by(()=>{
const scopes=picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file);
return scopes.length ? fleetServers.filter(server=>scopes.every(file=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value)).map(server=>server.name) : [];
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || selectionMode==='manual' || Object.values(instancesPerGame).some(count=>count!==1) || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
const selectedFiles = $derived(picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file));
const candidates = $derived(betaCandidateServers(selectedFiles,fleetServers));
const candidateServers = $derived(candidates.map(server=>server.name));
const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers));
const requestedCounts = $derived(betaInstanceCounts(applicableGames,instancesPerGame));
const automaticCounts = $derived(Object.fromEntries(Object.entries(requestedCounts).filter((entry):entry is [string,number]=>typeof entry[1]==='number' && entry[1]>0)));
const serverCount = $derived(selectionMode==='manual' ? pickedServers.size : Object.values(automaticCounts).reduce((total,count)=>total+count,0));
const serverSelectionIssue = $derived.by(()=>{
if(selectionMode==='automatic') {
if(!applicableGames.length)return 'The selected files have no registered servers in a supported game.';
if(Object.values(requestedCounts).some(count=>count===null || !Number.isInteger(count) || count<0 || count>1000))return 'Enter a whole number from 0 to 1,000 for each game.';
if(!serverCount)return 'Request at least one test instance.';
const missing=selectedFiles.find(file=>!candidates.some(server=>betaScopeMatches(file,server) && !!server.game && (automaticCounts[server.game]??0)>0));
if(missing)return `${missing.name} needs at least one instance in a matching game and scope.`;
const short=applicableGames.find(game=>(automaticCounts[game]??0)>candidates.filter(server=>server.game===game).length);
if(short)return `${gameLabel(short)} has fewer compatible registered servers than requested. Lower its instance count or add servers.`;
return '';
}
if(!pickedServers.size)return 'Select at least one compatible test server.';
if([...pickedServers].some(name=>!candidateServers.includes(name)))return 'Recheck the selected files and servers before starting.';
const missing=selectedFiles.find(file=>!candidates.some(server=>pickedServers.has(server.name)&&betaScopeMatches(file,server)));
return missing ? `Select at least one server matching ${missing.name}. Every file needs a test participant.` : '';
});
const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{
const identity=fleetIdentity(server), fresh=freshServer(server,now);
@@ -52,10 +73,11 @@
&& `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase());
}));
const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)})));
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? !pickedServers.size ? 'Select at least one compatible test server.' : '' : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady || !pickedServers.size || [...pickedServers].some(name=>!candidateServers.includes(name)) ? 'Recheck the selected files and servers before starting.' : '');
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? serverSelectionIssue : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady ? 'Recheck the selected files before starting.' : serverSelectionIssue);
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
function gameLabel(game:string) { return game==='t5'?'Black Ops I (T5)':game==='t6'?'Black Ops II (T6)':game; }
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
function reconcileSelection() {
@@ -63,7 +85,7 @@
// authoritative enough to remove selections from the saved draft.
if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return;
const result=compatibleBetaServers(pickedServers,candidateServers);
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match every file: ${result.removed.join(', ')}.`; }
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match any file: ${result.removed.join(', ')}.`; }
}
async function loadSelections(selections:BetaDraft['picks']) {
const warnings:string[]=[];
@@ -110,6 +132,7 @@
if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan);
betaLabel=source.beta.notes ?? '';
pickedServers=new Set(source.servers.map((s:{server:string})=>s.server));
selectionMode='manual';
await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null})));
reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true;
} else if(selections.length) { await loadSelections(selections); draftReady=true; }
@@ -120,22 +143,23 @@
async function resumeDraft() {
if(!availableDraft)return;
const draft=availableDraft; loading=true; flash=''; notice='';
try { testPlan=draft.testPlan; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
try { testPlan=draft.testPlan; selectionMode=draft.selectionMode; instancesPerGame=draft.instancesPerGame; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
finally { availableDraft=null; loading=false; draftReady=true; }
}
function discardDraft() {
const useLinked=!!availableDraft && hasLinkedSetup;
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); selectionMode='automatic'; instancesPerGame={}; testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
draftReady=true;
if(useLinked)void initialize(true);
}
$effect(()=>{
if(!draftReady || loading || openingBeta)return;
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],testPlan:JSON.parse(JSON.stringify(testPlan))};
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],selectionMode,instancesPerGame:{...instancesPerGame,...requestedCounts},testPlan:JSON.parse(JSON.stringify(testPlan))};
if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
});
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
$effect(()=>{ if(selectionMode==='automatic' && testPlan.scope!=='all')testPlan={...testPlan,scope:'all'}; });
beforeNavigate(navigation=>{
if(!dirty || !storageUnavailable || allowLeave || openingBeta)return;
if(navigation.willUnload){navigation.cancel();return;}
@@ -165,10 +189,10 @@
}
async function openBeta(event:Event) {
event.preventDefault();if(step!==3||forwardReason||openingBeta)return;
if(betaPlanIssues(testPlan,pickedServers.size).length){await move(2);await planBuilder?.validate();return;}
if(betaPlanIssues(testPlan,serverCount).length){await move(2);await planBuilder?.validate();return;}
openingBeta=true;flash='';
try {
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),...(selectionMode==='automatic'?{instancesPerGame:automaticCounts}:{servers:[...pickedServers]}),testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')));
@@ -180,12 +204,12 @@
<div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<BackLink fallback="/betas" />
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting." />
<SectionHeader title="New beta" subtitle="Test files across games in one beta. Choose how many instances need to pass." />
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…"/>
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.selectionMode==='automatic'?'Automatic server selection':`${availableDraft.servers.length} selected servers`}. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
{:else if scripts.length}
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
@@ -201,30 +225,56 @@
{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<p class="scope">{scopeText(file)} · {scopeCount(file)} eligible servers · <a href={`/${file.kind==='baseline'?'configs':file.kind+'s'}/${file.id}`}>Inspect file and versions</a>{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}<br/>No unpromoted versions. Upload a new version from the file page.{/if}</p>{/if}{/if}
</section>{/each}</div>
<Button onclick={()=>picks=[...picks,{key:nextKey++,scriptId:null,versionId:null}]} disabled={picks.length>=eligibleFiles.length}>Add file</Button>
<p class="muted">Test servers must match every file's scope. Changing a file retains compatible server selections.</p>
<p class="muted">Combine files for different games in one beta. Each file installs only on servers matching its scope, and every file needs at least one test participant.</p>
{:else if step===1}
<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>
{#if selectionMode==='automatic'}
<div class="automatic-selection">
<h3>Instances that must pass per game</h3>
<p>At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.</p>
<div class="instance-counts">{#each applicableGames as game}
<label>{gameLabel(game)}<input type="number" min="0" max="1000" step="1" value={requestedCounts[game]??''} oninput={event=>instancesPerGame={...instancesPerGame,[game]:event.currentTarget.value===''?null:event.currentTarget.valueAsNumber}}/><span class="muted">{candidates.filter(server=>server.game===game).length} compatible registered servers</span></label>
{/each}</div>
<p class="muted">Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies. Set a game to 0 only if every file still has a matching participant.</p>
</div>
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if}
<details><summary>Selected file scopes</summary><ul>{#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<li>{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers</li>{/if}{/if}{/each}</ul></details>
{#if !candidateServers.length}<div class="ui-error">The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.</div>
{:else}
{#if !candidateServers.length}<div class="ui-error">No registered servers match these files. Review their scopes above or register a matching server.</div>{/if}
<details class="advanced-selection" open={selectionMode==='manual'}><summary>Advanced options · choose specific servers</summary>
<div class="advanced-body">
<fieldset class="selection-mode"><legend>Server selection</legend>
<label><input type="radio" name="server-selection" value="automatic" bind:group={selectionMode}/>Choose least-used servers automatically</label>
<label><input type="radio" name="server-selection" value="manual" bind:group={selectionMode}/>Choose specific servers</label>
</fieldset>
{#if selectionMode==='manual' && candidateServers.length}
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
{/if}
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p></section>{/if}
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {pickedServers.size} servers</Button>{/if}</div>
</div></details>
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} {serverCount} allServersRequired={selectionMode==='automatic'}/>
{:else}<section class="ui-panel review">
<div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div>
<ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}{#if pick.scriptId!==null && detailById[pick.scriptId]}<br/><span class="muted">{scopeText(detailById[pick.scriptId].script)}</span>{/if}</li>{/each}</ul>
<div class="review-heading"><h3>Test instances ({serverCount})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div>
{#if selectionMode==='automatic'}
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {count} instance{count===1?'':'s'} must pass</li>{/each}</ul>
<p>The least-used available servers are chosen when you start the beta. Selection uses current player counts and ensures every file has a matching participant. If there are not enough available servers, the beta will not start.</p>
{:else}<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul>{/if}
<div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs each selected version only on matching test servers and restarts them when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p>
</section>{/if}
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {serverCount} instances</Button>{/if}</div>
</form>
{/if}
</div>
<style>
.beta-form input:not([type='checkbox']), .beta-form select { border-color:var(--border-control); }
.beta-form input:not([type='checkbox']):not([type='radio']), .beta-form select { border-color:var(--border-control); }
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']):not([type='radio']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
.automatic-selection,.advanced-body { display:grid; gap:16px; } .instance-counts { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(220px,100%),1fr)); gap:16px; } .advanced-selection { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } .advanced-selection summary { cursor:pointer; font-size:14px; } .advanced-body { margin-top:16px; } .selection-mode { display:grid; gap:12px; } .selection-mode label { display:flex; align-items:center; gap:8px; cursor:pointer; } .selection-mode input { accent-color:var(--accent-primary); }
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }