Refresh script library with batch uploads and beta handoff
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
|
||||
|
||||
export interface LibraryVersion {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||
}
|
||||
export interface LibraryScript {
|
||||
id: number; name: string; target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
currentVersion: LibraryVersion | null; latestVersion: LibraryVersion | null;
|
||||
versionCount: number; servers: string[]; runningBetaId: number | null;
|
||||
}
|
||||
export type ScriptStatus = 'untested' | 'testing' | 'tested' | 'failed' | 'promoted' | 'empty';
|
||||
export const statusLabels: Record<ScriptStatus, string> = {
|
||||
untested: 'Needs testing', testing: 'Beta active', tested: 'Test passed',
|
||||
failed: 'Test failed', promoted: 'Promoted', empty: 'No versions'
|
||||
};
|
||||
export function scriptStatus(s: LibraryScript): ScriptStatus {
|
||||
if (s.runningBetaId !== null) return 'testing';
|
||||
if (!s.latestVersion) return 'empty';
|
||||
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
||||
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
||||
}
|
||||
export function canTest(s: LibraryScript): boolean {
|
||||
return s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id;
|
||||
}
|
||||
export function mapLabel(canonical: string): string {
|
||||
const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter(m => m.canonical === canonical);
|
||||
return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical;
|
||||
}
|
||||
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
|
||||
return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase()
|
||||
: s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
|
||||
}
|
||||
export interface BetaSelection { scriptId: number; versionId: number; }
|
||||
export function betaSelectionHref(selections: BetaSelection[]): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
|
||||
return `/betas?${query}`;
|
||||
}
|
||||
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
|
||||
const values = query.getAll('artifact');
|
||||
if (!values.length && query.has('pluginId')) values.push(`${query.get('pluginId')}:${query.get('versionId')}`);
|
||||
const seen = new Set<number>();
|
||||
return values.map(value => {
|
||||
if (!/^[1-9]\d*:[1-9]\d*$/.test(value)) throw new Error('The beta selection link is invalid. Choose artifacts below.');
|
||||
const [scriptId, versionId] = value.split(':').map(Number);
|
||||
if (!Number.isSafeInteger(scriptId) || !Number.isSafeInteger(versionId) || seen.has(scriptId)) {
|
||||
throw new Error('The beta selection link contains invalid or repeated artifacts. Choose artifacts below.');
|
||||
}
|
||||
seen.add(scriptId);
|
||||
return { scriptId, versionId };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
|
||||
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
|
||||
interface Duplicate { scriptId: number; versionId: number; version: number; }
|
||||
interface Upload {
|
||||
key: number; file: File; target: number | 'new' | ''; name: string;
|
||||
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
|
||||
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
||||
}
|
||||
let dialog: HTMLDialogElement;
|
||||
let input: HTMLInputElement;
|
||||
let queue = $state<Upload[]>([]);
|
||||
let busy = $state(false);
|
||||
let notes = $state('');
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
||||
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||
let scopeLoaded = $state(false);
|
||||
let message = $state('');
|
||||
let preferredTarget = $state<number | null>(null);
|
||||
let nextKey = 0;
|
||||
const pending = $derived(queue.filter(q => !q.result));
|
||||
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
||||
const scopeValue = $derived(scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : '');
|
||||
const matches = $derived(servers.filter(s => scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue));
|
||||
const ready = $derived(pending.length > 0 && !busy && !inspecting && pending.every(q => q.inspected && !validation(q))
|
||||
&& (!hasNew || scopeType === 'none' || scopeLoaded && !!scopeValue));
|
||||
const results = $derived(queue.flatMap(q => q.result ? [q.result] : []));
|
||||
const testable = $derived(results.filter(r => {
|
||||
const s = scripts.find(s => s.id === r.scriptId);
|
||||
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
|
||||
}));
|
||||
|
||||
export function open(scriptId?: number) {
|
||||
// Reopening preserves unfinished work; a targeted upload starts a fresh review
|
||||
// only when there is no pending file to lose.
|
||||
if (!pending.length) { queue = []; notes = ''; message = ''; preferredTarget = scriptId ?? null; }
|
||||
dialog.showModal();
|
||||
if (!scopeLoaded) void loadScope();
|
||||
}
|
||||
async function loadScope() {
|
||||
try {
|
||||
const [scopeRes, serversRes] = await Promise.all([fetch('/api/scope-options'), fetch('/api/servers')]);
|
||||
if (!scopeRes.ok || !serversRes.ok) throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
|
||||
scopeOptions = await scopeRes.json(); servers = (await serversRes.json()).servers;
|
||||
scopeLoaded = true;
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
}
|
||||
function matchingScripts(file: File) {
|
||||
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||
}
|
||||
export async function addFiles(files: File[]) {
|
||||
if (busy || !files.length) return;
|
||||
if (!dialog.open) open();
|
||||
for (const file of files) {
|
||||
const matches = matchingScripts(file);
|
||||
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||
queue = [...queue, item];
|
||||
// Work with the reactive proxy, rather than the original object.
|
||||
await inspect(queue.find(q => q.key === item.key)!);
|
||||
}
|
||||
preferredTarget = null;
|
||||
}
|
||||
async function inspect(item: Upload) {
|
||||
item.inspecting = true; item.error = '';
|
||||
try {
|
||||
const form = new FormData(); form.append('content', item.file);
|
||||
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||
item.duplicates = body.duplicates; item.inspected = true;
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
finally { item.inspecting = false; }
|
||||
}
|
||||
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||
function validation(item: Upload): string {
|
||||
if (item.target === '') return 'Several scripts use this filename. Choose which script to update.';
|
||||
if (item.target === 'new') {
|
||||
if (!item.name.trim()) return 'Give this script a name.';
|
||||
if (scripts.some(s => s.name === item.name.trim())) return 'This name already exists. Choose a new name or update that script.';
|
||||
if (queue.some(q => q.key !== item.key && !q.result && q.target === 'new' && q.name.trim() === item.name.trim())) return 'Use a distinct name for each new script.';
|
||||
} else {
|
||||
const script = scripts.find(s => s.id === item.target);
|
||||
if (!script) return 'This script is no longer available.';
|
||||
if (script.runningBetaId !== null) return 'Finish the active beta before uploading another version.';
|
||||
if (queue.some(q => q.key !== item.key && !q.result && q.target === item.target)) return 'Choose one file per existing script in this batch.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
async function upload(prepareBeta: boolean) {
|
||||
if (!ready) return;
|
||||
busy = true; message = '';
|
||||
try {
|
||||
for (const item of queue.filter(q => !q.result)) {
|
||||
item.error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('content', item.file); form.append('notes', notes.trim());
|
||||
const creating = item.target === 'new';
|
||||
if (creating) {
|
||||
form.append('name', item.name.trim()); form.append('scopeType', scopeType);
|
||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||
} else form.append('reuseExisting', 'true');
|
||||
const res = await fetch(creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`, { method: 'POST', body: form });
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Upload failed. Try again.');
|
||||
item.result = { scriptId: creating ? body.script.id : item.target as number,
|
||||
versionId: creating ? body.versionId : body.version.id,
|
||||
version: creating ? 1 : body.version.version, reused: !!body.reused };
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
}
|
||||
await onuploaded();
|
||||
if (queue.some(q => !q.result)) message = 'Some files need attention. Successful uploads are saved; retry only the remaining files.';
|
||||
else {
|
||||
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
|
||||
// Read from refreshed props after Svelte applies the parent update.
|
||||
await tick();
|
||||
if (testable.length !== results.length) message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
|
||||
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||
}
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
|
||||
<div class="drawer">
|
||||
<header><div><span class="eyebrow">Prepare your next release</span><h2 id="upload-title">Upload scripts</h2></div><Button variant="ghost" ariaLabel="Close upload drawer" disabled={busy || inspecting} onclick={() => close()}>✕</Button></header>
|
||||
<p class="intro">Drop files anywhere or choose them below. Review each destination before uploading.</p>
|
||||
<div class="dropzone">
|
||||
<strong>{preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'}</strong>
|
||||
<span>Existing filenames are recognized automatically.</span>
|
||||
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button>
|
||||
<input class="file-input" bind:this={input} type="file" multiple aria-label="Choose script files" onchange={event => { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} />
|
||||
</div>
|
||||
<div class="queue" aria-live="polite">
|
||||
{#each queue as item (item.key)}
|
||||
<section class="upload-item" aria-label={item.file.name}>
|
||||
<div class="file-heading"><strong>{item.file.name}</strong><span>{(item.file.size / 1024).toFixed(1)} KB</span>
|
||||
<Button variant="ghost" size="sm" disabled={busy || inspecting} ariaLabel={`Remove ${item.file.name}`} onclick={() => queue = queue.filter(q => q.key !== item.key)}>Remove</Button>
|
||||
</div>
|
||||
{#if item.result}
|
||||
<p class="success">{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
|
||||
{:else}
|
||||
<label>Destination<select bind:value={item.target} disabled={busy || item.inspecting} onchange={() => item.error = ''}>
|
||||
<option value="" disabled>Choose a script…</option><option value="new">Create a new script</option>
|
||||
{#each scripts as s}<option value={s.id}>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null ? ' · beta active' : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
|
||||
{:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}<p class="note">Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}</p>{/if}
|
||||
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||
{:else if duplicate(item)}<p class="success">Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.</p>
|
||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">New version for {scripts.find(s => s.id === item.target)?.name}</p>{/if}
|
||||
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
|
||||
{#if item.error}<p class="error" role="alert">{item.error}</p>{#if !item.inspected}<Button size="sm" disabled={busy || inspecting} onclick={() => inspect(item)}>Retry inspection</Button>{/if}{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if pending.length}
|
||||
<section class="shared">
|
||||
<h3>Shared upload details</h3>
|
||||
<label>Version notes<textarea bind:value={notes} disabled={busy} rows="3" placeholder="What changed in these files?"></textarea></label>
|
||||
{#if hasNew}
|
||||
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
|
||||
{#if scopeType !== 'none'}
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{g.toUpperCase()}</option>{/each}</select></label>
|
||||
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
|
||||
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
|
||||
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival, Grief, or other variants.</p>{/if}
|
||||
{:else}<p class="note">Unscoped scripts deploy only to explicitly selected beta targets.</p>{/if}
|
||||
<p class="note">Existing scripts keep their current scope.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; border: 0; border-left: 1px solid var(--border-subtle); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: #0009; backdrop-filter: blur(3px); }
|
||||
.drawer { padding: 28px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; box-sizing: border-box; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { margin: 6px 0 0; font-size: var(--text-2xl); } h3 { margin: 0; font-size: var(--text-md); }
|
||||
.eyebrow { text-transform: uppercase; letter-spacing: .1em; color: var(--accent-primary); font-size: var(--text-xs); }
|
||||
.intro, .note { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
|
||||
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 28px; background: var(--surface-sunken); }
|
||||
.dropzone span { font-size: var(--text-xs); color: var(--text-secondary); } .file-input { display: none; }
|
||||
.queue, .shared { display: flex; flex-direction: column; gap: 16px; } .upload-item { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.file-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .file-heading strong { flex: 1; overflow-wrap: anywhere; } .file-heading span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
label { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); background: var(--surface-sunken); font: inherit; font-size: var(--text-sm); } textarea { resize: vertical; }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
|
||||
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||
</style>
|
||||
@@ -36,8 +36,13 @@ export async function GET({ url }: { url: URL }) {
|
||||
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(s.id);
|
||||
const current = versions.find(v => v.id === s.current_version_id);
|
||||
return {
|
||||
...s,
|
||||
currentVersion: current ? {
|
||||
id: current.id, version: current.version, sha256: current.sha256,
|
||||
size: current.size, state: current.state, uploaded_at: current.uploaded_at
|
||||
} : null,
|
||||
latestVersion: versions[0]
|
||||
? {
|
||||
id: versions[0].id,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
const n = Number.parseInt(idRaw, 10);
|
||||
@@ -21,6 +24,15 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
if (script.kind === 'script' && form.get('reuseExisting') === 'true') {
|
||||
if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||
if (findRunningBetaForScript(scriptId)) {
|
||||
return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { status: 409 });
|
||||
}
|
||||
const sha = createHash('sha256').update(content).digest('hex');
|
||||
const existing = db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1').get(scriptId, sha);
|
||||
if (existing) return json({ ok: true, version: existing, reused: true });
|
||||
}
|
||||
try {
|
||||
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
|
||||
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
// Read-only inspection works on HTTP fleet consoles as well as secure origins.
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const form = await request.formData();
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||
}
|
||||
const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex');
|
||||
const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
|
||||
FROM script_versions v JOIN scripts s ON s.id = v.script_id
|
||||
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256);
|
||||
return json({ sha256, duplicates });
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { parseBetaSelection } from '$lib/script-library.js';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -61,6 +62,7 @@
|
||||
fetch('/api/scripts'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
if (!betasRes.ok || !scriptsRes.ok || !serversRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
|
||||
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
|
||||
betas = bBody.betas;
|
||||
if (scriptsRes.ok) {
|
||||
@@ -71,7 +73,6 @@
|
||||
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
fleetServers = svBody.servers;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
async function refreshBetas(): Promise<void> {
|
||||
const res = await fetch('/api/betas');
|
||||
@@ -82,7 +83,7 @@
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; });
|
||||
void load().then(seedSelection).catch(error => { flash = (error as Error).message; }).finally(() => { loading = false; });
|
||||
poll = setInterval(refreshBetas, 5000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
@@ -91,7 +92,8 @@
|
||||
|
||||
const eligibleScripts = $derived(
|
||||
scripts.filter(
|
||||
(s) => s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id
|
||||
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|
||||
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -102,24 +104,26 @@
|
||||
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
||||
}
|
||||
|
||||
async function seedImportedPlugin(): Promise<void> {
|
||||
async function seedSelection(): Promise<void> {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
if (!query.has('pluginId')) return;
|
||||
const pluginId = Number(query.get('pluginId'));
|
||||
const versionId = Number(query.get('versionId'));
|
||||
if (!eligibleScripts.some(s => s.id === pluginId && s.kind === 'plugin')) {
|
||||
flash = 'This plugin is already promoted or has a running beta. Open its detail page to review its status.';
|
||||
return;
|
||||
const selections = parseBetaSelection(query);
|
||||
if (!selections.length) return;
|
||||
// Validate all selections before populating; never substitute a newer upload.
|
||||
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
|
||||
const res = await fetch(`/api/scripts/${scriptId}`);
|
||||
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
|
||||
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
|
||||
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|
||||
|| !detail.versions.some(v => v.id === versionId)) {
|
||||
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
|
||||
}
|
||||
picks = [{ key: nextKey++, scriptId: pluginId, versionId: null }];
|
||||
await onPickScript(0);
|
||||
if (!versionsForRow(picks[0]).some(v => v.id === versionId)) {
|
||||
picks[0].versionId = null;
|
||||
flash = 'The imported version is no longer available for testing. Choose a version to continue.';
|
||||
return;
|
||||
return detail;
|
||||
}));
|
||||
for (const detail of details) {
|
||||
detailById[detail.script.id] = detail;
|
||||
}
|
||||
picks[0].versionId = versionId;
|
||||
picks = [...picks];
|
||||
picks = selections.map(s => ({ key: nextKey++, ...s }));
|
||||
pickedServers = new Set();
|
||||
}
|
||||
|
||||
async function onPickScript(rowIdx: number): Promise<void> {
|
||||
@@ -246,6 +250,8 @@
|
||||
subtitle="Test unpromoted versions on selected servers before rolling them out."
|
||||
/>
|
||||
|
||||
{#if flash}<p class="flash" role="alert">{flash}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else}
|
||||
@@ -336,7 +342,6 @@
|
||||
>
|
||||
Open beta
|
||||
</Button>
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
+140
-360
@@ -1,387 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.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';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
||||
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
||||
import { zombieMaps } from '$lib/map-aliases.js';
|
||||
|
||||
interface ScriptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||||
latestVersion: {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||
|
||||
let scripts = $state<ScriptRow[]>([]);
|
||||
let scripts = $state<LibraryScript[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
|
||||
|
||||
let newName = $state('');
|
||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let newScopeGame = $state('');
|
||||
let newScopeMap = $state('');
|
||||
let newFile = $state<File | null>(null);
|
||||
let newNotes = $state('');
|
||||
|
||||
const scopeValue = $derived.by(() => {
|
||||
if (newScopeType === 'game') return newScopeGame;
|
||||
if (newScopeType === 'map') return newScopeMap;
|
||||
return '';
|
||||
});
|
||||
|
||||
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||
const matchingServers = $derived.by(() => {
|
||||
if (newScopeType === 'none') return null;
|
||||
if (!scopeValue) return null;
|
||||
return serverCache
|
||||
.filter((s) => (newScopeType === 'game' ? s.game : s.map) === scopeValue)
|
||||
.map((s) => s.name);
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
|
||||
fetch('/api/scripts?kind=script'),
|
||||
fetch('/api/scope-options'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
|
||||
scripts = body.scripts;
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
|
||||
serverCache = sBody.servers;
|
||||
loading = false;
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function pickFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
newFile = t.files?.[0] ?? null;
|
||||
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
async function createScript(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading || !newFile || !newName.trim()) return;
|
||||
if (newScopeType !== 'none' && !scopeValue) {
|
||||
flash = `Pick a ${newScopeType}`;
|
||||
return;
|
||||
}
|
||||
uploading = true; flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', newName.trim());
|
||||
form.append('scopeType', newScopeType);
|
||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
||||
form.append('content', newFile);
|
||||
try {
|
||||
const res = await fetch('/api/scripts', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean; error?: string;
|
||||
script?: { id: number; name: string };
|
||||
versionId?: number;
|
||||
};
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
successBanner = body.script
|
||||
? { name: body.script.name, scriptId: body.script.id, version: 1 }
|
||||
: null;
|
||||
newName = ''; newScopeType = 'none';
|
||||
newScopeGame = ''; newScopeMap = '';
|
||||
newNotes = ''; newFile = null;
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(s: ScriptRow): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function detailHref(s: ScriptRow): string {
|
||||
if (s.kind === 'config' || s.kind === 'baseline') return `/configs/${s.id}`;
|
||||
if (s.kind === 'plugin') return `/plugins/${s.id}`;
|
||||
return `/scripts/${s.id}`;
|
||||
}
|
||||
function stateVariantFor(s: ScriptRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
|
||||
if (!s.latestVersion) return null;
|
||||
if (s.current_version_id === null) return 'neutral';
|
||||
if (s.latestVersion.id !== s.current_version_id) {
|
||||
if (s.latestVersion.state === 'untested') return 'neutral';
|
||||
if (s.latestVersion.state === 'tested') return 'online';
|
||||
if (s.latestVersion.state === 'test-failed') return 'danger';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function stateLabel(s: ScriptRow): string {
|
||||
if (!s.latestVersion) return '';
|
||||
if (s.current_version_id === null) return 'untested';
|
||||
if (s.latestVersion.id !== s.current_version_id) {
|
||||
return `v${s.latestVersion.version} ${s.latestVersion.state.replace('-', ' ')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
{ value: '', label: '— Pick a game —', disabled: true },
|
||||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
||||
]);
|
||||
let mapOptions = $derived([
|
||||
{ value: '', label: '— Pick a map —', disabled: true },
|
||||
...(scopeOptions.mapsByGame[newScopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
||||
]);
|
||||
let scopeTypeOptions = [
|
||||
{ value: 'none', label: 'Unscoped' },
|
||||
{ value: 'game', label: 'Game' },
|
||||
{ value: 'map', label: 'Map' }
|
||||
let error = $state('');
|
||||
let search = $state('');
|
||||
let status = $state<ScriptStatus | 'all'>('all');
|
||||
let game = $state('');
|
||||
let map = $state('');
|
||||
let sort = $state('attention');
|
||||
let selected = $state<number[]>([]);
|
||||
let uploader: ScriptUploadDrawer;
|
||||
let dragDepth = $state(0);
|
||||
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'All scripts' }, { value: 'untested', label: 'Needs testing' },
|
||||
{ value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' },
|
||||
{ value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' }
|
||||
];
|
||||
const priority = { failed: 0, untested: 1, testing: 2, tested: 3, promoted: 4, empty: 5 };
|
||||
const maps = $derived([...new Set(scripts.filter(s => s.scope_type === 'map' && (!game || zombieMaps(game).some(m => m.canonical === s.scope_value))).map(s => s.scope_value!))].sort());
|
||||
const visible = $derived(scripts.filter(s => {
|
||||
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
||||
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
||||
&& (status === 'all' || scriptStatus(s) === status)
|
||||
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
||||
}).sort((a, b) => {
|
||||
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
||||
if (sort === 'attention') return priority[scriptStatus(a)] - priority[scriptStatus(b)] || a.name.localeCompare(b.name);
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||
const selectable = $derived(visible.filter(canTest));
|
||||
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/scripts?kind=script');
|
||||
if (!res.ok) throw new Error('Could not load scripts. Try again.');
|
||||
scripts = (await res.json()).scripts;
|
||||
selected = selected.filter(id => scripts.some(s => s.id === id && canTest(s)));
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
||||
function selectVisible() {
|
||||
const ids = selectable.map(s => s.id);
|
||||
selected = allSelected ? selected.filter(id => !ids.includes(id)) : [...new Set([...selected, ...ids])];
|
||||
}
|
||||
function betaHref(rows: LibraryScript[]) { return betaSelectionHref(rows.map(s => ({ scriptId: s.id, versionId: s.latestVersion!.id }))); }
|
||||
function resetFilters() { search = ''; status = 'all'; game = ''; map = ''; }
|
||||
function onDrag(event: DragEvent) { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); }
|
||||
function onDrop(event: DragEvent) {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault(); dragDepth = 0;
|
||||
void uploader.addFiles([...event.dataTransfer.files]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
||||
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
||||
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Scripts" subtitle="Upload a script, then open a beta to test it before promotion." />
|
||||
|
||||
<!-- Upload form -->
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Upload a script</h2>
|
||||
|
||||
{#if successBanner}
|
||||
<div class="banner">
|
||||
<strong>v{successBanner.version} uploaded — untested.</strong>
|
||||
<span>
|
||||
Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a>
|
||||
to test it before it can be promoted.
|
||||
</span>
|
||||
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="status-grid" aria-label="Filter by script status">
|
||||
{#each tabs as tab}
|
||||
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
|
||||
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={createScript} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<Field label="Scope" hint="Where this script deploys. Unscoped ships wherever a beta targets it.">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||
{/if}
|
||||
<section class="library" aria-label="Script library">
|
||||
<div class="toolbar">
|
||||
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
|
||||
</div>
|
||||
{#if newScopeType !== 'none' && scopeValue}
|
||||
{@const matches = matchingServers ?? []}
|
||||
{#if matches.length === 0}
|
||||
<div class="preview warn">No matching servers — the promoted script will land nowhere.</div>
|
||||
{:else}
|
||||
<div class="preview ok">
|
||||
{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}
|
||||
<div class="list-heading">
|
||||
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
|
||||
<span>{visible.length} of {scripts.length} scripts</span>
|
||||
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<Field label="File">
|
||||
<Input type="file" onchange={pickFile} disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !newFile || !newName}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload script
|
||||
</Button>
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- Script list -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>All scripts</h2>
|
||||
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if scripts.length === 0}
|
||||
<EmptyState title="No scripts yet" description="Upload one above to get started." />
|
||||
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
||||
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
||||
{:else if !scripts.length && !error}
|
||||
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
|
||||
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
|
||||
{:else if !visible.length && !error}
|
||||
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each scripts as s (s.id)}
|
||||
<a class="row" href={detailHref(s)}>
|
||||
<div class="row-top">
|
||||
<span class="name">{s.name}</span>
|
||||
<Badge
|
||||
variant={s.scope_type === 'game' ? 'info' :
|
||||
s.scope_type === 'map' ? 'warning' : 'neutral'}
|
||||
size="sm"
|
||||
>
|
||||
{scopeLabel(s)}
|
||||
</Badge>
|
||||
{#if stateVariantFor(s)}
|
||||
{@const v = stateVariantFor(s)!}
|
||||
<Badge variant={v} size="sm">{stateLabel(s)}</Badge>
|
||||
{/if}
|
||||
{#if s.runningBetaId}
|
||||
<Badge variant="warning" size="sm">β{s.runningBetaId} running</Badge>
|
||||
{/if}
|
||||
{#each visible as s (s.id)}
|
||||
{@const state = scriptStatus(s)}
|
||||
<article class="script-row" class:selected={selected.includes(s.id)}>
|
||||
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
|
||||
<div class="script-info">
|
||||
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></div>
|
||||
<div class="path">{s.target_rel_path}</div>
|
||||
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="path">{s.target_rel_path}</span>
|
||||
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
|
||||
{#if s.servers.length}
|
||||
<span>{s.servers.length} matching server{s.servers.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
<div class="versions">
|
||||
<span>{s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'}</span>
|
||||
{#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if}
|
||||
</div>
|
||||
</a>
|
||||
<div class="row-action">
|
||||
{#if s.runningBetaId !== null}<Button size="sm" href="/betas/{s.runningBetaId}">View beta →</Button>
|
||||
{:else if state === 'failed'}<Button size="sm" href="/scripts/{s.id}">Review version</Button>
|
||||
{:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button>
|
||||
{:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
|
||||
</section>
|
||||
{#if selectedScripts.length}
|
||||
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||
<strong>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
|
||||
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
||||
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dragDepth > 0}<div class="drop-overlay"><strong>Drop your scripts here</strong><span>We’ll recognize existing scripts and check for duplicate versions.</span></div>{/if}
|
||||
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.banner a { color: var(--state-online); text-decoration: underline; }
|
||||
|
||||
.upload-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.scope-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||||
.preview {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.preview.ok { background: var(--surface-sunken); color: var(--text-secondary); }
|
||||
.preview.warn { background: rgba(255, 176, 32, 0.06); color: var(--state-warning); border: 1px solid rgba(255, 176, 32, 0.28); }
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.flash {
|
||||
color: var(--state-danger);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
|
||||
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.row-top {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.name {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.row-meta {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.path { color: var(--text-secondary); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.upload-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-5); padding-bottom: 90px; }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-2); }
|
||||
.status-grid button { text-align: left; display: flex; flex-direction: column; gap: 12px; padding: 18px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); cursor: pointer; font: inherit; }
|
||||
.status-grid button.active { border-color: var(--accent-primary); background: color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
|
||||
.status-grid span { font-size: var(--text-xs); } .status-grid strong { color: var(--text-primary); font-size: var(--text-2xl); }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; } .toolbar label { min-width: 0; }
|
||||
input[type='search'], select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
input[type='checkbox'] { accent-color: var(--accent-primary); width: 16px; height: 16px; flex-shrink: 0; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.list-heading { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; padding: 20px 4px 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.select-all { display: flex; align-items: center; gap: 10px; } .text-button { background: none; border: 0; color: var(--accent-primary); cursor: pointer; font: inherit; }
|
||||
.rows { display: flex; flex-direction: column; gap: 8px; } .script-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(160px, auto) auto; gap: 20px; align-items: center; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.script-row.selected { border-color: var(--accent-primary); } .name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.name { font-weight: 600; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; } .name:hover { color: var(--accent-primary); }
|
||||
.path { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 6px; overflow-wrap: anywhere; }
|
||||
.meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 12px; }
|
||||
.versions { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); } .versions strong { font-weight: 500; color: var(--text-primary); }
|
||||
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
||||
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
|
||||
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
|
||||
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
|
||||
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { betaSelectionHref, canTest, mapLabel, parseBetaSelection, scriptStatus, type LibraryScript } from '../../src/lib/script-library.js';
|
||||
|
||||
const script: LibraryScript = {
|
||||
id: 1, name: 'HUD', target_rel_path: 'hud.gsc', scope_type: null, scope_value: null,
|
||||
current_version_id: null, currentVersion: null, versionCount: 1, servers: [], runningBetaId: null,
|
||||
latestVersion: { id: 10, version: 1, sha256: 'abc', size: 12, state: 'untested', uploaded_at: '2026-09-18' }
|
||||
};
|
||||
|
||||
describe('script library state', () => {
|
||||
it.each([['untested', 'untested'], ['tested', 'tested'], ['test-failed', 'failed']] as const)(
|
||||
'preserves %s for a never-promoted version', (state, expected) => {
|
||||
expect(scriptStatus({ ...script, latestVersion: { ...script.latestVersion!, state } })).toBe(expected);
|
||||
});
|
||||
it('distinguishes an active beta, a promoted release, and a newer upload', () => {
|
||||
expect(scriptStatus({ ...script, runningBetaId: 5 })).toBe('testing');
|
||||
expect(canTest({ ...script, runningBetaId: 5 })).toBe(false);
|
||||
expect(scriptStatus({ ...script, current_version_id: 10 })).toBe('promoted');
|
||||
expect(canTest({ ...script, current_version_id: 10 })).toBe(false);
|
||||
expect(scriptStatus({ ...script, current_version_id: 9 })).toBe('untested');
|
||||
expect(canTest({ ...script, current_version_id: 9 })).toBe(true);
|
||||
expect(scriptStatus({ ...script, latestVersion: null })).toBe('empty');
|
||||
});
|
||||
it('does not describe shared T6 engine maps as a single variant', () => {
|
||||
expect(mapLabel('zombie_moon')).toBe('Moon');
|
||||
expect(mapLabel('zm_transit')).toMatch(/variants/);
|
||||
expect(mapLabel('custom_map')).toBe('custom_map');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beta selection links', () => {
|
||||
it('round-trips exact versions in a multi-script selection', () => {
|
||||
const picks = [{ scriptId: 4, versionId: 20 }, { scriptId: 7, versionId: 25 }];
|
||||
expect(parseBetaSelection(new URL(betaSelectionHref(picks), 'http://localhost').searchParams)).toEqual(picks);
|
||||
});
|
||||
it('preserves existing plugin import links', () => {
|
||||
expect(parseBetaSelection(new URLSearchParams('pluginId=8&versionId=32'))).toEqual([{ scriptId: 8, versionId: 32 }]);
|
||||
});
|
||||
it.each(['artifact=0:1', 'artifact=1:NaN', 'artifact=1:2junk', 'artifact=1:2&artifact=1:3', 'artifact=9007199254740992:1', 'pluginId=8'])(
|
||||
'rejects malformed or ambiguous selections: %s', query => {
|
||||
expect(() => parseBetaSelection(new URLSearchParams(query))).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules(); fixture = await newFixture();
|
||||
delete process.env.KOMODO_ADDRESS; delete process.env.RCON_PASSWORD;
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
function request(content: string, reuse = false) {
|
||||
const form = new FormData(); form.append('content', new File([content], 'hud.gsc'));
|
||||
if (reuse) form.append('reuseExisting', 'true');
|
||||
return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form });
|
||||
}
|
||||
async function seed() {
|
||||
const mod = await import('../../src/lib/server/fleet/scripts.js');
|
||||
return { ...mod, ...mod.createScript({ name: 'HUD', targetRelPath: 'hud.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('main(){}') }) };
|
||||
}
|
||||
|
||||
describe('script upload review', () => {
|
||||
it('inspects content against all versions without creating records or including plugins', async () => {
|
||||
const { script, version, uploadNewVersion, createScript, listVersions } = await seed();
|
||||
uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||
createScript({ name: 'Plugin', targetRelPath: 'plugin.dll', kind: 'plugin', content: Buffer.from('main(){}') });
|
||||
const { POST } = await import('../../src/routes/api/scripts/inspect/+server.js');
|
||||
const res = await POST({ request: request('main(){}') });
|
||||
expect(await res.json()).toEqual({ sha256: version.sha256, duplicates: [{ scriptId: script.id, versionId: version.id, version: 1 }] });
|
||||
expect(listVersions(script.id)).toHaveLength(2);
|
||||
expect((await POST({ request: request('') })).status).toBe(400);
|
||||
});
|
||||
it('reuses identical historical content and creates changed versions while preserving scope', async () => {
|
||||
const { script, version, uploadNewVersion, listVersions, getScript } = await seed();
|
||||
uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||
const invoke = (content: string) => POST({ params: { id: String(script.id) }, request: request(content, true) });
|
||||
const res = await invoke('main(){}');
|
||||
expect(await res.json()).toMatchObject({ ok: true, reused: true, version: { id: version.id, version: 1 } });
|
||||
expect(listVersions(script.id)).toHaveLength(2);
|
||||
const added = await invoke('third(){}');
|
||||
expect(await added.json()).toMatchObject({ ok: true, version: { version: 3 } });
|
||||
expect(listVersions(script.id)).toHaveLength(3);
|
||||
expect(getScript(script.id)).toMatchObject({ scope_type: 'game', scope_value: 't5' });
|
||||
expect((await invoke('')).status).toBe(400);
|
||||
expect(listVersions(script.id)).toHaveLength(3);
|
||||
});
|
||||
it('rejects new versions when a beta becomes active after the upload preview', async () => {
|
||||
const { script, version, listVersions } = await seed();
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: ['t5_zombie_theater_1'], waitForHealth: false, waitForMatch: false });
|
||||
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||
const res = await POST({ params: { id: String(script.id) }, request: request('changed(){}', true) });
|
||||
expect(res.status).toBe(409);
|
||||
expect(listVersions(script.id)).toHaveLength(1);
|
||||
});
|
||||
it('lists promoted and latest version metadata separately without file contents', async () => {
|
||||
const { script, version, uploadNewVersion } = await seed();
|
||||
const latest = uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
const { GET } = await import('../../src/routes/api/scripts/+server.js');
|
||||
const body = await (await GET({ url: new URL('http://localhost/api/scripts?kind=script') })).json();
|
||||
expect(body.scripts[0]).toMatchObject({ currentVersion: { id: version.id, version: 1 }, latestVersion: { id: latest.id, version: 2 } });
|
||||
expect(body.scripts[0].currentVersion).not.toHaveProperty('content');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user