Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
+64 -62
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
import ServerIdentity from './ServerIdentity.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import PluginGithubImport from './PluginGithubImport.svelte';
import ConfigEditor from './ConfigEditor.svelte';
@@ -60,9 +67,10 @@
let scopeDialog = $state<HTMLDialogElement | null>(null);
let uploadDialog = $state<HTMLDialogElement | null>(null);
let fleetServers: ServerRow[] = [];
let poll: ReturnType<typeof setInterval> | null = null;
const fleetServers = $derived($sharedFleet);
let loadError = $state('');
let limit = $state(25);
let request = 0;
const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame;
@@ -82,39 +90,27 @@
function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped';
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
}
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
return s === 'tested' ? 'online' :
s === 'test-failed' ? 'danger' :
'neutral';
return s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : s.scope_value === 't5' ? 'Black Ops I' : s.scope_value === 't6' ? 'Black Ops II' : s.scope_value ?? 'Unscoped';
}
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
const [detailRes, serversRes, scopeRes] = await Promise.all([
fetch(`/api/scripts/${id}`),
fetch('/api/servers'),
fetch('/api/scope-options')
]);
if (!detailRes.ok) { loading = false; return; }
const body = (await detailRes.json()) as {
script: Artifact; versions: Version[];
matchedServers: string[]; runningBetaId: number | null;
};
artifact = body.script;
versions = body.versions;
matchedServers = body.matchedServers;
runningBetaId = body.runningBetaId;
if (serversRes.ok) {
const s = (await serversRes.json()) as { servers: ServerRow[] };
fleetServers = s.servers;
}
if (scopeRes.ok) {
scopeOptions = (await scopeRes.json()) as ScopeOptions;
}
if (seedForm) seedScopeForm();
loading = false;
const requestedId = id;
const current = ++request;
try {
const [detailRes, scopeRes] = await Promise.all([
fetch('/api/scripts/' + requestedId),
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
]);
if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
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;
if (scopes) scopeOptions = scopes;
if (seedForm) seedScopeForm();
loadError = '';
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
finally { if (requestedId === id && current === request) loading = false; }
}
function seedScopeForm(): void {
@@ -126,17 +122,14 @@
} else if (scopeType === 'map') {
scopeMap = artifact.scope_value ?? '';
const owner = fleetServers.find((sv) => sv.map === scopeMap);
scopeGame = owner?.game ?? '';
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
} else {
scopeGame = ''; scopeMap = '';
}
}
onMount(() => {
load();
poll = setInterval(() => load({ seedForm: false }), 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
@@ -163,7 +156,10 @@
}
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
if (!dialog) return;
if (e.target === dialog) dialog.close();
if (e.target === dialog && !uploadingVersion && !updatingScope) {
const bounds = dialog.getBoundingClientRect();
if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) dialog.close();
}
}
function pickVersionFile(e: Event): void {
@@ -189,7 +185,7 @@
versionFile = null; versionNotes = '';
uploadDialog?.close();
await load();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
uploadingVersion = false;
}
}
@@ -218,17 +214,19 @@
}
scopeDialog?.close();
await load();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
updatingScope = false;
}
}
async function deleteArtifact(): Promise<void> {
if (!confirm(`Delete this ${family}?`)) return;
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; }
goto(basePath);
if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
try {
const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
const body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
await goto(basePath);
} catch (error) { flash = (error as Error).message; }
}
let gameOptions = $derived([
@@ -246,11 +244,12 @@
];
</script>
{#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>
{:else if !artifact}
{:else if !artifact && !loadError}
<EmptyState title="Not found" description="This {family} may have been deleted." />
{:else}
{:else if artifact}
{@const a = artifact}
<div class="page">
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
@@ -303,7 +302,7 @@
{:else if matchedServers.length === 0}
<span class="warn">No matching servers</span>
{:else}
{matchedServers.join(', ')}
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
{/if}
</span>
</div>
@@ -323,7 +322,7 @@
<span class="k">Latest version</span>
<span class="v mono">
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
<StatusBadge state={latestVersion.state} size="sm" />
</span>
</div>
{/if}
@@ -344,7 +343,7 @@
<EmptyState title="No versions yet" description="Upload a first version to get started." />
{:else}
<div class="version-list">
{#each versions as v (v.id)}
{#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>
@@ -352,16 +351,18 @@
<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>
<span class="v-when">{v.uploaded_at}</span>
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<Badge variant={stateVariant(v.state)} size="sm">{v.state.replace('-', ' ')}</Badge>
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
{/if}
</div>
{/each}
</div>
{/if}
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
@@ -376,20 +377,20 @@
{#if flash}<div class="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<dialog aria-label="Edit scope" 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>
<button type="button" class="close" onclick={cancelScopeModal} aria-label="Close">×</button>
<button type="button" class="close" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
</header>
<Field label="Scope">
<div class="scope-row">
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
{#if scopeType === 'game' || scopeType === 'map'}
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
{/if}
{#if scopeType === 'map'}
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
{/if}
</div>
</Field>
@@ -404,11 +405,11 @@
</dialog>
<!-- Upload dialog -->
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
<dialog aria-label="Upload new version" oncancel={event => { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
<button type="button" class="close" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
</header>
<Field label="File">
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
@@ -589,4 +590,5 @@
.panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
.dlg { padding: var(--space-4); }
}
.matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); }
</style>
+22 -17
View File
@@ -1,5 +1,6 @@
<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 { coverageSummary, needsSessionTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
@@ -11,6 +12,7 @@
let saving = $state(false);
let editingId = $state('');
let appliedPresetName = $state('');
let choosingPreset = $state(false);
const matchingPreset = $derived(presets.find(p => p.name === appliedPresetName && JSON.stringify(p.plan) === JSON.stringify(plan))
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
@@ -32,6 +34,7 @@
// Validation returns an independent plain object, even when the source is a Svelte proxy.
plan = parseBetaPlan(preset.plan);
appliedPresetName = name;
choosingPreset = false;
editingId = ''; error = '';
message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`;
} catch (e) { error = (e as Error).message; }
@@ -61,8 +64,9 @@
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
<span class="pill">Manual promotion</span>
</header>
<p>Choose a starting point, then tailor what each server needs to prove.</p>
<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">
{#each builtInPlans as preset, index (preset.name)}
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
@@ -83,7 +87,8 @@
</select>
</label>
{/if}
<div class="feedback" role="status" aria-live="polite">{message}</div>
{/if}
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="active-plan">
@@ -194,17 +199,17 @@
</section>
<style>
.builder { display: grid; gap: 16px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
.builder { display: grid; gap: 16px; }
h3, h4, h5, p, dl, dd { margin: 0; }
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
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; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.pill { padding: 5px 9px; border-radius: 99px; font-size: 11px; color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.pill { padding: 5px 9px; border-radius: 99px; font-size: var(--text-xs); color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
.presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
.preset:hover { border-color: var(--accent-primary-hover); }
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
.preset-top { display: flex; align-items: center; justify-content: space-between; }
@@ -216,27 +221,27 @@
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
.preset li + li { margin-top: 5px; }
.preset li::marker { color: var(--accent-primary-hover); }
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: 11px; color: var(--text-secondary); }
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.preset-footer > span { color: var(--accent-primary-hover); }
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
.error { color: var(--state-danger); }
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: 12px; }
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
dt { color: var(--text-secondary); font-size: 11px; margin-bottom: 6px; }
dt { color: var(--text-secondary); font-size: var(--text-xs); margin-bottom: 6px; }
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
dd small { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
.plan-caption { font-size: 12px; }
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
.requirements { display: grid; gap: 10px; }
.requirement { border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface-sunken); min-width: 0; overflow: hidden; }
.requirement { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); min-width: 0; overflow: hidden; }
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.requirement-copy .eyebrow { font-size: 9px; margin-bottom: 4px; }
.requirement-copy .eyebrow { font-size: var(--text-xs); margin-bottom: 4px; }
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
.remove:hover { color: var(--state-danger); }
.editor { border-top: 1px solid var(--border-subtle); }
.editor > summary { padding: 10px 16px; font-size: 11px; color: var(--accent-primary-hover); }
.editor > summary { padding: 10px 16px; font-size: var(--text-xs); color: var(--accent-primary-hover); }
.editor > summary > span { color: var(--text-secondary); margin-left: 10px; }
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
@@ -250,14 +255,14 @@
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
.policy-settings { display: grid; gap: 8px; }
.policy { border: 1px solid var(--border-subtle); border-radius: 9px; }
.policy { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
.policy small { display: block; font-size: 11px; color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
.edit-hint { color: var(--accent-primary-hover); font-size: 11px; }
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
.policy-body.fields { display: flex; }
summary { cursor: pointer; }
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: 8px; }
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
+23 -14
View File
@@ -1,7 +1,14 @@
<script lang="ts">
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> = {}) {
@@ -17,23 +24,22 @@
</script>
<section aria-label="Test plan progress">
<div class="heading"><h2>Test plan</h2><strong class:failed={['failed', 'inconclusive'].includes(test.outcome)}>{test.outcome}</strong></div>
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
{#if test.reason}<p>{test.reason}</p>{/if}
<p>Coverage: {test.plan.scope === 'quorum' ? `${test.plan.quorum} servers` : test.plan.scope}. Window: {test.plan.timeoutHours} hours.
{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'}
Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.</p>
<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>
{#if !closed}
<div class="actions">
{#if test.outcome === 'paused'}<button disabled={busy} onclick={() => action('resume')}>Resume</button>{/if}
{#if ['active', 'passed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('pause')}>Pause test</button>{/if}
{#if ['paused', 'failed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</button>{/if}
{#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>
{/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>{server.server} <small>Recovery cycles: {server.phase}</small></h3>
<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}>
@@ -47,19 +53,22 @@
{#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 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>
<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>
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
{#each test.evidence as event (event.id)}
{#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>
{/each}
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
{/if}
</details>
</section>
@@ -70,12 +79,12 @@
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: 8px; padding: 16px; }
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, button { 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; } button, summary { cursor: pointer; } button:disabled { opacity: .5; }
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; }
@@ -1,4 +1,5 @@
<script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js';
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
@@ -63,7 +64,7 @@
}
</script>
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="builder">
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same blueprint.</p>
@@ -93,12 +94,12 @@
</dialog>
<style>
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: 16px; }
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: 10px; 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: 8px; 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: 10px; }.number { font-family: var(--font-mono); font-size: 11px; 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; }
.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; }
.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; }
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
+19 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js';
import { beforeNavigate, goto } from '$app/navigation';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
@@ -30,7 +31,16 @@
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
} catch { /* Draft storage is optional. */ }
});
beforeNavigate(({ cancel }) => { if (dirty && !confirm('Leave this editor? Your draft is kept in this browser when storage is available.')) cancel(); });
let allowNavigation = false;
beforeNavigate(({ cancel, to, type }) => {
if (!dirty || allowNavigation || type === 'leave' || !to) return;
cancel();
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
if (!accepted) return;
allowNavigation = true;
try { await goto(to.url); } finally { allowNavigation = false; }
});
});
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
$effect(() => {
const version = selected;
@@ -54,10 +64,11 @@
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
});
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
function changeVersion(event: Event) {
async function changeVersion(event: Event) {
const target = event.currentTarget as HTMLSelectElement;
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; }
clearDraft(); selected = target.value; success = '';
const next = target.value; target.value = selected;
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
clearDraft(); selected = next; success = '';
}
function restoreDraft() {
if (!recovered) return;
@@ -65,7 +76,7 @@
// recovered draft was started from a version that has since been deleted.
text = recovered.text; notes = recovered.notes; recovered = null;
}
function discard() { if (confirm('Discard your unsaved changes?')) { text = original; notes = ''; clearDraft(); } }
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
@@ -100,13 +111,13 @@
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
</div>
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
{#if success}<div class="success" role="status">{success} <a href="/betas">Open betas →</a></div>{/if}
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
{#if loading}<p class="muted" role="status">Loading config contents…</p>
{: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"><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.'}</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>
+7 -6
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
let { artifactId, draftText, scopeType, scopeValue }: { artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
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);
@@ -13,7 +14,7 @@
let raw = $state(false);
let reload = $state(0);
let ready = $state(false);
const eligible = $derived(servers.filter(s => !artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)));
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);
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
@@ -66,8 +67,8 @@
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div>
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!eligible.length}>
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each}
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
{#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>
@@ -129,13 +130,13 @@
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
strong { color: var(--accent-primary-hover); }
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
b { font-weight: 400; color: var(--state-warning); }
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
ol li.winner { color: var(--text-primary); }
small { display: block; font-size: 10px; color: var(--text-secondary); margin-top: 3px; }
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
+25 -19
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
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 SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte';
@@ -22,7 +25,9 @@
let game = $state('');
let scope = $state('');
let status = $state('');
let view = $state<'library' | 'inspect'>('library');
let view = $state('library');
let limit = $state(25);
$effect(() => { query; game; scope; status; limit = 25; });
let dialog: HTMLDialogElement;
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
@@ -94,15 +99,12 @@
</script>
<div class="page">
<SectionHeader title="Configs" eyebrow="Fleet configuration" subtitle="Build on a baseline. Fine-tune with overlays. Know where every value comes from.">
<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="overview">
<div><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></span></div>
<div><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div>
<div><span class="metric" class:pending={pending > 0}>{pending}</span><span>Awaiting promotion<small>Latest versions outside the promoted set</small></span></div>
</div>
<div class="navigation"><div class="view-switch" aria-label="Configs view"><button class:active={view === 'library'} aria-pressed={view === 'library'} onclick={() => view = 'library'}>Library</button><button class:active={view === 'inspect'} aria-pressed={view === 'inspect'} onclick={() => view = 'inspect'}>Inspect a server <span aria-hidden="true">↗</span></button></div><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh library</Button></div>
<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>
<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 />
@@ -128,14 +130,16 @@
{/if}
{#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays as artifact (artifact.id)}
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_value}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div>
</section>
{/if}
{/if}
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
{/if}
</div>
</div>
{#snippet versionBadges(artifact: Artifact)}
@@ -163,14 +167,11 @@
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
.overview > div { display: flex; align-items: center; gap: 18px; padding: 24px; } .overview > div + div { border-left: 1px solid var(--border-subtle); }
.metric { font-size: 32px; font-weight: 600; letter-spacing: -.04em; } .metric.pending { color: var(--state-warning); }
.overview > div > span:last-child { font-size: var(--text-sm); } small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 5px; }
.navigation, .view-switch, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
.view-switch { gap: 4px; }
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; }
@@ -190,7 +191,7 @@
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-md); background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
@@ -210,6 +211,11 @@
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width: 900px) { .overview > div { padding: 16px; gap: 12px; } .overview small { display: none; } .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
@media (max-width: 600px) { .overview { grid-template-columns: 1fr; } .overview > div + div { border-left: 0; border-top: 1px solid var(--border-subtle); } .overview > div { padding: 12px 16px; } .metric { font-size: 24px; min-width: 30px; } .overview small { display: block; } .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
@media (max-width: 900px) { .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
@media (max-width: 600px) { .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
.config-panel { display:grid; gap:20px; }
.baseline-card { padding:16px; border-radius:var(--radius-md); background:var(--surface); }
.card-footer { padding-top:12px; margin-top:12px; }
.welcome { padding:24px; }
.loading { padding:24px 0; }
</style>
@@ -51,7 +51,7 @@
.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; }
code { font: var(--text-sm) var(--font-mono); }
small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
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); }
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
summary { cursor: pointer; }
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import { confirmation, resolveConfirmation } from '../confirm.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
</script>
<Dialog bind:open={() => $confirmation !== null, (open) => { if (!open) resolveConfirmation(false); }} title={$confirmation?.title ?? 'Confirm action'}>
<p>{$confirmation?.message}</p>
{#snippet actions()}
<Button variant="ghost" onclick={() => resolveConfirmation(false)}>Cancel</Button>
<Button variant={$confirmation?.danger ? 'danger' : 'primary'} onclick={() => resolveConfirmation(true)}>{$confirmation?.confirmLabel ?? 'Continue'}</Button>
{/snippet}
</Dialog>
<style>p { margin: 0; white-space: pre-line; color: var(--text-secondary); line-height: var(--leading-body); }</style>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import Button from './primitives/Button.svelte';
import { toast } from '../toast.js';
let { value, label = 'ID' }: { value: string; label?: string } = $props();
async function copy() {
try { await navigator.clipboard.writeText(value); toast(`${label} copied.`, { variant: 'success' }); }
catch { toast('Copy unavailable. Select and copy the text directly.', { variant: 'warning' }); }
}
</script>
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button></div>
<style>.copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
@@ -46,7 +47,7 @@
}
</script>
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<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}>
<div class="composer">
<header><div><span class="eyebrow">Your lineup, ready to launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
{#if error}<p class="error" role="alert">{error}</p>{/if}
@@ -80,9 +81,9 @@
</dialog>
<style>
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; 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: 10px; 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: 11px; }.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: 12px; 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: 11px; }
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; 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); }
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, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; 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); }
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
@@ -103,7 +104,7 @@
}
</script>
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
<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>
<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>
@@ -144,19 +145,19 @@
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<style>
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.eyebrow { font-size: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
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: 8px; 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: 11px; }
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
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; }
.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); }
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: 8px; font-size: 13px; }.success { color: var(--accent-primary-hover); }
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); }
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: 10px; } h2 { font-size: 22px; } }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
</style>
@@ -32,7 +32,7 @@
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Destroy container?</h2>
<p class="name">{name}</p>
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</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.
@@ -47,12 +47,12 @@
</dialog>
<style>
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
dialog { width: min(520px, calc(100vw - 32px)); 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 / .65); backdrop-filter: blur(4px); }
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
p { line-height: 1.6; color: var(--text-secondary); }
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
.notice { padding: 16px; background: var(--surface-raised); border-radius: 8px; line-height: 1.6; }
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
@@ -1,4 +1,5 @@
<script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
@@ -44,9 +45,9 @@
}
</script>
<dialog bind:this={dialog} aria-label="House Party Protocol" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<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">Call it a night</span><h2>House Party Protocol</h2></header>
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</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>
{#if error}<p class="error" role="alert">{error}</p>{/if}
@@ -63,12 +64,12 @@
</dialog>
<style>
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
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: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
.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: 8px; }
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; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>
+2 -2
View File
@@ -21,13 +21,13 @@
<style>
.recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
.mono, code, .fingerprint { font-family: var(--font-mono); }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: 11px; } li span, small { color: var(--text-secondary); }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
</style>
+39 -66
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte';
const uid = $props.id();
interface TabDef {
key: string; // matches the log key in KNOWN_LOGS
@@ -32,27 +36,33 @@
// Default to the first tab once props are populated; don't overwrite
// the user's later selection.
$effect(() => {
if (!active && tabs.length > 0) active = tabs[0].key;
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
});
let loading = $state(false);
let autoRefresh = $state(true);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let refreshError = $state('');
const pending = new Set<string>();
let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true);
async function fetchLog(key: string): Promise<void> {
loading = true;
const currentServer = server;
const requestKey = currentServer + ':' + key;
if (pending.has(requestKey)) return;
pending.add(requestKey); loading = true;
try {
const res = await fetch(
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
);
const body = (await res.json()) as LogResponse;
data = { ...data, [key]: body };
if (!res.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
if (server === currentServer) { data = { ...data, [key]: body }; if (key === active) refreshError = ''; }
} catch (err) {
data = { ...data, [key]: { ok: false, reason: (err as Error).message } };
if (server === currentServer && key === active) refreshError = (err as Error).message;
} finally {
loading = false;
if (stickToBottom) {
pending.delete(requestKey);
if (server === currentServer && key === active) loading = false;
if (stickToBottom && server === currentServer && key === active) {
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
@@ -67,37 +77,18 @@
stickToBottom = nearBottom;
}
function switchTab(key: string): void {
active = key;
if (!data[key]) void fetchLog(key);
}
function schedulePoll(): void {
if (pollTimer) clearInterval(pollTimer);
if (autoRefresh) {
pollTimer = setInterval(() => {
if (active) void fetchLog(active);
}, pollMs);
}
}
function toggleAuto(): void {
autoRefresh = !autoRefresh;
schedulePoll();
}
// Initial load
function toggleAuto() { autoRefresh = !autoRefresh; }
$effect(() => { server; data = {}; refreshError = ''; });
$effect(() => {
if (active && !data[active]) void fetchLog(active);
});
// Reschedule when autoRefresh or active changes
$effect(() => {
schedulePoll();
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
server;
const key = active;
const auto = autoRefresh;
const interval = pollMs;
if (!key) return;
return untrack(() => {
if (auto) return pollVisible(() => fetchLog(key), interval);
void fetchLog(key);
});
});
function ago(iso: string | null | undefined): string {
@@ -122,11 +113,7 @@
<div class="log-panel">
<div class="tabs">
{#each tabs as t (t.key)}
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
{t.label}
</button>
{/each}
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} 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>
@@ -141,18 +128,20 @@
</div>
</div>
{#if !current}
<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}
{#if !current && !refreshError}
<div class="empty">loading…</div>
{:else if !current.ok}
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current.missing}
{:else if current?.missing}
<div class="empty">
log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if}
</div>
{:else if !current.lines || current.lines.length === 0}
{:else if current && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else}
{:else if current?.lines}
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
@@ -160,6 +149,7 @@
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
{/if}
{/if}
</div>
</div>
<style>
@@ -176,24 +166,7 @@
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.tab {
background: transparent;
color: var(--ink-300);
border: 1px solid transparent;
border-radius: var(--r-sm) var(--r-sm) 0 0;
padding: 8px 14px;
font-family: var(--font-mono);
font-size: var(--text-mono);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.tab:hover { color: var(--cream); background: var(--bg-card); }
.tab.on {
color: var(--cream);
background: var(--bg-card);
border-color: var(--border);
border-bottom-color: var(--bg-card);
}
.right {
margin-left: auto;
display: flex;
@@ -1,6 +1,8 @@
<script lang="ts">
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte';
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
let limit = $state(25);
</script>
<section>
@@ -17,8 +19,9 @@
<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}
<ol class="timeline">
{#each events as event (event.id)}
{#each events.slice(-limit) as event (event.id)}
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
{/each}
</ol>
@@ -34,7 +37,7 @@
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; }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
.flow .done { color: var(--text-primary); }
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
+9 -16
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import RoundTelemetry from './RoundTelemetry.svelte';
import ServerIdentity from './ServerIdentity.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.js';
import { mapTitle, serverTitle } from '$ui/server-labels.js';
import { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.js';
import Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.svelte';
import Stat from './primitives/Stat.svelte';
@@ -71,6 +72,7 @@
let showRoster = $derived(health === 'online' || health === 'stale');
let title = $derived(serverTitle(name));
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
let gtLabel = $derived(gametypeLabel(gametype));
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
@@ -101,7 +103,7 @@
<div class="head">
<div class="name-row">
<StatusPip {health} />
<strong class="name" title={name}>{title}</strong>
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
</div>
{#if health === 'starting'}
<Badge variant="info" uppercase>Starting</Badge>
@@ -119,13 +121,7 @@
{/if}
</div>
<div class="meta">
<span class="map">{mapLabel}</span>
{#if gtLabel}
<span class="dot" aria-hidden="true">·</span>
<span class="gt">{gtLabel}</span>
{/if}
</div>
{#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 === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
@@ -194,7 +190,7 @@
outline: 1px solid rgba(255, 176, 32, 0.28);
}
:global(.card.offline) {
opacity: 0.62;
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
}
.restart-bar {
@@ -232,9 +228,7 @@
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.players { flex-shrink: 0; }
@@ -249,9 +243,8 @@
overflow: hidden;
text-overflow: ellipsis;
}
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
.gt { color: var(--text-tertiary); }
.dot { color: var(--text-quaternary); }
.timing {
display: flex;
@@ -265,7 +258,7 @@
gap: 2px;
}
.metric-label {
font-size: 10px;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
@@ -0,0 +1,12 @@
<script lang="ts">
import { serverIdentity } from '../server-labels.js';
let { name, mapLabel, showGame = true }: { name: string; mapLabel?: string; showGame?: boolean } = $props();
const identity = $derived(serverIdentity(name));
</script>
<span class="identity" title={name}><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span class="lobby">Lobby {identity.lobby}</span>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span>
<style>
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
.map { overflow-wrap: anywhere; }
.lobby { white-space: nowrap; font-size: .85em; color: var(--text-secondary); font-weight: var(--weight-medium); }
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
</style>
@@ -45,8 +45,9 @@
aria-label={ariaLabel}
{title}
href={disabled || loading ? undefined : href}
role="button"
tabindex="0"
tabindex={disabled || loading ? -1 : undefined}
aria-busy={loading}
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
@@ -60,6 +61,7 @@
{type}
{title}
aria-label={ariaLabel}
aria-busy={loading}
disabled={disabled || loading}
{onclick}
>
@@ -81,7 +83,8 @@
font-family: var(--font-body);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-normal);
line-height: 1;
line-height: 1.35;
min-height: 40px;
cursor: pointer;
text-decoration: none;
border: 1px solid transparent;
@@ -105,7 +108,7 @@
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
/* Sizes */
.s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
+6 -12
View File
@@ -45,6 +45,7 @@
{tabindex}
{style}
{onclick}
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
>
{@render children?.()}
</svelte:element>
@@ -56,7 +57,8 @@
display: flex;
flex-direction: column;
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 90%, transparent);
background: var(--surface);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
text-decoration: none;
transition:
@@ -77,12 +79,6 @@
/* Backdrop blur only where the browser supports it — some rendering
backends downgrade poorly, and we don't want blurred noise. */
@supports (backdrop-filter: blur(4px)) {
.card {
backdrop-filter: blur(10px) saturate(1.2);
-webkit-backdrop-filter: blur(10px) saturate(1.2);
}
}
/* Padding scale */
.p-none { padding: 0; }
@@ -91,23 +87,21 @@
.p-lg { padding: var(--space-6); }
/* Elevation */
.e-flat { box-shadow: var(--shadow-ambient); }
.e-flat { box-shadow: none; }
.e-raised { box-shadow: var(--shadow-raised); }
.e-interactive {
box-shadow: var(--shadow-ambient);
box-shadow: none;
cursor: pointer;
}
.e-interactive:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-raised);
border-color: var(--border-strong);
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
}
.e-interactive:focus-visible {
outline: none;
box-shadow: var(--focus-ring), var(--shadow-raised);
}
.e-interactive:active { transform: translateY(-1px); }
@media (max-width: 480px) {
.p-md, .p-lg { padding: var(--space-4); }
}
@@ -0,0 +1,42 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Button from './Button.svelte';
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
const id = $props.id();
let dialog = $state<HTMLDialogElement>();
let returnFocus: HTMLElement | null = null;
$effect(() => {
if (!dialog) return;
if (open && !dialog.open) {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.showModal();
} else if (!open && dialog.open) dialog.close();
});
function close() { if (!busy) open = false; }
async function closed() {
open = false;
await tick();
if (returnFocus?.isConnected) returnFocus.focus();
}
</script>
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
<div class="body">{@render children?.()}</div>
{#if actions}<footer>{@render actions()}</footer>{/if}
</dialog>
<style>
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
dialog.wide { width: min(880px, calc(100vw - 32px)); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.body { padding: var(--space-6); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
</style>
@@ -6,20 +6,15 @@
description?: string | null;
icon?: Snippet;
actions?: Snippet;
spacious?: boolean;
}
let { title, description = null, icon, actions }: Props = $props();
let { title, description = null, icon, actions, spacious = false }: Props = $props();
</script>
<div class="empty" role="status">
<div class="empty" class:spacious role="status">
{#if icon}
<div class="icon">{@render icon()}</div>
{:else}
<div class="icon default">
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 4" />
</svg>
</div>
{/if}
<h3 class="title">{title}</h3>
{#if description}<p class="desc">{description}</p>{/if}
@@ -32,12 +27,13 @@
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-16) var(--space-6);
padding: var(--space-6);
gap: var(--space-3);
color: var(--text-secondary);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.spacious { padding: var(--space-12) var(--space-6); }
.icon {
color: var(--text-tertiary);
margin-bottom: var(--space-2);
+13 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { setContext, type Snippet } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
interface Props {
label?: string;
@@ -11,22 +12,28 @@
}
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
const id = $props.id();
setContext<FieldContext>(fieldContext, {
get labelId() { return `${id}-label`; },
get messageId() { return error || hint ? `${id}-message` : undefined; },
get invalid() { return !!error; }
});
</script>
<div class="field">
{#if label}
<label class="label" for={forId}>
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
{label}
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
</label>
</svelte:element>
{/if}
<div class="control" class:has-error={!!error}>
{@render children?.()}
</div>
{#if error}
<div class="msg msg-error" role="alert">{error}</div>
<div id={`${id}-message`} class="msg msg-error" role="alert">{error}</div>
{:else if hint}
<div class="msg msg-hint">{hint}</div>
<div id={`${id}-message`} class="msg msg-hint">{hint}</div>
{/if}
</div>
@@ -40,9 +47,7 @@
.label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
color: var(--text-secondary);
}
.req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; }
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props {
value?: string;
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
@@ -13,6 +16,7 @@
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
accept?: string;
ariaLabel?: string;
}
let {
@@ -28,7 +32,8 @@
mono = false,
onchange,
oninput,
accept
accept,
ariaLabel
}: Props = $props();
</script>
@@ -44,6 +49,10 @@
{readonly}
{autocomplete}
{accept}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
{oninput}
@@ -0,0 +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();
</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>
{/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>
@@ -37,7 +37,7 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-8);
margin-bottom: 0;
}
.crumbs {
font-size: var(--text-xs);
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Option {
value: string;
label: string;
@@ -14,6 +17,7 @@
disabled?: boolean;
placeholder?: string;
onchange?: (e: Event) => void;
ariaLabel?: string;
}
let {
@@ -24,7 +28,8 @@
required = false,
disabled = false,
placeholder,
onchange
onchange,
ariaLabel
}: Props = $props();
</script>
@@ -35,6 +40,10 @@
{name}
{required}
{disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
>
@@ -0,0 +1,7 @@
<script lang="ts">
import Badge from './Badge.svelte';
import { statusPresentation } from '../../status.js';
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
const status = $derived(statusPresentation(state));
</script>
<Badge variant={status.tone} {size}>{status.label}</Badge>
+23 -4
View File
@@ -12,9 +12,24 @@
onchange?: (id: string) => void;
ariaLabel?: string;
size?: 'sm' | 'md';
panelId?: string;
idPrefix?: string;
}
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
const uid = $props.id();
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
function onKey(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const enabled = tabs.filter(t => !t.disabled);
if (!enabled.length) return;
const index = enabled.findIndex(t => t.id === value);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
select(enabled[next].id, false);
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
}
function select(id: string, disabled: boolean | undefined) {
if (disabled) return;
@@ -30,7 +45,11 @@
class="tab"
class:active={value === t.id}
role="tab"
id={`${idPrefix}-${t.id}`}
aria-selected={value === t.id}
aria-controls={panelId}
tabindex={value === t.id ? 0 : -1}
onkeydown={onKey}
disabled={t.disabled}
onclick={() => select(t.id, t.disabled)}
>
@@ -52,7 +71,7 @@
gap: 2px;
padding: 4px;
background: color-mix(in oklab, var(--surface) 80%, transparent);
border-radius: var(--radius-pill);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.tab {
@@ -68,7 +87,7 @@
font-family: var(--font-body);
font-weight: var(--weight-medium);
cursor: pointer;
border-radius: var(--radius-pill);
border-radius: var(--radius-sm);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
@@ -97,7 +116,7 @@
background: var(--surface-overlay);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-size: var(--text-xs);
}
.tab.active .badge {
background: var(--accent-primary);
@@ -1,4 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props {
value?: string;
placeholder?: string;
@@ -10,6 +13,7 @@
readonly?: boolean;
mono?: boolean;
oninput?: (e: Event) => void;
ariaLabel?: string;
}
let {
@@ -22,7 +26,8 @@
disabled = false,
readonly = false,
mono = false,
oninput
oninput,
ariaLabel
}: Props = $props();
</script>
@@ -36,6 +41,10 @@
{required}
{disabled}
{readonly}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value
{oninput}
></textarea>
+25
View File
@@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
export interface Confirmation {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
export const confirmation = writable<Confirmation | null>(null);
let resolvePending: ((accepted: boolean) => void) | null = null;
/** Resolve only from a deliberate confirm/cancel action; never default to acceptance. */
export function confirmAction(options: Confirmation): Promise<boolean> {
resolvePending?.(false);
return new Promise(resolve => {
resolvePending = resolve;
confirmation.set(options);
});
}
export function resolveConfirmation(accepted: boolean): void {
const resolve = resolvePending;
resolvePending = null;
confirmation.set(null);
resolve?.(accepted);
}
+6
View File
@@ -0,0 +1,6 @@
/** Native dialogs also receive clicks in their padding; only clicks beyond the bounds dismiss. */
export function isDialogBackdrop(event: MouseEvent, dialog: HTMLDialogElement | null | undefined): boolean {
if (!dialog || event.target !== dialog) return false;
const rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
}
+6
View File
@@ -0,0 +1,6 @@
export const fieldContext = Symbol('field');
export interface FieldContext {
readonly labelId: string;
readonly messageId: string | undefined;
readonly invalid: boolean;
}
+25
View File
@@ -0,0 +1,25 @@
/** Poll visible page content without overlapping requests. Fleet watches use their own monitor. */
export function pollVisible(task: () => void | Promise<unknown>, interval: number, immediate = true): () => void {
let stopped = false;
let running = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const visible = () => document.visibilityState !== 'hidden';
const run = async () => {
if (stopped || running || !visible()) return;
clearTimeout(timer);
running = true;
try { await task(); }
catch (error) { console.error('Page refresh failed', error); }
finally {
running = false;
if (!stopped && visible()) timer = setTimeout(run, interval);
}
};
const visibility = () => {
clearTimeout(timer);
if (document.visibilityState !== 'hidden') void run();
};
document.addEventListener('visibilitychange', visibility);
if (immediate) void run(); else timer = setTimeout(run, interval);
return () => { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); };
}
+10
View File
@@ -13,3 +13,13 @@ export function serverTitle(name: string): string {
const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
}
export function serverIdentity(name: string): { map: string; lobby: number | null; game: string } {
const parsed = parseServerName(name);
if (!parsed || !['t5', 't6'].includes(parsed.game.toLowerCase()) || !parsed.mapCanonical || (parsed.gameMode && parsed.gameMode !== 'zombie')) return { map: name, lobby: null, game: '' };
return {
map: zombieMaps(parsed.game).find(entry => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical),
lobby: parsed.lobby,
game: parsed.game === 't6' ? 'Black Ops II' : 'Black Ops I'
};
}
+20
View File
@@ -0,0 +1,20 @@
export type StatusTone = 'neutral' | 'online' | 'warning' | 'danger' | 'info';
const states: Record<string, [string, StatusTone]> = {
online: ['Online', 'online'], offline: ['Offline', 'danger'], stale: ['Report outdated', 'warning'], starting: ['Starting', 'info'],
queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting for empty lobby', 'neutral'],
'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'],
failed: ['Failed', 'danger'], 'test-failed': ['Test failed', 'danger'], interrupted: ['Interrupted', 'danger'],
warning: ['Completed with warnings', 'warning'], held: ['Held by beta', 'neutral'],
manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
untested: ['Needs testing', 'neutral'], testing: ['Beta active', 'info'], empty: ['No versions', 'neutral'], attention: ['Needs attention', 'warning']
};
export function statusPresentation(state: string): { label: string; tone: StatusTone } {
const result = states[state];
return result ? { label: result[0], tone: result[1] } : { label: (state.charAt(0).toUpperCase() + state.slice(1)).replaceAll('-', ' '), tone: 'neutral' };
}
+6 -2
View File
@@ -10,6 +10,7 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
});
export const fleetError = writable('');
export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const lobbyWatches = writable<LobbyWatch[]>([]);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
@@ -17,7 +18,7 @@ export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], (
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
return status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
roster: status.roster, health: status.health, active_since: status.activeSince,
roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt } : server;
}));
@@ -39,6 +40,7 @@ export function refreshFleet(): Promise<void> {
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.');
const [servers, operations] = await Promise.all(responses.map(response => response.json()));
fleetError.set('');
fleetIntegrations.set(servers.integrations);
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
} catch (error) {
fleetError.set(error instanceof Error && error.name !== 'AbortError'
@@ -79,7 +81,9 @@ export function startFleetMonitor(): () => void {
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
});
void refreshFleet();
const interval = setInterval(() => void refreshFleet(), 10_000);
const interval = setInterval(() => {
if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet();
}, 10_000);
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus);
const check = () => {
+2 -1
View File
@@ -23,7 +23,7 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
});
}
export function startPipelineStream(): void {
export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return;
source = new EventSource('/api/events/pipeline');
@@ -49,4 +49,5 @@ export function startPipelineStream(): void {
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
});
});
return () => { source?.close(); source = null; };
}
+2 -1
View File
@@ -50,7 +50,7 @@ let tickInterval: ReturnType<typeof setInterval> | null = null;
let source: EventSource | null = null;
export function startServerStream(): void {
export function startServerStream(): (() => void) | undefined {
if (!browser || source) return;
source = new EventSource('/api/events/servers');
source.addEventListener('server-telemetry', (evt) => {
@@ -73,4 +73,5 @@ export function startServerStream(): void {
if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
}
return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; };
}
+30
View File
@@ -0,0 +1,30 @@
/* Shared layout and interaction rules. Components own their contents; parents own spacing. */
:root { color-scheme: dark; }
:where(button, input, select, textarea) { font-family: var(--font-body); font-size: var(--text-sm); }
:where(input:not([type='checkbox']):not([type='radio']), select, textarea) {
min-width: 0; max-width: 100%; min-height: 40px; padding: 10px 12px;
color: var(--text-primary); background: var(--surface-sunken);
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
}
:where(input, textarea)::placeholder { color: var(--text-tertiary); opacity: 1; }
:where(button, summary) { cursor: pointer; }
:where(input[type='checkbox'], input[type='radio']) { accent-color: var(--accent-primary); }
:where(dialog) { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.ui-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
.ui-toolbar .search, .ui-toolbar > input[type='search'] { flex: 1 1 240px; }
.ui-toolbar select { flex: 0 1 auto; }
.ui-summary { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3) var(--space-6); color: var(--text-secondary); font-size: var(--text-sm); }
.ui-summary strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.ui-error { border: 1px solid color-mix(in srgb, var(--state-danger) 35%, transparent); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); color: var(--state-danger); font-size: var(--text-sm); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); }
.ui-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); }
.ui-sticky-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); position: sticky; bottom: 0; z-index: 5; background: var(--surface); padding: var(--space-4); border-top: 1px solid var(--border-strong); }
.ui-panel { min-width: 0; }
.ui-muted { color: var(--text-secondary); font-size: var(--text-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.ui-toolbar .search, .ui-toolbar > input[type='search'] { flex-basis: 100%; width: 100%; }
.ui-toolbar select { flex: 1 1 130px; }
.ui-sticky-actions { padding: var(--space-3); bottom: env(safe-area-inset-bottom); }
}
+5 -5
View File
@@ -26,7 +26,7 @@
--blue-950: #060b16;
--blue-900: #0d1826;
--blue-700: #1c3d6b;
--blue-500: #2e86d1;
--blue-500: #4da3e6;
--blue-300: #7cc0f5;
/* Neutral gray — cool-tinted, distinct from the blue ramp so surfaces
@@ -62,9 +62,9 @@
/* Text — four steps of hierarchy on dark. */
--text-primary: var(--gray-050);
--text-secondary: var(--gray-300);
--text-tertiary: var(--gray-500);
--text-quaternary: var(--gray-700);
--text-on-accent: #ffffff;
--text-tertiary: #98a4b8;
--text-quaternary: var(--gray-400);
--text-on-accent: #08111f;
/* Border — hairlines, no more. */
--border-subtle: rgba(255, 255, 255, 0.06);
@@ -74,7 +74,7 @@
/* Accent — one color, one voice. */
--accent-primary: var(--blue-500);
--accent-primary-hover: var(--blue-300);
--accent-primary-active: #256cae;
--accent-primary-active: #398fcf;
/* Typography — Inter everywhere, mono for data.
Display face lives inside BrandMark; no global --font-display token. */