Improve operational UX, recovery flows, and action safety

Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
2026-09-19 21:34:34 -04:00
parent 940f81e6ca
commit 8ac420fe61
83 changed files with 2574 additions and 1190 deletions
+65 -32
View File
@@ -50,6 +50,13 @@
let artifact = $state<Artifact | null>(null);
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let installedVersions = $state<Array<{ server: string; version: number; versionId: number; recordedAt: string }>>([]);
interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; }
let scopeReviewOpen = $state(false);
let scopeImpact = $state<ScopeImpact | null>(null);
let scopePreviewError = $state('');
let scopePreviewLoading = $state(false);
let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null);
let runningBetaId = $state<number | null>(null);
let loading = $state(true);
let flash = $state<string | null>(null);
@@ -84,9 +91,24 @@
: null
);
const latestVersion = $derived(versions[0] ?? null);
const showLatestSeparately = $derived(
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
);
$effect(() => {
const type = scopeType; const value = editedScopeValue; const artifactId = id;
if (!scopeReviewOpen) return;
scopeImpact = null; scopePreviewError = '';
if (type !== 'none' && !value) { scopePreviewLoading = false; return; }
const controller = new AbortController(); scopePreviewLoading = true;
void (async () => {
try {
const response = await fetch(`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`, { signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
if (!controller.signal.aborted) scopeImpact = body;
} catch (error) { if (!controller.signal.aborted) scopePreviewError = (error as Error).message; }
finally { if (!controller.signal.aborted) scopePreviewLoading = false; }
})();
return () => controller.abort();
});
function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped';
@@ -105,7 +127,7 @@
const body = await detailRes.json();
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
if (requestedId !== id || current !== request) return;
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId; installedVersions = body.installedVersions ?? [];
if (scopes) scopeOptions = scopes;
if (seedForm) seedScopeForm();
loadError = '';
@@ -135,10 +157,12 @@
if (runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
scopeDialog?.showModal();
}
function cancelScopeModal(): void {
seedScopeForm();
scopeReviewOpen = false;
scopeDialog?.close();
}
function openUploadModal(): void {
@@ -192,7 +216,7 @@
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope) return;
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
@@ -207,12 +231,13 @@
scopeValue: scopeType === 'none' ? null : editedScopeValue
})
});
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string; detached?: string[] };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeDialog?.close();
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/campaigns?server=' + encodeURIComponent(scopeImpact.affected[0]) : '/campaigns' };
scopeReviewOpen = false; scopeDialog?.close();
await load();
} catch (error) { flash = (error as Error).message; } finally {
updatingScope = false;
@@ -244,6 +269,8 @@
];
</script>
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
@@ -258,6 +285,9 @@
{/snippet}
</SectionHeader>
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Running servers have not been changed. <a href={scopeComplete.href}>Review reconciliation →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId}
<div class="warn-banner">
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
@@ -278,7 +308,7 @@
</div>
</div>
<div class="kv-grid">
<details class="file-settings"><summary>Target file, scope, and eligible servers</summary><div class="kv-grid">
<div class="kv">
<span class="k">Target file</span>
<span class="v mono">{a.target_rel_path}</span>
@@ -295,7 +325,7 @@
</span>
</div>
<div class="kv">
<span class="k">Applies to</span>
<span class="k">Eligible servers</span>
<span class="v mono">
{#if !a.scope_type}
<span class="dim">Unscoped — deploy from a beta</span>
@@ -307,28 +337,21 @@
</span>
</div>
<div class="kv">
<span class="k">Current version</span>
<span class="k">Promoted version</span>
<span class="v mono">
{#if currentVersion}
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
<span class="dim">Never promoted — test a version first</span>
{/if}
</span>
</div>
{#if showLatestSeparately && latestVersion}
<div class="kv">
<span class="k">Latest version</span>
<span class="v mono">
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
<StatusBadge state={latestVersion.state} size="sm" />
</span>
</div>
{/if}
</div>
</div></details>
</Card>
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
@@ -346,8 +369,6 @@
{#each versions.slice(0,limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
<span class="v-size">{v.size} B</span>
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
@@ -358,6 +379,7 @@
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
{/if}
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
</div>
{/each}
</div>
@@ -374,10 +396,10 @@
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if flash}<div class="alert">{flash}</div>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<dialog aria-label="Edit scope" onclose={() => scopeReviewOpen = false} oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
@@ -387,20 +409,21 @@
<div class="scope-row">
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
{#if scopeType === 'game' || scopeType === 'map'}
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
<Select ariaLabel="Scope game" bind:value={scopeGame} onchange={() => scopeMap = ''} options={gameOptions} disabled={updatingScope} />
{/if}
{#if scopeType === 'map'}
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition. Live configuration files remain unchanged until a deployment or reconciliation.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Files remain on servers; review reconciliation to remove orphaned files.` : 'Scope describes eligibility. Saving does not install files or restart servers; review reconciliation for existing promoted files.'}</p></section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
<Button type="submit" variant="primary" disabled={updatingScope} loading={updatingScope}>
Save scope
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
@@ -433,7 +456,7 @@
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
</div>
@@ -499,7 +522,7 @@
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
.version-row {
display: grid;
grid-template-columns: minmax(100px, 1fr) 130px 80px 1fr 220px auto;
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
@@ -514,7 +537,17 @@
background: rgba(124, 212, 255, 0.04);
}
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.version-technical { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.version-technical span { display: block; margin-top: 8px; }
.scope-complete { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); font-size: var(--text-sm); }
.release-summary { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
.release-summary h2 { font-size: var(--text-lg); margin: 0 0 8px; }
.release-summary p, .installed p, .scope-impact p { color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
.scope-impact { border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); }
.installed { font-size: var(--text-sm); }
.file-settings { font-size: var(--text-sm); }
.file-settings .kv-grid { margin-top: 16px; }
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
+38 -22
View File
@@ -1,9 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$ui/components/primitives/Button.svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
import { onMount, tick } from 'svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
let { plan = $bindable<BetaPlan>(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props();
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
export async function validate(): Promise<boolean> {
validationRequested = true;
const issue = betaPlanIssues(plan, serverCount)[0];
if (!issue) return true;
if (issue.requirementId) editingId = issue.requirementId;
if (['quorum', 'waveSize'].includes(issue.field)) coverageOpen = true;
if (issue.field === 'timeoutHours') deadlineOpen = true;
await tick();
document.getElementById(`plan-${issue.field}`)?.focus();
return false;
}
const targetLabel = (kind: RequirementKind) => ({ round:'Target round', matches:'Matches required', 'populated-minutes':'Populated minutes', 'player-hours':'Player-hours required', population:'Players required', 'sustained-population':'Consecutive minutes', 'healthy-minutes':'Consecutive healthy minutes', 'failure-budget':'Maximum health outages', recovery:'Recovery restarts', manual:'Checklist items' })[kind];
let presets = $state(builtInPlans);
let presetName = $state('');
let addKind = $state<RequirementKind>('round');
@@ -45,6 +59,7 @@
message = `${requirementLabels[addKind]} added.`;
}
async function savePreset() {
if (!await validate()) return;
message = ''; error = ''; saving = true;
try {
const validated = parseBetaPlan(plan);
@@ -61,13 +76,13 @@
<section class="builder" aria-label="Beta test plan">
<header class="section-heading">
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
<p>Define the checks each qualifying server must pass.</p>
<span class="pill">Manual promotion</span>
<button type="button" class="secondary" aria-expanded={choosingPreset} aria-controls="beta-presets" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</button>
</header>
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
{#if choosingPreset}
<div class="presets" role="group" aria-label="Start from a preset">
<div class="presets" id="beta-presets" role="group" aria-label="Start from a preset">
{#each builtInPlans as preset, index (preset.name)}
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
aria-pressed={matchingPreset?.name === preset.name} aria-label={`Use ${preset.name} preset`} onclick={() => applyPreset(preset.name)}>
@@ -90,6 +105,7 @@
{/if}
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if issues.length}<p class="error" role="alert">Correct {issues.length} highlighted {issues.length === 1 ? 'field' : 'fields'} before continuing.</p>{/if}
<div class="active-plan">
<div class="section-heading">
@@ -118,20 +134,21 @@
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
<div class="editor-fields">
<div class="fields">
<label>Description<input bind:value={r.label} maxlength="200" required /></label>
<label>Description<input id={`plan-${r.id}-label`} aria-invalid={!!fieldError(`${r.id}-label`)} aria-describedby={`error-${r.id}-label`} bind:value={r.label} maxlength="200" required /><span class="error" id={`error-${r.id}-label`}>{fieldError(`${r.id}-label`) ?? ''}</span></label>
{#if r.kind !== 'manual'}
<label>{r.kind === 'failure-budget' ? 'Maximum allowed' : 'Target'}
<input type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
<label>{targetLabel(r.kind)}
<input id={`plan-${r.id}-target`} aria-invalid={!!fieldError(`${r.id}-target`)} aria-describedby={`error-${r.id}-target`} type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
<span class="error" id={`error-${r.id}-target`}>{fieldError(`${r.id}-target`) ?? ''}</span>
</label>
{/if}
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
<label>Minimum players<input type="number" min="1" step="1" bind:value={r.minPlayers} required /></label>
<label>Minimum players<input id={`plan-${r.id}-minPlayers`} aria-invalid={!!fieldError(`${r.id}-minPlayers`)} aria-describedby={`error-${r.id}-minPlayers`} type="number" min="1" step="1" bind:value={r.minPlayers} required /><span class="error" id={`error-${r.id}-minPlayers`}>{fieldError(`${r.id}-minPlayers`) ?? ''}</span></label>
{/if}
{#if r.kind === 'matches'}
<label>Minimum round in the same game<input type="number" min="0" step="1" bind:value={r.minRound} required /></label>
<label>Minimum round in the same game<input id={`plan-${r.id}-minRound`} aria-invalid={!!fieldError(`${r.id}-minRound`)} aria-describedby={`error-${r.id}-minRound`} type="number" min="0" step="1" bind:value={r.minRound} required /><span class="error" id={`error-${r.id}-minRound`}>{fieldError(`${r.id}-minRound`) ?? ''}</span></label>
{/if}
{#if r.kind === 'matches'}
<label>Minimum match duration (minutes)<input type="number" min="0" step="any" bind:value={r.minMinutes} required /></label>
<label>Minimum match duration (minutes)<input id={`plan-${r.id}-minMinutes`} aria-invalid={!!fieldError(`${r.id}-minMinutes`)} aria-describedby={`error-${r.id}-minMinutes`} type="number" min="0" step="any" bind:value={r.minMinutes} required /><span class="error" id={`error-${r.id}-minMinutes`}>{fieldError(`${r.id}-minMinutes`) ?? ''}</span></label>
{/if}
</div>
{#if needsGameTelemetry(r)}
@@ -157,7 +174,7 @@
</div>
<div class="policy-settings">
<details class="policy">
<details class="policy" bind:open={coverageOpen}>
<summary><span>Coverage &amp; rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<div class="fields">
@@ -166,21 +183,21 @@
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
<option value="map-game">At least one per selected game/map combination</option>
</select></label>
{#if plan.scope === 'quorum'}<label>Required servers<input type="number" min="1" step="1" bind:value={plan.quorum} required /></label>{/if}
<label>Servers per wave (0 = all at once)<input type="number" min="0" step="1" bind:value={plan.waveSize} required /></label>
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
</div>
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
</div>
</details>
<details class="policy">
<details class="policy" bind:open={deadlineOpen}>
<summary><span>Deadline &amp; failure handling<small>{plan.timeoutHours}-hour window · {failureSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<div class="fields">
<label>Testing window (hours from opening)<input type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /></label>
<label>Testing window (hours from opening)<input id="plan-timeoutHours" aria-invalid={!!fieldError('timeoutHours')} aria-describedby="error-timeoutHours" type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /><span class="error" id="error-timeoutHours">{fieldError('timeoutHours') ?? ''}</span></label>
<label>On test failure<select bind:value={plan.failureAction}><option value="fail">Mark failed and hold</option><option value="pause">Pause for investigation</option><option value="rollback">Roll back automatically</option></select></label>
<label>Rollback restarts<select bind:value={plan.rollbackRestart}><option value="when-empty">Rolling restart when empty</option><option value="manual">Restore files; restart manually</option></select></label>
</div>
<p>Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.</p>
<p>Waiting for empty servers uses this window. Pausing and retrying do not extend it. Missing evidence at the deadline is inconclusive. Health checks continue until you manually promote.</p>
</div>
</details>
<details class="policy save-policy">
@@ -195,8 +212,7 @@
<style>
.builder { display: grid; gap: 16px; }
h3, h4, h5, p, dl, dd { margin: 0; }
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
h4, h5, p, dl, dd { margin: 0; }
h4 { font-size: 19px; font-weight: 600; }
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
@@ -243,7 +259,7 @@
.add-row label { flex: 1 1 220px; }
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; }
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
button { cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
+81 -69
View File
@@ -1,91 +1,103 @@
<script lang="ts">
import { tick } from 'svelte';
import ServerIdentity from './ServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import Button from './primitives/Button.svelte';
import { coverageSummary } from '$lib/beta-plan-summary.js';
import type { BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
let notes = $state<Record<string, string>>({});
let evidenceOpen = $state(false);
let evidenceLimit = $state(25);
let busy = $state(false);
let error = $state('');
async function action(action: string, extra: Record<string, string> = {}) {
busy = true; error = '';
import { confirmAction } from '$ui/confirm.js';
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, deadline, retryBlocked, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
let notes = $state<Record<string,string>>({});
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
async function action(action:string, extra:Record<string,string>={}) {
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? new Date(deadline).toLocaleString() : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
busy = true; error = ''; message = '';
try {
const response = await fetch(`/api/betas/${betaId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...extra }) });
const body = await response.json();
if (!response.ok) throw new Error(body.error);
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
await onUpdate();
} catch (e) { error = (e as Error).message; }
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
} catch(e) { error = (e as Error).message; }
finally { busy = false; }
}
async function showEvidence(server:string, requirement:BetaRequirement) {
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
}
</script>
<section aria-label="Test plan progress">
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
{#if test.reason}<p>{test.reason}</p>{/if}
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
<details><summary>Test policy</summary><p>{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}</p></details>
<div class="heading"><h2>Test results</h2><StatusBadge state={test.outcome}/></div>
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
{#if !closed}
<div class="actions">
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
</div>
<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
{#if ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
</div>
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
{#each test.servers as server (server.server)}
<article>
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
<div class="requirement" class:failed={progress.failed}>
<div><strong>{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}</strong><span>{progress.detail}</span></div>
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)}
<span>Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same game` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''}</span>
{/if}
{#if !['manual', 'failure-budget'].includes(requirement.kind)}
<progress aria-label={progress.label} value={Math.min(progress.current, progress.target)} max={progress.target}></progress>
{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed}
<div class="actions">
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
</div>
{/if}
</div>
{/each}
</article>
{/each}
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
{#if evidenceOpen}
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
{#if message}<p role="status">{message}</p>{/if}
{#if !test.servers.length}<p>Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.</p>{/if}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={new Date(server.observation.lastGameplayAt).toLocaleString()}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed}
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
{/if}
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
</div>{/if}
{/each}
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
</details>
{/each}
<details><summary>Test policy</summary><p>{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart servers manually.'} Health checks continue after passing until promotion.</p></details>
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
{#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{new Date(event.received_at).toLocaleString()}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if}
</details>
</section>
<style>
section { display: grid; gap: 16px; }
h2, h3, p { margin: 0; }
h2 { font-size: 18px; } h3 { font-size: 15px; }
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.heading { justify-content: space-between; }
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
.requirement { display: grid; gap: 8px; margin-top: 16px; }
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
.failed { color: var(--state-danger); }
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
</style>
+11 -6
View File
@@ -15,7 +15,10 @@
let busy = $state(false);
let error = $state('');
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20));
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row)));
let reason = $derived(!name.trim() ? 'Enter a blueprint name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : '');
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
let wasOpen = false;
let session = 0;
let nextKey = 0;
@@ -74,19 +77,20 @@
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
<div class="lineup">
<div class="section-title"><h3>Your lineup</h3><span class="count">{total} server{total === 1 ? '' : 's'}</span></div>
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
{#each rows as row, index (row.key)}
<div class="map-row">
<span class="number">{String(index + 1).padStart(2, '0')}</span>
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
<label>Map<select aria-label={`Map ${index + 1}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 20) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–20</span></label>
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
</div>
{/each}
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
</div>
<p class="muted">Blueprints save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
</form>
{/if}
@@ -94,11 +98,12 @@
</dialog>
<style>
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
@media (max-width: 620px) {
.builder { padding: 20px; }
+5 -1
View File
@@ -5,6 +5,7 @@
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
import { configWarnings } from '$lib/config-workbench.js';
interface Version { id: number; version: number; notes: string | null; }
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
@@ -23,6 +24,7 @@
let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
const dirty = $derived(text !== original || notes.trim().length > 0);
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
onMount(() => {
selected = String(versions[0]?.id ?? ''); ready = true;
@@ -117,7 +119,7 @@
{:else if loadedVersion}
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong class="save-warning">{ignoredWarnings.length} ignored or incomplete lines — review the open configuration notes before saving.</strong>{/if}</span>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
</div>
@@ -144,8 +146,10 @@
.actions { margin-top: 16px; }
.actions > span { flex: 1 1 200px; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>
+31 -15
View File
@@ -3,10 +3,14 @@
import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
import { saveLibraryContext } from '$ui/library-context.js';
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
let servers = $state<ConfigServer[]>([]);
let selected = $state('');
let inspection = $state<ConfigInspection | null>(null);
let promoted = $state<ConfigInspection | null>(null);
let expanded = $state<string[]>([]);
let inspectedServer = '';
let loading = $state(true);
let error = $state('');
let query = $state('');
@@ -14,10 +18,15 @@
let raw = $state(false);
let reload = $state(0);
let ready = $state(false);
let desiredServer = '';
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value])));
const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []);
const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []);
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; }
async function loadServers() {
loading = true; error = '';
@@ -30,8 +39,9 @@
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
}
onMount(loadServers);
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; });
$effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); });
$effect(() => {
const server = selected;
const text = draftText;
@@ -39,7 +49,8 @@
const refresh = reload;
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
const controller = new AbortController();
loading = true; error = ''; inspection = null;
loading = true; error = '';
if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; }
const timer = setTimeout(async () => {
try {
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
@@ -48,7 +59,7 @@
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
if (!controller.signal.aborted) inspection = body.inspection;
if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; }
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
}, text === undefined ? 0 : 350);
@@ -71,26 +82,26 @@
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label>
{#if error}<div class="error" role="alert">{error}</div>
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
{:else if inspection}
<div class="sources" aria-label="Composition sources">
<ol class="sources" aria-label="Composition order, later layers take precedence">
{#each inspection.sources as source, index}
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
{/each}
</div>
</ol>
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
{:else}
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
<div class="settings">
{#each settings as setting (setting.key)}
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
</details>
{:else}<p class="empty">No matching settings.</p>{/each}
@@ -110,10 +121,14 @@
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); }
.sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; }
.sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; }
.layer-number { color: var(--text-primary); font-weight: 600; }
.preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; }
.change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; }
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
.sources a.draft { color: var(--state-warning); }
.map-source, .arrow { color: var(--text-secondary); }
.map-source { color: var(--text-secondary); }
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
.switch { display: flex; gap: 4px; }
@@ -142,4 +157,5 @@
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
@media (max-width: 600px) { .inspector { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
</style>
+12 -6
View File
@@ -3,13 +3,14 @@
import Tabs from './primitives/Tabs.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import { mapLabel } from '$lib/script-library.js';
import { goto } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
interface Artifact {
@@ -27,7 +28,11 @@
let status = $state('');
let view = $state('library');
let limit = $state(25);
$effect(() => { query; game; scope; status; limit = 25; });
let mounted = $state(false);
afterNavigate(() => { mounted = true; });
let lastFilters = '';
$effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
$effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); });
let dialog: HTMLDialogElement;
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
@@ -67,7 +72,7 @@
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
}
onMount(load);
onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); });
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
async function importFile(event: Event) {
@@ -102,20 +107,20 @@
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
</SectionHeader>
<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>
{#if artifacts.length}<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>{/if}
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
<div class="filters">
{#if loading || artifacts.length || error}<div class="filters">
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
</div>
</div>{/if}
{#if loading}<div class="loading" role="status">Loading your config library…</div>
{:else if !artifacts.length && !error}
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
@@ -218,4 +223,5 @@
.card-footer { padding-top:12px; margin-top:12px; }
.welcome { padding:24px; }
.loading { padding:24px 0; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>
+17 -3
View File
@@ -1,8 +1,17 @@
<script lang="ts">
import { changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
import { addConfigSetting, changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
let mode = $state<'raw' | 'settings'>('raw');
let search = $state('');
let newKey = $state('');
let newValue = $state('');
let addError = $state('');
function addSetting() {
try { text = addConfigSetting(text, newKey, newValue); search = ''; newKey = ''; newValue = ''; addError = ''; }
catch (error) { addError = (error as Error).message; }
}
function addOnEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); if (!disabled && newKey.trim() && newValue.trim()) addSetting(); } }
const entries = $derived(configLines(text));
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
const warnings = $derived(configWarnings(text, overlay));
@@ -26,12 +35,14 @@
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
</label>
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}</p>{/each}
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add your first setting below.'}</p>{/each}
<div class="add-setting"><label>Setting name<input bind:value={newKey} onkeydown={addOnEnter} placeholder="g_inactivity" {disabled} /></label><label>Value<input bind:value={newValue} onkeydown={addOnEnter} placeholder="0" {disabled} /></label><Button size="sm" onclick={addSetting} disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button></div>
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
</div>
{/if}
</div>
{#if warnings.length}
<details class="warnings"><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}</summary>
<details class="warnings" open={warnings.some(w => w.includes('ignored') || w.includes('no value'))}><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before saving</summary>
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
</details>
{/if}
@@ -50,6 +61,8 @@
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
.setting span { min-width: 0; overflow-wrap: anywhere; }
.add-setting { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-strong); padding-top: 16px; }
.add-setting label { flex: 1 1 140px; display: grid; gap: 6px; font-size: var(--text-sm); }
code { font: var(--text-sm) var(--font-mono); }
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
@@ -57,4 +70,5 @@
summary { cursor: pointer; }
li { margin: 6px 0; }
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
input:not([type='checkbox']):not([type='file']), textarea { border-color: var(--border-control); }
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
@@ -14,28 +15,41 @@
let error = $state('');
let wasOpen = false;
let session = 0;
let keyNotice = $state('');
let now = $state(Date.now());
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
if (open && !wasOpen) { wasOpen = true; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
async function review() {
const current = ++session;
busy = true; error = ''; preview = null; keys = {};
const previous = preview;
busy = true; error = ''; keyNotice = '';
try {
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
if (current === session) preview = body;
if (current === session) {
const next = body as CollectionPreview;
const names = new Set(next.servers.map(server => server.name));
keys = Object.fromEntries(next.servers.filter(server => server.name).map(server => [server.name!, keys[server.name!] ?? '']));
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
preview = next; now = Date.now();
}
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
async function launch() {
if (!preview || !canLaunch || busy) return;
if (!preview || !canLaunch || expired || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
@@ -46,6 +60,11 @@
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function nextMissing() {
await tick();
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
}
</script>
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
@@ -54,9 +73,12 @@
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
{:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
<p class="muted">Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.</p>
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. Only missing servers are created. Extra servers remain and existing servers are not updated.</p>
{#if preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
</div>
{#if preview.servers.length}
<div class="pacing-note">
@@ -70,15 +92,15 @@
<p class="muted">Add a key for each new lobby below, or reuse its saved key. Fleet counts and available slots are checked again when you queue the launch.</p>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<ol class="servers" aria-label="Server launch order">
{#each preview.servers as server, index}
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
<li class="server-row">
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
<div class="server-identity"><strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key ready' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this lobby’s key'} /></label>{/if}
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key available' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} data-missing={!server.hasSavedKey && !keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this server’s key'} /></label>{/if}
</li>
{/each}
</ol>
<div class="launch-footer"><p class="caption">Follow every server from queued to ready.<br />Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
</form>
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
@@ -87,10 +109,15 @@
</dialog>
<style>
input { scroll-margin-block: 32px 190px; }
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-control); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
.pacing-note { display: flex; align-items: flex-start; gap: 14px; background: color-mix(in srgb, var(--accent-primary) 8%, transparent); border-left: 2px solid var(--accent-primary); padding: 16px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }.pacing-note svg { flex: 0 0 24px; width: 24px; height: 24px; color: var(--accent-primary-hover); }.pacing-note strong { font-size: 14px; }.pacing-note p { margin-top: 6px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
@@ -29,6 +30,11 @@
let saving = $state(false);
let wasOpen = false;
let session = 0;
let keyNotice = $state('');
let now = $state(Date.now());
let heading: HTMLHeadingElement;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
$effect(() => {
if (!dialog) return;
@@ -38,7 +44,7 @@
async function initialize() {
const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId;
game = initialGame;
@@ -63,19 +69,24 @@
open = false; collectionOpen = true; return;
}
const current = session;
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = '';
try {
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) preview = body;
if (current === session) {
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
preview = body; now = Date.now();
await tick(); heading?.focus();
}
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
async function launch() {
if (!preview || !preview.ready || busy) return;
if (!preview || !preview.ready || expired || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -106,22 +117,25 @@
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer">
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
{#if error}<div class="error" role="alert">{error}</div>{/if}
{#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
</section>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
<p class="muted">{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key available — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
<p class="muted">{preview.hasSavedKey ? 'A key is stored for this exact server ID; its validity is checked when the game starts. Enter a replacement here to update it at launch.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
</form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
@@ -152,7 +166,7 @@
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
@@ -30,18 +30,18 @@
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Destroy container?</h2>
<h2 id="destroy-title">Queue server removal?</h2>
<p class="name">{name}</p>
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
<div class="notice">
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the deployment page.
</div>
<p class="irreversible">Once destruction begins, it cannot be undone.</p>
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<footer>
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
<Button variant="danger" type="submit" loading={submitting}>Destroy container</Button>
<Button variant="danger" type="submit" loading={submitting}>Queue server removal</Button>
</footer>
</form>
</dialog>
@@ -47,9 +47,10 @@
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="content">
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</h2></header>
<header><h2>Queue fleet removal</h2></header>
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
<p>In Active deployments, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
@@ -67,7 +68,6 @@
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
.eyebrow { display: block; color: var(--state-danger); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
+54 -12
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { tick, untrack } from 'svelte';
import { tick, untrack, onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { toast } from '$ui/toast.js';
import { pollVisible } from '$ui/poll.js';
import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte';
@@ -30,14 +33,15 @@
reason?: string;
}
let active = $state<string>('');
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
let data = $state<Record<string, LogResponse | null>>({});
// Default to the first tab once props are populated; don't overwrite
// the user's later selection.
$effect(() => {
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
});
function chooseLog(key: string) {
const url = new URL($page.url); url.searchParams.set('log', key);
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
}
let query = $state('');
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
let loading = $state(false);
let autoRefresh = $state(true);
let refreshError = $state('');
@@ -62,7 +66,7 @@
} finally {
pending.delete(requestKey);
if (server === currentServer && key === active) loading = false;
if (stickToBottom && server === currentServer && key === active) {
if (stickToBottom && !query && server === currentServer && key === active) {
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
@@ -78,6 +82,33 @@
}
function toggleAuto() { autoRefresh = !autoRefresh; }
async function jumpToLatest() {
query = ''; stickToBottom = true;
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
async function copy(value: string, message: string) {
try { await navigator.clipboard.writeText(value); toast(message); }
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); }
}
function copyLink() {
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
void copy(url.href, 'Log link copied.');
}
function download() {
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function highlight(line: string) {
if (!query) return [{ text: line, match: false }];
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
}
parts.push({ text: line.slice(start), match: false }); return parts;
}
$effect(() => { server; data = {}; refreshError = ''; });
$effect(() => {
server;
@@ -109,24 +140,27 @@
}
let current = $derived(data[active] ?? null);
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
</script>
<div class="log-panel">
<div class="tabs">
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
<div class="right">
{#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
{/if}
<label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
auto {pollMs / 1000}s
Auto-refresh · {pollMs / 1000}s
</label>
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
{loading ? '…' : 'refresh'}
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</div>
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div>
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
@@ -142,7 +176,9 @@
{:else if current && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else if current?.lines}
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
{:else}
@@ -153,6 +189,12 @@
</div>
<style>
.log-tools, .log-actions, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.log-tools { align-items: end; }
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
mark { background: var(--state-warning); color: var(--surface-sunken); }
.log-panel {
display: flex;
flex-direction: column;
@@ -0,0 +1,24 @@
<script lang="ts">
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js';
let { open = $bindable(false) }: { open?: boolean } = $props();
$effect(() => { if (open) markNotificationsRead(); });
</script>
<Dialog bind:open title="Recent notifications" description="Recent action results and completed lobby watches in this browser tab. Watches run while the app is open.">
{#if $notificationHistory.length}
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
{:else}<p class="empty">No recent notifications. Completed lobby watches and action results will appear here.</p>{/if}
{#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
</Dialog>
<style>
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
li { border: 1px solid var(--border-strong); padding: 12px; border-radius: var(--radius-md); }
li.attention { border-color: var(--state-warning); }
p { margin: 0 0 8px; font-size: var(--text-sm); }
time { display: block; color: var(--text-secondary); font-size: var(--text-xs); }
a { display: inline-block; margin-top: 8px; font-size: var(--text-sm); }
.empty { color: var(--text-secondary); }
</style>
+24 -5
View File
@@ -3,6 +3,11 @@
import Button from './primitives/Button.svelte';
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
let limit = $state(25);
let query = $state('');
let newestFirst = $state(true);
const latest = $derived(events.at(-1));
const matching = $derived(events.filter(event => event.message.toLowerCase().includes(query.toLowerCase())));
const visible = $derived(newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit));
</script>
<section>
@@ -17,23 +22,37 @@
</ol>
</section>
<section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if events.length}
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
{#if latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{new Date(latest.created_at).toLocaleString()}</time></p>{/if}
<details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length}
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
<ol class="timeline">
{#each events.slice(-limit) as event (event.id)}
{#each visible as event (event.id)}
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
{/each}
</ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
{#if matching.length > limit}<Button size="sm" onclick={() => limit += 25}>Show more activity ({matching.length - limit})</Button>{/if}
{:else}
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
{/if}
</details>
</section>
<style>
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
details[open] summary { margin-bottom: 16px; }
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
.latest strong { display: block; margin-bottom: 4px; }
.latest time { display: block; margin-top: 4px; }
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
label:first-child { flex: 1; min-width: 180px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
time, .muted { color: var(--text-secondary); font-size: 13px; }
p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
@@ -1,13 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import { toast } from '$ui/toast.js';
let { pluginId, targetPath, disabled = false, onimport }: {
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => void | Promise<void>;
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
} = $props();
let url = $state('');
let name = $state('');
@@ -15,10 +16,11 @@
let release = $state<GithubPluginRelease | null>(null);
let assetId = $state('');
let update = $state<PluginUpdate | null>(null);
let busy = $state(false);
let checking = $state(false);
let error = $state('');
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
let active = true;
onDestroy(() => { active = false; });
function showRelease(value: GithubPluginRelease) {
release = value;
@@ -58,6 +60,7 @@
async function importRelease(startBeta: boolean) {
if (!release || !assetId || busy || disabled) return;
const startPath = window.location.pathname;
busy = true; error = ''; success = null;
try {
const response = await fetch('/api/plugins/github/import', {
@@ -67,11 +70,12 @@
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
if (!active) { toast('Plugin imported. Review its version when ready.', { variant: 'success', href: `/plugins/${body.pluginId}` }); return; }
success = body;
await onimport?.();
if (startBeta) await goto(body.betaUrl);
await onimport?.(body);
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
else if (pluginId) await checkUpdates();
} catch (e) { error = (e as Error).message; }
} catch (e) { error = (e as Error).message; if (!active) toast(`Plugin import failed: ${error}`, { variant: 'danger', href: '/plugins' }); }
finally { busy = false; }
}
</script>
@@ -172,4 +176,5 @@
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>
@@ -24,6 +24,8 @@
let message = $state('');
let preferredTarget = $state<number | null>(null);
let nextKey = 0;
let activeFile = $state('');
const checkedCount = $derived(queue.filter(q => q.inspected).length);
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'));
@@ -56,19 +58,20 @@
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 (busy || inspecting || !files.length) return;
if (!dialog.open) open();
for (const file of files) {
const items = files.map(file => {
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)!);
}
return item;
});
queue = [...queue, ...items];
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
preferredTarget = null;
}
async function inspect(item: Upload) {
activeFile = item.file.name;
item.inspecting = true; item.error = '';
try {
const form = new FormData(); form.append('content', item.file);
@@ -77,7 +80,7 @@
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; }
finally { item.inspecting = false; activeFile = ''; }
}
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
function validation(item: Upload): string {
@@ -99,6 +102,7 @@
busy = true; message = '';
try {
for (const item of queue.filter(q => !q.result)) {
activeFile = item.file.name;
item.error = '';
try {
const form = new FormData();
@@ -126,7 +130,7 @@
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
}
} catch (e) { message = (e as Error).message; }
finally { busy = false; }
finally { busy = false; activeFile = ''; }
}
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
</script>
@@ -183,6 +187,7 @@
{/if}
{#if message}<p class="notice" role="status">{message}</p>{/if}
<footer>
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
{#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>
@@ -205,6 +210,8 @@
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); }
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
@media (max-width: 640px) { .drawer { padding: 20px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
</style>
+11 -1
View File
@@ -80,7 +80,7 @@
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
let showTiming = $derived(
showRoster && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
health === 'online' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
);
// Shared-element transition to /servers/{name} — Chromium only, others
@@ -109,6 +109,8 @@
<Badge variant="info" uppercase>Starting</Badge>
{:else if health === 'offline'}
<Badge variant="danger" uppercase>offline</Badge>
{:else if health === 'stale'}
<Badge variant="warning">Stale</Badge>
{:else}
<div class="players" aria-live="polite">
<Stat
@@ -123,6 +125,11 @@
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
{#if health === 'stale'}
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
{/if}
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
{#if showTiming && !(compactEmpty && players === 0)}
@@ -185,6 +192,9 @@
</Card>
<style>
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
/* Global tweaks scoped through :global() since Card is a child component. */
:global(.card.restarting) {
outline: 1px solid rgba(255, 176, 32, 0.28);
@@ -153,7 +153,7 @@
/* Loading */
.is-loading .label,
.is-loading .slot { visibility: hidden; }
.is-loading .slot { opacity: 0; }
.spinner {
position: absolute;
width: 14px; height: 14px;
+17 -3
View File
@@ -18,14 +18,28 @@
get messageId() { return error || hint ? `${id}-message` : undefined; },
get invalid() { return !!error; }
});
function connectLabel(node: HTMLDivElement) {
const connect = () => {
const label = node.querySelector<HTMLLabelElement>('label.label');
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
if (!label || !control || forId) return;
if (!control.id) control.id = `${id}-control`;
label.htmlFor = control.id;
};
connect();
const observer = new MutationObserver(connect);
observer.observe(node, { childList: true, subtree: true });
return { destroy: () => observer.disconnect() };
}
</script>
<div class="field">
<div class="field" use:connectLabel>
{#if label}
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}>
{label}
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
</svelte:element>
</label>
{/if}
<div class="control" class:has-error={!!error}>
{@render children?.()}
@@ -63,7 +63,7 @@
width: 100%;
padding: 9px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
@@ -77,7 +77,7 @@
.input.mono { font-family: var(--font-mono); }
.input::placeholder { color: var(--text-quaternary); }
.input:hover { border-color: var(--border-strong); }
.input:hover { border-color: var(--border-control-hover); }
.input:focus {
outline: none;
border-color: var(--accent-primary);
@@ -1,8 +1,8 @@
<script lang="ts">
import Button from './Button.svelte';
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $props();
</script>
{#if total > step}
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => { limit += step; onchange?.(limit); }}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
{/if}
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
@@ -69,7 +69,7 @@
appearance: none;
padding: 9px 32px 9px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
@@ -81,7 +81,7 @@
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.select:hover { border-color: var(--border-strong); }
.select:hover { border-color: var(--border-control-hover); }
.select:focus {
outline: none;
border-color: var(--accent-primary);
+28 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
interface Tab {
id: string;
label: string;
@@ -18,6 +19,32 @@
const uid = $props.id();
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
let tablist = $state<HTMLDivElement | null>(null);
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
$effect(() => {
const selected = value;
const node = tablist;
if (!node) return;
let cancelled = false;
let observer: ResizeObserver | undefined;
void tick().then(() => {
if (cancelled) return;
const tab = document.getElementById(`${idPrefix}-${selected}`);
if (!tab) return;
const reveal = () => {
const bounds = node.getBoundingClientRect();
const target = tab.getBoundingClientRect();
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
};
reveal();
observer = new ResizeObserver(reveal);
observer.observe(node);
observer.observe(tab);
});
return () => { cancelled = true; observer?.disconnect(); };
});
function onKey(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
@@ -38,7 +65,7 @@
}
</script>
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel}>
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel} bind:this={tablist}>
{#each tabs as t (t.id)}
<button
type="button"
@@ -54,7 +54,7 @@
width: 100%;
padding: var(--space-3) var(--space-3);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
@@ -68,7 +68,7 @@
}
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
.textarea::placeholder { color: var(--text-quaternary); }
.textarea:hover { border-color: var(--border-strong); }
.textarea:hover { border-color: var(--border-control-hover); }
.textarea:focus {
outline: none;
border-color: var(--accent-primary);