1231 lines
52 KiB
Svelte
1231 lines
52 KiB
Svelte
<script lang="ts">
|
|
import { countLabel } from '$ui/plural.js';
|
|
import { page } from '$app/stores';
|
|
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
|
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
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,
|
|
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';
|
|
import { fleetIdentity, freshServer } from '$lib/fleet-overview.js';
|
|
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
|
|
import { serverTimeNow } from '$ui/stores/servers.js';
|
|
import { confirmAction } from '$ui/confirm.js';
|
|
import BetaPlanBuilder from '$ui/components/BetaPlanBuilder.svelte';
|
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
|
import Button from '$ui/components/primitives/Button.svelte';
|
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
|
|
|
interface FileRow {
|
|
id: number;
|
|
name: string;
|
|
kind: string;
|
|
target_rel_path: string;
|
|
scope_type: 'game' | 'map' | null;
|
|
scope_value: string | null;
|
|
current_version_id: number | null;
|
|
latestVersion: { id: number } | null;
|
|
runningBetaId: number | null;
|
|
}
|
|
interface Version {
|
|
id: number;
|
|
version: number;
|
|
release_version: string | null;
|
|
sha256: string;
|
|
state: string;
|
|
}
|
|
interface FileDetail {
|
|
script: FileRow;
|
|
versions: Version[];
|
|
matchedServers: string[];
|
|
runningBetaId?: number | null;
|
|
}
|
|
interface Pick {
|
|
key: number;
|
|
scriptId: number | null;
|
|
versionId: number | null;
|
|
}
|
|
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 instanceCount = $state<BetaDraft['instanceCount']>(1);
|
|
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;
|
|
let hasLinkedSetup = $state(false);
|
|
let loadingVersions = $state<Set<number>>(new Set());
|
|
const rowRequests = new Map<number, number>();
|
|
let versionRequest = 0;
|
|
let betaLabel = $state('');
|
|
let search = $state(''),
|
|
serverGame = $state(''),
|
|
availability = $state(''),
|
|
now = $state(Date.now());
|
|
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
|
|
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 ||
|
|
selectionMode === 'manual' ||
|
|
instanceCount !== 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 automaticCounts = $derived(betaInstanceCounts(applicableGames, instanceCount));
|
|
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 (
|
|
instanceCount === null ||
|
|
!Number.isInteger(instanceCount) ||
|
|
instanceCount < 1 ||
|
|
instanceCount > 1000
|
|
)
|
|
return 'Enter a whole number from 1 to 1,000. This count applies to every game in the beta.';
|
|
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 the 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);
|
|
return (
|
|
(!serverGame || identity.game === serverGame) &&
|
|
(!availability ||
|
|
(availability === 'empty'
|
|
? fresh && server.last_players === 0
|
|
: availability === 'occupied'
|
|
? fresh && (server.last_players ?? 0) > 0
|
|
: !fresh)) &&
|
|
`${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
|
|
? 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' : game === 't6' ? 'Black Ops II' : 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() {
|
|
// Failed refreshes can leave an empty or retained snapshot; neither is
|
|
// 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 any file: ${result.removed.join(', ')}.`;
|
|
}
|
|
}
|
|
async function loadSelections(selections: BetaDraft['picks']) {
|
|
const warnings: string[] = [];
|
|
picks = await Promise.all(
|
|
selections.map(async (selection) => {
|
|
const pick = { key: nextKey++, ...selection };
|
|
if (pick.scriptId === null) return pick;
|
|
const listed = scripts.find((file) => file.id === pick.scriptId);
|
|
if (!listed || listed.runningBetaId !== null) {
|
|
warnings.push(
|
|
`File #${pick.scriptId} is unavailable or in another beta. Choose a replacement.`
|
|
);
|
|
return { ...pick, scriptId: null, versionId: null };
|
|
}
|
|
try {
|
|
const response = await fetch(`/api/scripts/${pick.scriptId}`);
|
|
if (!response.ok) throw Error();
|
|
const detail = (await response.json()) as FileDetail;
|
|
detailById[pick.scriptId] = detail;
|
|
if (detail.runningBetaId != null) {
|
|
warnings.push(
|
|
`${listed.name} is in beta #${detail.runningBetaId}. Choose a replacement.`
|
|
);
|
|
return { ...pick, scriptId: null, versionId: null };
|
|
}
|
|
if (
|
|
!detail.versions.some((v) => v.id === pick.versionId) ||
|
|
detail.script.current_version_id === pick.versionId
|
|
) {
|
|
warnings.push(
|
|
`${listed.name}: saved version ${pick.versionId ?? ''} is unavailable or already promoted. Select an unpromoted version; no newer version was substituted.`
|
|
);
|
|
pick.versionId = null;
|
|
}
|
|
return pick;
|
|
} catch {
|
|
warnings.push(
|
|
`${listed.name}: versions could not be loaded. Choose the file again to retry.`
|
|
);
|
|
return { ...pick, versionId: null };
|
|
}
|
|
})
|
|
);
|
|
if (warnings.length) {
|
|
flash = warnings.join(' ');
|
|
step = 0;
|
|
}
|
|
}
|
|
async function initialize(ignoreDraft = false) {
|
|
loading = true;
|
|
flash = '';
|
|
draftReady = false;
|
|
try {
|
|
const response = await fetch('/api/scripts');
|
|
if (!response.ok)
|
|
throw new Error('Could not load files for beta setup. Retry to continue.');
|
|
scripts = (await response.json()).scripts;
|
|
await refreshFleet();
|
|
const query = new URLSearchParams(window.location.search),
|
|
selections = parseBetaSelection(query),
|
|
reuse = Number(query.get('reuse'));
|
|
hasLinkedSetup = selections.length > 0 || reuse > 0;
|
|
// Returning from a file detail retains the original selection URL. A
|
|
// stored draft must be offered before that URL can seed a fresh setup.
|
|
if (!ignoreDraft) {
|
|
try {
|
|
availableDraft = readBetaDraft(localStorage.getItem(betaDraftKey));
|
|
} catch {
|
|
storageUnavailable = true;
|
|
}
|
|
if (availableDraft) {
|
|
if (hasLinkedSetup)
|
|
notice =
|
|
'An unfinished draft is saved. Resume it, or discard it to start from the linked files and exact versions.';
|
|
return;
|
|
}
|
|
}
|
|
if (reuse > 0) {
|
|
const response = await fetch(`/api/betas/${reuse}`);
|
|
if (!response.ok)
|
|
throw new Error('The beta to reuse is unavailable. Choose files below.');
|
|
const source = await response.json();
|
|
if (!['promoted', 'rolled-back'].includes(source.beta.state))
|
|
throw new Error(
|
|
'Roll back or promote the original beta before reusing its files.'
|
|
);
|
|
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;
|
|
} else draftReady = true;
|
|
} catch (error) {
|
|
flash = (error as Error).message;
|
|
draftReady = true;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
async function resumeDraft() {
|
|
if (!availableDraft) return;
|
|
const draft = availableDraft;
|
|
loading = true;
|
|
flash = '';
|
|
notice = '';
|
|
try {
|
|
testPlan = draft.testPlan;
|
|
selectionMode = draft.selectionMode;
|
|
instanceCount = draft.instanceCount;
|
|
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. Automatic selection uses the same instance count for every game; review it before starting.';
|
|
} 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();
|
|
selectionMode = 'automatic';
|
|
instanceCount = 1;
|
|
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],
|
|
selectionMode,
|
|
instanceCount,
|
|
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) {
|
|
void 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;
|
|
}
|
|
if (navigation.to) {
|
|
navigation.cancel();
|
|
void confirmAction({
|
|
title: 'Leave unfinished beta?',
|
|
message:
|
|
'This browser cannot save the draft. Leaving will discard your unfinished setup.',
|
|
confirmLabel: 'Leave setup',
|
|
danger: true
|
|
}).then((confirmed) => {
|
|
if (confirmed) {
|
|
allowLeave = true;
|
|
void goto(navigation.to!.url);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
onMount(() => {
|
|
void initialize();
|
|
const timer = setInterval(() => (now = serverTimeNow()), 10000);
|
|
const unload = (event: BeforeUnloadEvent) => {
|
|
if (dirty && storageUnavailable && !allowLeave && !openingBeta) {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
}
|
|
};
|
|
window.addEventListener('beforeunload', unload);
|
|
return () => {
|
|
clearInterval(timer);
|
|
window.removeEventListener('beforeunload', unload);
|
|
};
|
|
});
|
|
async function onPickFile(index: number) {
|
|
const pick = picks[index],
|
|
scriptId = pick.scriptId,
|
|
current = ++versionRequest;
|
|
rowRequests.set(pick.key, current);
|
|
pick.versionId = null;
|
|
flash = '';
|
|
if (scriptId === null) {
|
|
loadingVersions = new Set([...loadingVersions].filter((key) => key !== pick.key));
|
|
reconcileSelection();
|
|
return;
|
|
}
|
|
loadingVersions = new Set([...loadingVersions, pick.key]);
|
|
try {
|
|
const response = await fetch(`/api/scripts/${scriptId}`);
|
|
if (!response.ok)
|
|
throw new Error('Could not load versions. Choose the file again to retry.');
|
|
const detail = (await response.json()) as FileDetail;
|
|
if (
|
|
rowRequests.get(pick.key) !== current ||
|
|
pick.scriptId !== scriptId ||
|
|
!picks.some((p) => p.key === pick.key)
|
|
)
|
|
return;
|
|
detailById[scriptId] = detail;
|
|
pick.versionId = versionsFor(pick)[0]?.id ?? null;
|
|
picks = [...picks];
|
|
reconcileSelection();
|
|
} catch (error) {
|
|
if (rowRequests.get(pick.key) === current) flash = (error as Error).message;
|
|
} finally {
|
|
if (rowRequests.get(pick.key) === current)
|
|
loadingVersions = new Set([...loadingVersions].filter((key) => key !== pick.key));
|
|
}
|
|
}
|
|
function toggleServer(name: string) {
|
|
const next = new Set(pickedServers);
|
|
if (next.has(name)) next.delete(name);
|
|
else next.add(name);
|
|
pickedServers = next;
|
|
}
|
|
function selectVisible(select: boolean) {
|
|
const next = new Set(pickedServers);
|
|
for (const server of visibleServers) {
|
|
if (select) next.add(server.name);
|
|
else next.delete(server.name);
|
|
}
|
|
pickedServers = next;
|
|
}
|
|
async function move(next: number) {
|
|
flash = '';
|
|
if (next > step && step === 2) {
|
|
if (!(await planBuilder?.validate())) return;
|
|
try {
|
|
parseBetaPlan(testPlan);
|
|
} catch (error) {
|
|
flash = (error as Error).message;
|
|
return;
|
|
}
|
|
}
|
|
step = next;
|
|
await tick();
|
|
stepHeading?.focus();
|
|
}
|
|
async function openBeta(event: Event) {
|
|
event.preventDefault();
|
|
if (step !== 3 || forwardReason || openingBeta) 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
|
|
})),
|
|
...(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 {
|
|
// Draft cleanup is best effort when browser storage is unavailable.
|
|
}
|
|
allowLeave = true;
|
|
await goto(
|
|
contextHref(
|
|
`/betas/${body.result.betaId}`,
|
|
safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')
|
|
)
|
|
);
|
|
} catch (error) {
|
|
flash = (error as Error).message;
|
|
} finally {
|
|
openingBeta = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
|
|
<div class="page">
|
|
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
|
|
<SectionHeader
|
|
title="New beta"
|
|
subtitle="Test files across games in one beta. Choose one instance count for every game."
|
|
/>
|
|
{#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.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>
|
|
<div class="draft-status">
|
|
{#if storageUnavailable}<span role="alert"
|
|
>Draft saving is unavailable in this browser. Keep this page open to preserve
|
|
your work.</span
|
|
>{:else}<span>Unfinished changes save in this browser.</span>{/if}{#if dirty}<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onclick={async () => {
|
|
if (
|
|
await confirmAction({
|
|
title: 'Discard beta draft?',
|
|
message: 'Clear the selected files, servers, and custom test plan?',
|
|
confirmLabel: 'Discard draft',
|
|
danger: true
|
|
})
|
|
)
|
|
discardDraft();
|
|
}}>Discard draft</Button
|
|
>{/if}
|
|
</div>
|
|
<form onsubmit={openBeta} class="beta-form">
|
|
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
|
{#if step === 0}
|
|
<label
|
|
>Beta label (optional)<input
|
|
bind:value={betaLabel}
|
|
maxlength="120"
|
|
placeholder="For example, Town balance update"
|
|
/><span class="muted"
|
|
>Shown in the beta list so others can identify this test.</span
|
|
></label
|
|
>
|
|
<div class="picks">
|
|
{#each picks as pick, index (pick.key)}<section class="pick-row">
|
|
<label
|
|
>File {index + 1}<select
|
|
bind:value={pick.scriptId}
|
|
onchange={() => onPickFile(index)}
|
|
><option value={null}>Choose a file</option
|
|
>{#each eligibleFor(index) as file}<option value={file.id}
|
|
>[{file.kind}] {file.name}</option
|
|
>{/each}</select
|
|
></label
|
|
>
|
|
<label
|
|
>Exact version<select
|
|
bind:value={pick.versionId}
|
|
disabled={pick.scriptId === null ||
|
|
loadingVersions.has(pick.key)}
|
|
><option value={null}
|
|
>{loadingVersions.has(pick.key)
|
|
? 'Loading versions…'
|
|
: 'Choose a version'}</option
|
|
>{#each versionsFor(pick) as version}<option value={version.id}
|
|
>{artifactVersionLabel(version)} · {version.sha256.slice(
|
|
0,
|
|
8
|
|
)} · {version.state.replaceAll('-', ' ')}</option
|
|
>{/each}</select
|
|
></label
|
|
>
|
|
<Button
|
|
ariaLabel={`Remove file ${index + 1}`}
|
|
disabled={picks.length === 1}
|
|
onclick={() => {
|
|
picks = picks.filter((p) => p.key !== pick.key);
|
|
reconcileSelection();
|
|
}}>Remove</Button
|
|
>
|
|
{#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">
|
|
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}
|
|
{#if selectionMode === 'automatic'}
|
|
<div class="automatic-selection">
|
|
<h3>Instances that must pass</h3>
|
|
<p>
|
|
At launch, Electric Cherry chooses compatible servers with the fewest
|
|
current players. Every requested instance must pass the test plan.
|
|
</p>
|
|
<label class="instance-count"
|
|
>Instances per game<input
|
|
type="number"
|
|
min="1"
|
|
max="1000"
|
|
step="1"
|
|
value={instanceCount ?? ''}
|
|
oninput={(event) =>
|
|
(instanceCount =
|
|
event.currentTarget.value === ''
|
|
? null
|
|
: event.currentTarget.valueAsNumber)}
|
|
/><span class="muted"
|
|
>The same number applies to every game in this beta.</span
|
|
></label
|
|
>
|
|
<p class="muted">
|
|
{applicableGames
|
|
.map(
|
|
(game) =>
|
|
`${gameLabel(game)}: ${countLabel(candidates.filter((server) => server.game === game).length, 'compatible registered server')}`
|
|
)
|
|
.join(' · ')}
|
|
</p>
|
|
<p class="muted">
|
|
Servers are selected when you start the beta, using fresh player counts
|
|
and availability checks. Restarts wait for confirmed empty lobbies.
|
|
</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">
|
|
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
|
|
? `${countLabel(server.last_players, 'player')} · ${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}
|
|
</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>
|
|
{countLabel(count, 'server')} 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']):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: var(--text-lg);
|
|
}
|
|
h3 {
|
|
font-size: var(--text-md);
|
|
}
|
|
p,
|
|
li {
|
|
font-size: var(--text-base);
|
|
line-height: 1.6;
|
|
}
|
|
.muted,
|
|
.draft-status {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-sm);
|
|
}
|
|
.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: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
label {
|
|
display: grid;
|
|
gap: 6px;
|
|
font-size: var(--text-sm);
|
|
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-count {
|
|
max-width: 400px;
|
|
}
|
|
.advanced-selection {
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
padding: 16px;
|
|
}
|
|
.advanced-selection summary {
|
|
cursor: pointer;
|
|
font-size: var(--text-base);
|
|
}
|
|
.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: var(--text-base);
|
|
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: var(--text-xs);
|
|
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;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.pick-row {
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
}
|
|
.pick-row label:first-child {
|
|
grid-column: 1/-1;
|
|
}
|
|
.steps {
|
|
gap: 10px;
|
|
}
|
|
.steps li {
|
|
font-size: var(--text-xs);
|
|
}
|
|
}
|
|
</style>
|