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
+1 -1
View File
@@ -37,7 +37,7 @@ export interface BetaSelection { scriptId: number; versionId: number; }
export function betaSelectionHref(selections: BetaSelection[]): string {
const query = new URLSearchParams();
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
return `/betas?${query}`;
return `/betas/new?${query}`;
}
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
const values = query.getAll('artifact');
+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. */
+34 -16
View File
@@ -7,6 +7,9 @@
import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.svelte';
import Confirmation from '$ui/components/Confirmation.svelte';
import { resolveConfirmation } from '$ui/confirm.js';
import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build.
@@ -17,7 +20,7 @@
let menuOpen = $state(false);
let menuButton: HTMLButtonElement;
afterNavigate(() => { menuOpen = false; });
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && menuOpen) {
@@ -27,8 +30,8 @@
}
onMount(() => {
startPipelineStream();
startServerStream();
const stopPipeline = startPipelineStream();
const stopServers = startServerStream();
const stopFleetMonitor = startFleetMonitor();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
@@ -42,7 +45,7 @@
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (!armed && e.key === 'g') {
@@ -64,6 +67,8 @@
window.addEventListener('keydown', onKey);
return () => {
stopFleetMonitor();
stopPipeline?.();
stopServers?.();
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
@@ -74,20 +79,23 @@
onNavigate(enableViewTransitions);
const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' },
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
{ href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
{ href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
];
const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
: tabs[2].match($page.url.pathname)
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
</script>
<svelte:window onkeydown={closeMenu} />
<div class="shell">
<a class="skip-link" href="#main-content">Skip to content</a>
<nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home">
<BrandMark size="sm" />
@@ -112,10 +120,12 @@
</div>
<div class="strut" aria-hidden="true"></div>
</nav>
<main>
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
<main id="main-content" tabindex="-1">
{@render children()}
</main>
<Toaster />
<Confirmation />
</div>
<style>
@@ -140,7 +150,8 @@
color: var(--text-on-accent);
}
:global(:focus-visible) {
outline: none;
outline: 2px solid var(--accent-primary-hover);
outline-offset: 3px;
}
/* Ambient background — a faint radial gradient hints at depth without
@@ -223,14 +234,14 @@
main {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-10) var(--space-10) var(--space-24);
padding: var(--space-6) var(--space-8) var(--space-16);
max-width: 1440px;
margin: 0 auto;
view-transition-name: main;
}
/* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1000px) {
@media (max-width: 800px) {
nav {
flex-wrap: wrap;
justify-content: space-between;
@@ -271,6 +282,13 @@
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
.skip-link:focus { top: 12px; }
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
.subnav a { text-decoration: none; white-space: nowrap; font-size: var(--text-sm); color: var(--text-secondary); padding: var(--space-3) 0; border-bottom: 2px solid transparent; }
.subnav a[aria-current] { color: var(--text-primary); border-bottom-color: var(--accent-primary); }
@media (max-width: 720px) { .subnav > div { padding-inline: var(--space-4); } }
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
:global(::view-transition-old(root)),
+51 -40
View File
@@ -3,7 +3,7 @@
import ServerCard from '$ui/components/ServerCard.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
@@ -22,6 +22,7 @@
let view = $state<'cards' | 'table'>('cards');
let grouping = $state<'map' | 'game' | 'none'>('none');
let operationsOpen = $state(false);
let viewOptionsOpen = $state(false);
let pinned = $state<string[]>([]);
let preferencesReady = $state(false);
let deployOpen = $state(false);
@@ -110,10 +111,10 @@
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Fleet" subtitle="Your lobbies. Every player. The next move.">
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
{#snippet actions()}
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
<Button variant="primary" onclick={() => launch()}>Deploy new server</Button>
<Button variant="primary" onclick={() => launch()}>Create server</Button>
{/snippet}
</SectionHeader>
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
@@ -126,21 +127,21 @@
{#if !$fleet.receivedAt}
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
{:else}
<div class="overview">
<div><Stat label="Players online" value={players} size="lg" tone={players > 0 ? 'online' : 'primary'} tween /><span class="hint">From fresh server reports</span></div>
<div><Stat label="Healthy servers" value={`${healthy.length}/${servers.length}`} size="lg" tween={false} /><span class="hint">{starting ? `${starting} starting` : 'Live fleet status'}</span></div>
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><Stat label="Empty lobbies" value={empty} size="lg" tween /><span class="hint">Find an open lobby →</span></button>
<a class="metric-link" href="/campaigns"><Stat label="Active campaigns" value={campaigns} size="lg" tween /><span class="hint">View progress →</span></a>
<div class="overview" aria-label="Fleet summary">
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
<a class="metric-link" href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
</div>
{#if servers.length || activeOperations.length}
{#if problems.length || attentionOperations.length || starting}
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
<div class="attention-copy">
{#if problems.length}
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button>
{:else}<strong>{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}</strong>{/if}
<span>{problems.length ? 'Open a server to inspect its connection and diagnostics.' : 'Live health, match reports, and operation progress in one place.'}</span>
</div>
{#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if}
</div>
@@ -154,6 +155,9 @@
</a>
{/if}
{#if activeOperations.length || $lobbyWatches.length}
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
<summary>Activity · {activeOperations.length} operations{ $lobbyWatches.length ? ` · ${$lobbyWatches.length} lobby watches` : ''}{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
{#if $lobbyWatches.length}
<div class="watches" aria-label="Lobby watches">
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
@@ -163,9 +167,6 @@
</div>
{/if}
{#if activeOperations.length}
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
<summary>{activeOperations.length} server {activeOperations.length === 1 ? 'operation' : 'operations'} in progress{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
<div class="operation-list">{#each activeOperations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
{/each}</div>
@@ -177,24 +178,28 @@
{:else}
<div class="toolbar">
<div class="search-row">
<label class="search"><span>Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search players, maps, or server names…" /></label>
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">T5 · Black Ops</option><option value="t6">T6 · Black Ops II</option></select></label>
<label class="search"><span class="sr-only">Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search lobbies, maps, or players…" /></label>
<button class="view-options-toggle" aria-expanded={viewOptionsOpen} aria-controls="fleet-view-options" onclick={() => viewOptionsOpen = !viewOptionsOpen}>View options{game ? ' · filtered' : ''}</button>
</div>
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Group by<select bind:value={grouping}><option value="map">Map</option><option value="game">Game</option><option value="none">None</option></select></label>
<label>Sort by<select bind:value={sort}><option value="name">Lobby name</option><option value="players">Most players</option><option value="round">Highest round</option><option value="duration">Longest match</option></select></label>
</div>
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
</div>{/if}
<div class="filter-row">
<div class="chips" aria-label="Filter servers">{#each filters as item}
<button class:selected={filter === item.id} aria-pressed={filter === item.id} onclick={() => filter = item.id}>{item.label}<span>{servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span></button>
{/each}</div>
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
</div>
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}<span class="hint">Pinned lobbies appear first in each group.</span></div>
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}</div>
</div>
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
{#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}>
<div class="group-heading"><div><span class="eyebrow">{grouping === 'map' ? group.game.toUpperCase() : 'Fleet lineup'}</span><h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0)} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div>
{#if view === 'cards'}
@@ -214,7 +219,7 @@
<div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody>
{#each group.servers as server (server.name)}
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)}
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}">{serverTitle(server.name)}</a><small>{fleetIdentity(server).map}</small></td>
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}"><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td>
<td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td>
<td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td>
<td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
@@ -247,48 +252,52 @@
{/snippet}
<style>
.page { display: flex; flex-direction: column; gap: 24px; }
.page { display: flex; flex-direction: column; gap: var(--space-4); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.overview { display: grid; grid-template-columns: repeat(4, 1fr); padding: 24px; gap: 24px; background: var(--surface); border-radius: 16px; border: 1px solid var(--border-subtle); }
.overview { display: flex; align-items: center; flex-wrap: wrap; padding: 12px 16px; gap: 8px 24px; background: var(--surface); border-radius: var(--radius-md); border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: var(--text-sm); }
.overview strong { color: var(--text-primary); font-variant-numeric: tabular-nums; margin-right: 4px; }
.view-options { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius-md); }
.view-options label { flex: 1 1 140px; }
.view-options-toggle { background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); padding: 8px 12px; font-size: var(--text-sm); }
.overview > * { min-width: 0; }
.metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 0; }
.metric-link:hover .hint { color: var(--accent-primary-hover); }
.hint { display: block; color: var(--text-secondary); font-size: 12px; margin-top: 6px; }
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.attention-mark { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; background: var(--surface-raised); color: var(--state-online); }
.has-issues { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
.has-issues .attention-mark { color: var(--state-warning); }
.attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.attention-copy span { font-size: 13px; color: var(--text-secondary); }
.attention button, .result-count button, .error button { background: none; color: var(--accent-primary-hover); border: 0; padding: 0; text-align: left; }
.attention a { font-size: 13px; color: var(--state-warning); }
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 20px 24px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: 14px; color: var(--text-primary); text-decoration: none; }
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 12px 16px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); color: var(--text-primary); text-decoration: none; }
.spotlight > div { flex: 1; min-width: 190px; }
.spotlight strong { display: block; margin-top: 6px; }
.spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
.round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); }
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: 10px; letter-spacing: .12em; font-weight: 600; }
.toolbar { display: flex; flex-direction: column; gap: 18px; }
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: var(--text-xs); letter-spacing: .12em; font-weight: 600; }
.toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
.search { flex: 1; min-width: min(280px, 100%); }
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; min-height: 42px; padding: 10px 12px; max-width: 100%; }
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
.result-count .hint { margin: 0 0 0 auto; }
.map-group { display: flex; flex-direction: column; gap: 16px; }
.group-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
.group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); gap: 16px; align-items: start; }
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: 14px; overflow: hidden; background: var(--surface); }
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
.server-tile.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.server-tile :global(.card) { border-radius: 0; }
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
@@ -298,27 +307,29 @@
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
.server-operations .issue, .operation-list .issue { color: var(--state-warning); }
.operations { border: 1px solid var(--border-subtle); padding: 16px 20px; border-radius: 12px; font-size: 13px; }
.operations { border: 1px solid var(--border-subtle); padding: 16px 20px; border-radius: var(--radius-md); font-size: 13px; }
summary { cursor: pointer; }
.operation-list { display: grid; gap: 10px; margin-top: 16px; }
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: 8px; color: var(--text-primary); }
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.watches { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
.watches { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
.watches > div { margin-right: auto; }
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: 12px; font-size: 13px; line-height: 1.7; }
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
.error button { margin-left: 8px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: 16px; }
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: 12px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; text-align: left; }
th { color: var(--text-secondary); font-weight: 500; white-space: nowrap; font-size: 12px; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
tr:last-child td { border-bottom: 0; }
td small { display: block; color: var(--text-secondary); margin-top: 6px; }
td .server-operations { padding: 0; min-width: 180px; }
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; white-space: nowrap; }
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; }
.health { display: flex; align-items: center; gap: 8px; text-transform: capitalize; }
.table-actions { display: flex; align-items: center; gap: 8px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, 1fr); } .attention { flex-wrap: wrap; } }
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .result-count .hint { width: 100%; margin: 0; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } .operation-list a { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } .operation-list a { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .chips button { white-space: nowrap; flex-shrink: 0; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
</style>
+11 -5
View File
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ url }: { url: URL }) {
const activeOnly = url.searchParams.get('active') === 'true';
const server = url.searchParams.get('server');
const deployments = db()
.prepare(
@@ -14,10 +15,11 @@ export async function GET({ url }: { url: URL }) {
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id = d.id AND target.server = ?))
ORDER BY d.started_at DESC
LIMIT 500`
)
.all() as Array<{
.all(server, server) as Array<{
id: string;
action: string;
started_at: string;
@@ -33,12 +35,15 @@ export async function GET({ url }: { url: URL }) {
prior_version: number | null;
}>;
const ids = deployments.map(deployment => deployment.id);
if (!ids.length) return json({ deployments: [] });
const placeholders = ids.map(() => '?').join(',');
const serversByDep = db()
.prepare(
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
FROM deployment_servers`
FROM deployment_servers WHERE deployment_id IN (${placeholders})`
)
.all() as Array<{
.all(...ids) as Array<{
deployment_id: string;
server: string;
files_changed: number;
@@ -64,9 +69,10 @@ export async function GET({ url }: { url: URL }) {
FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id`
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id
WHERE ds.deployment_id IN (${placeholders})`
)
.all() as Array<{
.all(...ids) as Array<{
deployment_id: string;
script_id: number;
script_name: string | null;
@@ -1,12 +1,18 @@
import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js';
import { listServerScripts } from '$lib/server/fleet/inventory.js';
import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
const metadata = db().prepare(`SELECT i.rel_path, s.kind FROM server_scripts i
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
WHERE i.server = ?`).all(params.name) as Array<{ rel_path: string; kind: string }>;
const kinds = new Map(metadata.map(row => [row.rel_path, row.kind]));
const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path,
kind: kinds.get(r.rel_path) ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
scriptVersionId: r.script_version_id,
sha256: r.sha256,
size: r.size,
+42 -490
View File
@@ -1,17 +1,14 @@
<script lang="ts">
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
import { onMount, onDestroy } from 'svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { parseBetaSelection } from '$lib/script-library.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import { pollVisible } from '$ui/poll.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface BetaListRow {
id: number;
@@ -27,495 +24,50 @@
}>;
servers: Array<{ server: string; stage: string; error: string | null }>;
}
interface ScriptListRow {
id: number; name: string; target_rel_path: string;
scope_type: 'game' | 'map' | null; scope_value: string | null;
current_version_id: number | null; kind: ArtifactKind;
latestVersion: {
id: number; version: number; sha256: string;
state: 'untested' | 'tested' | 'test-failed';
} | null;
runningBetaId: number | null;
}
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
interface ServerRow { name: string; game: string | null; map: string | null; }
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
let betas = $state<BetaListRow[]>([]);
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
let fleetServers = $state<ServerRow[]>([]);
let detailById = $state<Record<number, ScriptDetail>>({});
let nextKey = 0;
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
let pickedServers = $state<Set<string>>(new Set());
let testPlan = $state(defaultPlan());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let flash = $state<string | null>(null);
async function load(): Promise<void> {
const [betasRes, scriptsRes, serversRes] = await Promise.all([
fetch('/api/betas'),
fetch('/api/scripts'),
fetch('/api/servers')
]);
if (!betasRes.ok || !scriptsRes.ok || !serversRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
betas = bBody.betas;
if (scriptsRes.ok) {
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
scripts = sBody.scripts;
}
if (serversRes.ok) {
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
fleetServers = svBody.servers;
}
let error = $state('');
let tab = $state('active');
let search = $state('');
let limit = $state(25);
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
$effect(() => { search; tab; limit = 25; });
async function load() {
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; error = ''; }
catch (e) { error = (e as Error).message; } finally { loading = false; }
}
async function refreshBetas(): Promise<void> {
const res = await fetch('/api/betas');
if (!res.ok) return;
const body = (await res.json()) as { betas: BetaListRow[] };
betas = body.betas;
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
void load().then(seedSelection).catch(error => { flash = (error as Error).message; }).finally(() => { loading = false; });
poll = setInterval(refreshBetas, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
const eligibleScripts = $derived(
scripts.filter(
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
)
);
function eligibleForRow(rowIdx: number): ScriptListRow[] {
const takenIds = new Set(
picks.map((p, i) => (i === rowIdx ? null : p.scriptId)).filter((id): id is number => id !== null)
);
return eligibleScripts.filter((s) => !takenIds.has(s.id));
}
async function seedSelection(): Promise<void> {
const query = new URLSearchParams(window.location.search);
const selections = parseBetaSelection(query);
if (!selections.length) return;
// Validate all selections before populating; never substitute a newer upload.
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
const res = await fetch(`/api/scripts/${scriptId}`);
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|| !detail.versions.some(v => v.id === versionId)) {
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
}
return detail;
}));
for (const detail of details) {
detailById[detail.script.id] = detail;
}
picks = selections.map(s => ({ key: nextKey++, ...s }));
pickedServers = new Set();
}
async function onPickScript(rowIdx: number): Promise<void> {
flash = null;
const pick = picks[rowIdx];
pick.versionId = null;
picks = [...picks];
if (pick.scriptId === null) return;
if (!detailById[pick.scriptId]) {
const res = await fetch(`/api/scripts/${pick.scriptId}`);
if (!res.ok) { flash = 'Failed to load script detail'; return; }
const body = (await res.json()) as ScriptDetail;
detailById = { ...detailById, [pick.scriptId]: body };
}
const detail = detailById[pick.scriptId];
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
pick.versionId = eligibleVersions[0]?.id ?? null;
picks = [...picks];
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function addRow(): void {
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
}
function removeRow(idx: number): void {
picks = picks.filter((_, i) => i !== idx);
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
function versionsForRow(row: Pick): Version[] {
if (row.scriptId === null) return [];
const detail = detailById[row.scriptId];
if (!detail) return [];
return detail.versions.filter((v) => v.id !== detail.script.current_version_id);
}
function candidatesFor(row: Pick): string[] | null {
if (row.scriptId === null) return null;
const detail = detailById[row.scriptId];
if (!detail) return null;
return detail.script.scope_type ? detail.matchedServers : fleetServers.map((s) => s.name);
}
const candidateServers = $derived.by(() => {
const perPick: string[][] = [];
for (const p of picks) {
const c = candidatesFor(p);
if (c === null) continue;
perPick.push(c);
}
if (perPick.length === 0) return [] as string[];
const [first, ...rest] = perPick;
return first.filter((name) => rest.every((set) => set.includes(name)));
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
return pollVisible(load, 5000);
});
const canSubmit = $derived(
!openingBeta &&
testPlan.requirements.length > 0 &&
picks.length > 0 &&
picks.every((p) => p.scriptId !== null && p.versionId !== null) &&
candidateServers.length > 0 &&
pickedServers.size > 0
);
async function openNewBeta(e: Event): Promise<void> {
e.preventDefault();
if (!canSubmit) return;
openingBeta = true;
flash = null;
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
servers: [...pickedServers],
testPlan: parseBetaPlan(testPlan),
waitForHealth,
waitForMatch
})
});
const body = (await res.json()) as { ok: boolean; error?: string; result?: { betaId: number } };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
if (body.result) await goto(`/betas/${body.result.betaId}`);
} catch (error) { flash = (error as Error).message; } finally {
openingBeta = false;
}
}
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
let grouped = $derived(
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
);
function summarizeScripts(b: BetaListRow): string {
if (b.scripts.length === 0) return '(no artifacts)';
if (b.scripts.length === 1) {
const s = b.scripts[0];
return `[${s.kind}] ${s.name} · ${artifactVersionLabel(s.version)}`;
}
const byKind: Record<string, number> = {};
for (const s of b.scripts) byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
return Object.entries(byKind).map(([k, n]) => `${n} ${k}${n === 1 ? '' : 's'}`).join(', ');
}
function betaStateVariant(s: string): 'online' | 'warning' | 'info' | 'neutral' {
return s === 'ready-promote' ? 'online' :
s === 'running' ? 'warning' :
s === 'promoted' ? 'info' :
'neutral';
}
</script>
<div class="page">
<SectionHeader
title="Betas"
subtitle="Test unpromoted versions on selected servers before rolling them out."
/>
{#if flash}<p class="flash" role="alert">{flash}</p>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else}
<Card padding="lg">
<h2 class="panel-title">Open a beta</h2>
{#if eligibleScripts.length === 0}
<EmptyState
title="No artifacts with untested versions"
description="Upload a new version from scripts / configs / plugins to open a beta."
/>
{:else}
<form onsubmit={openNewBeta} class="beta-form">
<Field label="Artifacts">
<div class="picks">
{#each picks as row, idx (row.key)}
<div class="pick-row">
<div class="pick-select">
<select
class="native-select"
bind:value={row.scriptId}
onchange={() => onPickScript(idx)}
>
<option value={null}>— Pick an artifact —</option>
{#each eligibleForRow(idx) as s (s.id)}
<option value={s.id}>[{s.kind}] {s.name}</option>
{/each}
</select>
<span class="chev">▾</span>
</div>
<div class="pick-select">
<select
class="native-select"
bind:value={row.versionId}
disabled={row.scriptId === null}
>
{#if row.scriptId === null}
<option value={null}>—</option>
{:else}
{#each versionsForRow(row) as v (v.id)}
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
{/if}
</select>
<span class="chev">▾</span>
</div>
<button
type="button"
class="row-x"
onclick={() => removeRow(idx)}
disabled={picks.length === 1}
title={picks.length === 1 ? 'Need at least one script' : 'Remove this script'}
aria-label="Remove artifact"
>×</button>
</div>
{/each}
</div>
<button type="button" class="add-row" onclick={addRow}>+ Add artifact</button>
</Field>
<Field label="Test servers" hint="Intersection of scopes across all picked artifacts.">
<div class="server-picker">
{#if candidateServers.length === 0}
<span class="picker-empty">No server satisfies all selected scopes — pick different scripts.</span>
{:else}
{#each candidateServers as name (name)}
<label class="chk">
<input
type="checkbox"
checked={pickedServers.has(name)}
onchange={() => toggleServer(name)}
/>
<span>{name}</span>
</label>
{/each}
{/if}
</div>
</Field>
<BetaPlanBuilder bind:plan={testPlan} />
<div class="form-actions">
<Button
type="submit"
variant="primary"
disabled={!canSubmit}
loading={openingBeta}
>
Open beta
</Button>
</div>
</form>
{/if}
</Card>
{#if betas.length === 0}
<EmptyState title="No betas yet" description="Open one above once you have unpromoted versions." />
{:else}
{#each grouped as g (g.state)}
{#if g.rows.length > 0}
<section class="section">
<div class="section-head">
<h2 class="capitalize">{g.state.replace('-', ' ')}</h2>
<Badge variant="neutral" size="sm">{g.rows.length}</Badge>
</div>
<div class="beta-rows">
{#each g.rows as b (b.id)}
<a class="beta-row" href="/betas/{b.id}">
<span class="beta-id">#{b.id}</span>
<span class="beta-script">{summarizeScripts(b)}</span>
<Badge variant={betaStateVariant(b.state)} size="sm" uppercase>{b.testOutcome && b.state === 'running' ? b.testOutcome : b.state}</Badge>
<span class="beta-servers">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
{/each}
</div>
</section>
{/if}
{/each}
{/if}
{/if}
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
</SectionHeader>
<Tabs {tabs} bind:value={tab} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" />
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
<a href={'/betas/' + beta.id} class="row">
<div><strong>Beta #{beta.id}</strong><span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.length} servers</span></div>
</a>
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
</div>
</div>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
.panel-title {
margin: 0 0 var(--space-4);
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.beta-form { display: flex; flex-direction: column; gap: var(--space-5); }
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
.pick-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
}
.pick-select { position: relative; min-width: 0; }
.native-select {
appearance: none;
width: 100%;
padding: 9px 32px 9px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease-standard);
}
.native-select:hover { border-color: var(--border-strong); }
.native-select:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15); }
.native-select:disabled { opacity: 0.5; cursor: not-allowed; }
.chev {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
font-size: var(--text-xs);
}
.row-x {
background: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 0 14px;
height: 38px;
font-size: var(--text-lg);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.row-x:hover:not(:disabled) { color: var(--state-danger); border-color: rgba(255, 86, 112, 0.4); }
.row-x:disabled { opacity: 0.3; cursor: default; }
.add-row {
align-self: flex-start;
background: transparent;
color: var(--text-secondary);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 8px 14px;
margin-top: var(--space-2);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.add-row:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.server-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.picker-empty {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.chk {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
user-select: none;
}
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head { display: flex; align-items: center; gap: var(--space-3); }
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.capitalize { text-transform: capitalize; }
.beta-rows { display: flex; flex-direction: column; gap: var(--space-2); }
.beta-row {
display: grid;
grid-template-columns: 56px 1fr auto auto;
gap: var(--space-4);
align-items: center;
padding: var(--space-3) var(--space-4);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-primary);
transition: background-color var(--dur-fast) var(--ease-standard);
}
.beta-row:hover { background: var(--surface-raised); }
.beta-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.beta-id { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text-tertiary); }
.beta-script { font-size: var(--text-sm); color: var(--text-primary); font-weight: var(--weight-medium); }
.beta-servers { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
@media (max-width: 720px) {
.beta-row { grid-template-columns: 1fr; gap: var(--space-2); }
.pick-row { grid-template-columns: minmax(0, 1fr) 44px; }
.pick-select:first-child { grid-column: 1 / -1; }
.chk { min-height: 44px; min-width: 0; }
.chk input { flex-shrink: 0; }
}
.page { display:grid; gap:24px; }
.rows { display:grid; gap:8px; }
.row { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); text-decoration:none; }
.row:hover { border-color:var(--border-strong); }
.files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
.meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
</style>
+14 -28
View File
@@ -2,7 +2,11 @@
import { artifactVersionLabel } from '$lib/artifact-version.js';
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
import type { BetaTestView } from "$lib/beta-plan.js";
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { page } from '$app/stores';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -63,11 +67,7 @@
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
poll = setInterval(load, 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
onMount(() => pollVisible(load, 5000, false));
async function promote(): Promise<void> {
acting = 'promote'; flash = null;
@@ -76,7 +76,7 @@
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
await load();
} finally { acting = null; }
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
async function rollback(): Promise<void> {
const isReject = beta?.state === 'ready-promote';
@@ -84,14 +84,14 @@
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
: `Rollback beta #${id}? This restores original files on every beta server.`;
const restart = test ? test.plan.rollbackRestart === 'when-empty' ? ' Restarts will roll out when servers are empty.' : ' Restart servers manually to load the restored files.' : '';
if (!confirm(prompt + restart)) return;
if (!await confirmAction({title:isReject ? 'Reject beta?' : 'Roll back beta?',message:prompt + restart,confirmLabel:isReject ? 'Reject beta' : 'Roll back',danger:true})) return;
acting = 'rollback'; flash = null;
try {
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; }
await load();
} finally { acting = null; }
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
async function retry(server: string): Promise<void> {
acting = `retry:${server}`; flash = null;
@@ -104,21 +104,7 @@
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
await load();
} finally { acting = null; }
}
function betaStateVariant(s: string): 'online' | 'warning' | 'info' | 'neutral' {
return s === 'ready-promote' ? 'online' :
s === 'running' ? 'warning' :
s === 'promoted' ? 'info' :
'neutral';
}
function stageVariant(s: string): 'online' | 'warning' | 'danger' | 'neutral' {
return s === 'ready' ? 'online' :
s === 'failed' ? 'danger' :
s === 'awaiting-empty' || s === 'restarting' || s === 'awaiting-health' || s === 'awaiting-match' || s === 'deploying'
? 'warning' :
'neutral';
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
</script>
@@ -137,7 +123,7 @@
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} />
{/snippet}
{#snippet actions()}
<Badge variant={betaStateVariant(b.state)} size="md" uppercase>{b.state}</Badge>
<StatusBadge state={b.state} />
{/snippet}
</SectionHeader>
@@ -163,7 +149,7 @@
{#each scripts as s (s.id)}
<div class="script-row">
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
<a class="s-name" href={"/" + (s.kind === "baseline" ? "configs" : s.kind + "s") + "/" + s.id}>{s.name}</a>
<span class="s-target">{s.target_rel_path}</span>
<span class="s-ver">{artifactVersionLabel(s.version)}</span>
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
@@ -202,8 +188,8 @@
<div class="srv-list">
{#each servers as bs (bs.server)}
<div class="srv-row">
<a class="srv-name" href="/servers/{bs.server}">{bs.server}</a>
<Badge variant={stageVariant(bs.stage)} size="sm" uppercase>{bs.stage}</Badge>
<a class="srv-name" href="/servers/{bs.server}"><ServerIdentity name={bs.server} /></a>
<StatusBadge state={bs.stage} />
<div class="ticks" aria-label="pipeline progress">
<span class:on={bs.deployed_at}>Deploy</span>
<span class:on={bs.restarted_at || bs.stage === 'restarting'}>Empty</span>
+422
View File
@@ -0,0 +1,422 @@
<script lang="ts">
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
import { onMount, tick } from 'svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { goto } from '$app/navigation';
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
import { parseBetaSelection } from '$lib/script-library.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface ScriptListRow {
id: number; name: string; target_rel_path: string;
scope_type: 'game' | 'map' | null; scope_value: string | null;
current_version_id: number | null; kind: ArtifactKind;
latestVersion: {
id: number; version: number; sha256: string;
state: 'untested' | 'tested' | 'test-failed';
} | null;
runningBetaId: number | null;
}
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
interface ServerRow { name: string; game: string | null; map: string | null; }
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
const fleetServers = $derived($sharedFleet);
let detailById = $state<Record<number, ScriptDetail>>({});
let nextKey = 0;
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
let pickedServers = $state<Set<string>>(new Set());
let testPlan = $state(defaultPlan());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let flash = $state<string | null>(null);
async function load(): Promise<void> {
const scriptsRes = await fetch('/api/scripts');
if (!scriptsRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
if (scriptsRes.ok) {
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
scripts = sBody.scripts;
}
}
async function initialize() {
loading = true; flash = null;
try { await load(); await seedSelection(); }
catch (error) { flash = (error as Error).message; }
finally { loading = false; }
}
onMount(() => { void initialize(); });
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
let step = $state(0);
let stepHeading = $state<HTMLHeadingElement>();
const filesReady = $derived(picks.length > 0 && picks.every(p => p.scriptId !== null && p.versionId !== null));
async function move(next: number) {
flash = null;
if (next > step && step === 2) {
try { parseBetaPlan(testPlan); } catch (error) { flash = (error as Error).message; return; }
}
step = next; await tick(); stepHeading?.focus();
}
const eligibleScripts = $derived(
scripts.filter(
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
)
);
function eligibleForRow(rowIdx: number): ScriptListRow[] {
const takenIds = new Set(
picks.map((p, i) => (i === rowIdx ? null : p.scriptId)).filter((id): id is number => id !== null)
);
return eligibleScripts.filter((s) => !takenIds.has(s.id));
}
async function seedSelection(): Promise<void> {
const query = new URLSearchParams(window.location.search);
const selections = parseBetaSelection(query);
if (!selections.length) return;
// Validate all selections before populating; never substitute a newer upload.
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
const res = await fetch(`/api/scripts/${scriptId}`);
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|| !detail.versions.some(v => v.id === versionId)) {
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
}
return detail;
}));
for (const detail of details) {
detailById[detail.script.id] = detail;
}
picks = selections.map(s => ({ key: nextKey++, ...s }));
pickedServers = new Set();
}
async function onPickScript(rowIdx: number): Promise<void> {
flash = null;
const pick = picks[rowIdx];
const scriptId = pick.scriptId;
pickedServers = new Set();
pick.versionId = null;
picks = [...picks];
if (scriptId === null) return;
if (!detailById[scriptId]) {
const res = await fetch(`/api/scripts/${scriptId}`).catch(() => null);
if (!res?.ok) { flash = 'Could not load file versions. Choose the file again to retry.'; return; }
const body = (await res.json()) as ScriptDetail;
detailById = { ...detailById, [scriptId]: body };
}
if (pick.scriptId !== scriptId) return;
const detail = detailById[scriptId];
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
pick.versionId = eligibleVersions[0]?.id ?? null;
picks = [...picks];
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function addRow(): void {
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
}
function removeRow(idx: number): void {
picks = picks.filter((_, i) => i !== idx);
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
function versionsForRow(row: Pick): Version[] {
if (row.scriptId === null) return [];
const detail = detailById[row.scriptId];
if (!detail) return [];
return detail.versions.filter((v) => v.id !== detail.script.current_version_id);
}
function candidatesFor(row: Pick): string[] | null {
if (row.scriptId === null) return null;
const detail = detailById[row.scriptId];
if (!detail) return null;
return detail.script.scope_type ? detail.matchedServers : fleetServers.map((s) => s.name);
}
const candidateServers = $derived.by(() => {
const perPick: string[][] = [];
for (const p of picks) {
const c = candidatesFor(p);
if (c === null) continue;
perPick.push(c);
}
if (perPick.length === 0) return [] as string[];
const [first, ...rest] = perPick;
return first.filter((name) => rest.every((set) => set.includes(name)));
});
const canSubmit = $derived(
!openingBeta &&
testPlan.requirements.length > 0 &&
picks.length > 0 &&
picks.every((p) => p.scriptId !== null && p.versionId !== null) &&
candidateServers.length > 0 &&
pickedServers.size > 0
);
async function openNewBeta(e: Event): Promise<void> {
e.preventDefault();
if (step !== 3 || !canSubmit) return;
openingBeta = true;
flash = null;
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
servers: [...pickedServers],
testPlan: parseBetaPlan(testPlan),
waitForHealth,
waitForMatch
})
});
const body = (await res.json()) as { ok: boolean; error?: string; result?: { betaId: number } };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
if (body.result) await goto(`/betas/${body.result.betaId}`);
} catch (error) { flash = (error as Error).message; } finally {
openingBeta = false;
}
}
</script>
<div class="page">
<SectionHeader title="New beta" subtitle="Choose files and test servers, then review before starting.">
{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}
</SectionHeader>
{#if flash}<div class="ui-error" role="alert">{flash}{#if loading === false && scripts.length === 0}<Button size="sm" onclick={initialize}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
{#if loading}<EmptyState title="Loading beta setup…" />
{:else if eligibleScripts.length === 0 && !flash}<EmptyState title="No versions ready to test" description="Upload a new version in the Library to start a beta." />
{:else if scripts.length}
<ol class="steps" aria-label="Beta setup progress">
{#each steps as label, index}<li aria-current={step === index ? 'step' : undefined} class:current={step === index}><span>{index + 1}</span>{label}</li>{/each}
</ol>
<form onsubmit={openNewBeta} class="beta-form">
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
{#if step === 0} <Field label="Files">
<div class="picks">
{#each picks as row, idx (row.key)}
<div class="pick-row">
<div class="pick-select">
<select
class="native-select"
aria-label={`File ${idx + 1}`}
bind:value={row.scriptId}
onchange={() => onPickScript(idx)}
>
<option value={null}>— Pick an file —</option>
{#each eligibleForRow(idx) as s (s.id)}
<option value={s.id}>[{s.kind}] {s.name}</option>
{/each}
</select>
<span class="chev">▾</span>
</div>
<div class="pick-select">
<select
class="native-select"
aria-label={`Version for file ${idx + 1}`}
bind:value={row.versionId}
disabled={row.scriptId === null}
>
{#if row.scriptId === null}
<option value={null}>—</option>
{:else}
{#each versionsForRow(row) as v (v.id)}
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
{/if}
</select>
<span class="chev">▾</span>
</div>
<button
type="button"
class="row-x"
onclick={() => removeRow(idx)}
disabled={picks.length === 1}
title={picks.length === 1 ? 'Need at least one script' : 'Remove this script'}
aria-label="Remove file"
>×</button>
</div>
{/each}
</div>
<button type="button" class="add-row" onclick={addRow}>+ Add file</button>
</Field>
{:else if step === 1} <Field label="Test servers" hint="Choose servers that match every selected file.">
<div class="server-picker">
{#if candidateServers.length === 0}
<span class="picker-empty">No server satisfies all selected scopes — pick different scripts.</span>
{:else}
{#each candidateServers as name (name)}
<label class="chk">
<input
type="checkbox"
checked={pickedServers.has(name)}
onchange={() => toggleServer(name)}
/>
<ServerIdentity {name} />
</label>
{/each}
{/if}
</div>
</Field>
{:else if step === 2}<BetaPlanBuilder bind:plan={testPlan} />
{:else}
<div class="ui-panel review">
<h3>Files and versions</h3>
<ul>{#each picks as pick}<li><strong>{scripts.find(s => s.id === pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsForRow(pick).find(v => v.id === pick.versionId))}</li>{/each}</ul>
<h3>Test servers ({pickedServers.size})</h3>
<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name} /></li>{/each}</ul>
<h3>Pass criteria</h3><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour testing window</p>
<ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul>
<h3>Rollout and failure handling</h3>
<p>{testPlan.waveSize ? `${testPlan.waveSize} servers per wave.` : 'Deploy to all selected servers.'} {testPlan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : testPlan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold the beta for review.'} {testPlan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart the servers manually.'}</p>
<p class="ui-muted">Starting installs these versions and restarts the selected servers. Promotion to the rest of the fleet is a separate action after testing.</p>
</div>
{/if}
<div class="ui-sticky-actions">
<Button onclick={() => move(step - 1)} disabled={step === 0 || openingBeta}>Back</Button>
{#if step < 3}<Button variant="primary" onclick={() => move(step + 1)} disabled={step === 0 ? !filesReady : step === 1 ? pickedServers.size === 0 : testPlan.requirements.length === 0}>Continue</Button>
{:else}<Button type="submit" variant="primary" disabled={!canSubmit} loading={openingBeta}>Start beta</Button>{/if}
</div>
</form>
{/if}
</div>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.beta-form { display: flex; flex-direction: column; gap: var(--space-5); }
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
.pick-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
}
.pick-select { position: relative; min-width: 0; }
.native-select {
appearance: none;
width: 100%;
padding: 9px 32px 9px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease-standard);
}
.native-select:hover { border-color: var(--border-strong); }
.native-select:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15); }
.native-select:disabled { opacity: 0.5; cursor: not-allowed; }
.chev {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
font-size: var(--text-xs);
}
.row-x {
background: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 0 14px;
height: 38px;
font-size: var(--text-lg);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.row-x:hover:not(:disabled) { color: var(--state-danger); border-color: rgba(255, 86, 112, 0.4); }
.row-x:disabled { opacity: 0.3; cursor: default; }
.add-row {
align-self: flex-start;
background: transparent;
color: var(--text-secondary);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 8px 14px;
margin-top: var(--space-2);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.add-row:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.server-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.picker-empty {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.chk {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
user-select: none;
}
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
h2 { margin: 0; font-size: var(--text-lg); }
.steps { display: flex; list-style: none; margin: 0; padding: 0; gap: 12px; flex-wrap: wrap; }
.steps li { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 13px; }
.steps li span { border: 1px solid var(--border-subtle); border-radius: 50%; padding: 4px 8px; }
.steps li.current { color: var(--text-primary); font-weight: 600; }
.steps li.current span { background: var(--accent-primary); color: var(--text-on-accent); }
.review { display: grid; gap: 16px; }
.review h3,.review p,.review ul { margin: 0; }
.review li { margin-bottom: 8px; }
@media (max-width: 720px) {
.pick-row { grid-template-columns: minmax(0,1fr) 44px; }
.pick-select:first-child { grid-column: 1 / -1; }
.chk { min-height: 44px; }
.server-picker { flex-direction: column; }
}
</style>
+56 -50
View File
@@ -1,5 +1,11 @@
<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 { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -47,6 +53,10 @@
interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
let loadError = $state('');
let limit = $state(25);
let driftLimit = $state(25);
$effect(() => { driftFilter; driftLimit = 25; });
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let drift = $state<DriftPreview | null>(null);
@@ -54,20 +64,28 @@
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
let driftError = $state('');
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
async function loadCampaigns(): Promise<void> {
const res = await fetch('/api/campaigns');
const body = (await res.json()) as { deployments: Deployment[] };
deployments = body.deployments;
try {
const res = await fetch('/api/campaigns');
if (!res.ok) throw new Error('Could not refresh rollouts.');
deployments = (await res.json()).deployments; loadError = '';
} catch (error) { loadError = (error as Error).message; }
finally { loading = false; }
}
async function scanDrift(): Promise<void> {
if (scanning) return;
scanning = true;
try {
const res = await fetch('/api/drift');
if (!res.ok) throw new Error('Could not scan drift.');
drift = (await res.json()) as DriftPreview;
driftError = '';
} catch (error) { driftError = (error as Error).message;
} finally {
scanning = false;
}
@@ -97,7 +115,7 @@
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
if (!await confirmAction({ title:'Reconcile drift?', message:`This will ${parts.join(' and ')} across ${drift.servers.length} servers.`, confirmLabel:'Reconcile', danger:true })) return;
running = true; flash = null;
try {
const res = await fetch('/api/drift', {
@@ -117,20 +135,12 @@
if (restored > 0) parts2.push(`installed ${restored}`);
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
await Promise.all([loadCampaigns(), scanDrift()]);
} finally {
} catch (error) { flash = (error as Error).message; } finally {
running = false;
}
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await Promise.all([loadCampaigns(), scanDrift()]);
loading = false;
poll = setInterval(loadCampaigns, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
onMount(() => { void scanDrift(); return pollVisible(loadCampaigns, 5000); });
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
@@ -142,13 +152,6 @@
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
@@ -169,16 +172,16 @@
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = 'Rollback campaign opened';
flash = 'Rollback started';
await loadCampaigns();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
rollingBack = null;
}
}
@@ -186,7 +189,7 @@
// History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>();
for (const d of history) {
for (const d of history.slice(0,limit)) {
const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
@@ -197,29 +200,27 @@
// Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived(
drift?.servers.filter((s) =>
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase())
!driftFilter || (s.server + ' ' + serverTitle(s.server)).toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<div class="page">
<SectionHeader
title="Campaigns"
subtitle="Fleet campaigns, drift reconciliation, and rollbacks. Servers restart in stages as earlier servers recover."
title="Rollouts"
subtitle="Track file rollouts, reconcile drift, and review rollbacks."
/>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
<button
type="button"
class="drift-summary"
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
{#if driftError}<div class="ui-error" role="alert">{driftError}<Button size="sm" onclick={scanDrift} disabled={scanning}>Retry scan</Button></div>{/if}
<div class="drift-summary">
<button class="drift-summary-l" aria-expanded={driftOpen} aria-controls="drift-body" onclick={() => driftOpen = !driftOpen}>
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span>
{#if drift === null}
@@ -240,8 +241,8 @@
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
</div>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
</button>
<div class="drift-summary-r">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
@@ -255,21 +256,21 @@
Reconcile
</Button>
</div>
</button>
</div>
{#if driftOpen}
<div class="drift-body">
<div class="drift-body" id="drift-body">
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter"
class="drift-filter" aria-label="Filter drift by server"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
/>
{#each filteredDriftServers as s (s.server)}
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<span class="server-name">{s.server}</span>
<span class="server-name"><ServerIdentity name={s.server} /></span>
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="drift-files">
@@ -286,6 +287,7 @@
</div>
</div>
{/each}
<ListFooter total={filteredDriftServers.length} bind:limit={driftLimit} label="servers" />
{:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else}
@@ -308,7 +310,7 @@
{#if active.length === 0}
<EmptyState
title="Nothing in flight"
description="Push scripts or open a beta to see campaigns here."
description="Promote a beta to roll out tested files to the fleet."
/>
{:else}
<div class="deploy-list">
@@ -327,7 +329,7 @@
</div>
{#if history.length === 0}
<EmptyState
title="No completed campaigns in the retention window"
title="No completed rollouts in the retention window"
description="Retention is 7 days by default."
/>
{:else}
@@ -343,14 +345,15 @@
{/each}
{/if}
</section>
<ListFooter total={history.length} bind:limit label="rollouts" />
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<a class="deploy-row" href="/campaigns/{d.id}">
<article class="deploy-row">
<div class="row-head">
<div class="row-head-l">
<span class="action">{d.action}</span>
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span>
{/if}
@@ -381,7 +384,7 @@
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
<span class="node-label">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
</span>
{/each}
</div>
@@ -391,12 +394,12 @@
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
<span class="state-count">{n} <StatusBadge {state} size="sm" /></span>
{/if}
{/each}
</div>
</div>
</a>
</article>
{/snippet}
<style>
@@ -664,4 +667,7 @@
.drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; }
}
.drift-summary-l { padding:0; background:transparent; border:0; color:inherit; text-align:left; cursor:pointer; }
.state-count { display:inline-flex; align-items:center; gap:5px; }
.action { color:var(--text-primary); text-decoration:underline; text-underline-offset:4px; }
</style>
+35 -86
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { page } from '$app/stores';
import DiffViewer from '$ui/components/DiffViewer.svelte';
import Card from '$ui/components/primitives/Card.svelte';
@@ -35,6 +39,10 @@
let detail = $state<Detail | null>(null);
let loadError = $state('');
let request = 0;
let diffOpen = $state(false);
let diffError = $state('');
let diffLoading = $state(false);
let diffRequest = 0;
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
async function loadDetail(): Promise<void> {
@@ -42,7 +50,7 @@
const current = ++request;
try {
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
if (!res.ok) throw new Error(res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.');
if (!res.ok) throw new Error(res.status === 404 ? 'Rollout not found.' : 'Could not refresh rollout.');
const body = await res.json() as Detail;
if (current !== request || id !== $page.params.id) return;
detail = body; loadError = '';
@@ -51,20 +59,21 @@
}
}
$effect(() => { $page.params.id; detail = null; openFile = null; loadError = ''; void loadDetail(); });
$effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; void loadDetail(); });
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
poll = setInterval(loadDetail, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
onMount(() => pollVisible(loadDetail, 5000, false));
async function loadDiff(f: FileRow) {
const res = await fetch(`/api/campaigns/${$page.params.id}/file/${f.id}`);
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string };
openFile = { id: f.id, ...body };
const id = $page.params.id;
const current = ++diffRequest;
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
try {
const response = await fetch('/api/campaigns/' + id + '/file/' + f.id);
if (!response.ok) throw new Error('Could not load this file comparison. Close and select it again to retry.');
const body = await response.json();
if (id === $page.params.id && current === diffRequest) openFile = {id:f.id, ...body};
} catch (error) { if (id === $page.params.id && current === diffRequest) diffError = (error as Error).message; }
finally { if (current === diffRequest) diffLoading = false; }
}
let byServer = $derived.by(() => {
@@ -76,14 +85,6 @@
}
return m;
});
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
return v === 'added' ? 'online' :
v === 'updated' ? 'info' :
@@ -100,7 +101,7 @@
}
</script>
<svelte:head><title>Campaign | Electric Cherry</title></svelte:head>
<svelte:head><title>Rollout | Electric Cherry</title></svelte:head>
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
{#if detail}
@@ -109,7 +110,7 @@
<SectionHeader title={action} subtitle={detail.deployment.id}>
{#snippet crumbs()}
<Breadcrumbs items={[
{ label: 'Campaigns', href: '/campaigns' },
{ label: 'Rollouts', href: '/campaigns' },
{ label: action }
]} />
{/snippet}
@@ -139,13 +140,13 @@
{#each detail.servers as s (s.server)}
<div class="srv-row">
<div class="srv-l">
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
{#if s.error}
<span class="err">{s.error}</span>
{/if}
</div>
<div class="srv-r">
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
<StatusBadge state={s.restart_state} />
{#if s.players !== null && s.players > 0}
<span class="players">{s.players}p</span>
{/if}
@@ -165,11 +166,11 @@
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div>
{#if detail.files.length === 0}
<EmptyState title="No file changes recorded" description="This campaign didn't touch any tracked artifacts." />
<EmptyState title="No file changes recorded" description="This rollout didn't change any tracked files." />
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group">
<h3>{srv}</h3>
<h3><ServerIdentity name={srv} /></h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
@@ -188,29 +189,11 @@
{/if}
</section>
{#if openFile}
<div
class="diff-scrim"
onclick={() => (openFile = null)}
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
role="button"
tabindex="-1"
aria-label="Close diff"
>
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
<div class="diff-head">
<span class="diff-title">{openFile.relPath}</span>
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
</div>
<DiffViewer
before={openFile.before}
after={openFile.after}
verb={openFile.verb}
relPath={openFile.relPath}
/>
</div>
</div>
{/if}
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
{#if diffError}<p class="ui-error" role="alert">{diffError}</p>
{:else if diffLoading}<EmptyState title="Loading comparison…" />
{:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
</Dialog>
</div>
{:else if !loadError}
<div class="loading">Loading…</div>
@@ -341,44 +324,10 @@
}
/* Diff modal */
.diff-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: grid;
place-items: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.diff-modal {
width: min(1000px, 92vw);
max-height: 92vh;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-raised);
}
.diff-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.diff-title {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
@media (max-width: 720px) {
.srv-row { align-items: flex-start; flex-direction: column; }
.srv-l, .file-row { flex-wrap: wrap; }
.diff-modal { padding: var(--space-4); max-height: 92dvh; }
}
</style>
+61 -43
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -31,13 +38,23 @@
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let confirmRemove = $state('');
let tab = $state('active');
let limit = $state(25);
let collectionLimit = $state(10);
let historyState = $state('all');
$effect(() => { action; search; tab; historyState; limit = 25; collectionLimit = 10; });
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]);
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
let action = $state('all');
let search = $state('');
let refreshing = false;
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
const attention = $derived(deployments.filter(needsAttention));
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
(historyState === 'all' || needsAttention(d)) &&
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
async function load() {
if (refreshing) return;
refreshing = true;
@@ -54,12 +71,7 @@
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
onMount(() => {
void load();
void loadBlueprints();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
onMount(() => pollVisible(load, 5000));
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
@@ -70,11 +82,12 @@
finally { blueprintsLoading = false; }
}
async function removeBlueprint(id: string) {
if (!await confirmAction({title:'Remove blueprint?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove blueprint', danger:true})) return;
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove blueprint.');
blueprints = blueprints.filter(b => b.id !== id); confirmRemove = '';
blueprints = blueprints.filter(b => b.id !== id);
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
@@ -87,20 +100,18 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." />
<div class="launch-banner">
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps and target lobby counts. Existing and queued servers count toward the blueprint; preview and create only the shortfall.</p></div>
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
</div>
<SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
{#snippet actions()}<Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
</SectionHeader>
<Tabs {tabs} bind:value={tab} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<div class="fleet-shutdown">
<div><strong>Call it a night.</strong><p>Queue the whole fleet for removal once each server is empty.</p></div>
<Button variant="danger" onclick={() => housePartyOpen = true}>House Party Protocol</Button>
</div>
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in In flight below.`; void load(); }} />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if tab === 'blueprints'}
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
@@ -108,7 +119,7 @@
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
{:else}
<div class="blueprints">
{#each blueprints as blueprint (blueprint.id)}
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
@@ -120,57 +131,61 @@
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>
{#if confirmRemove === blueprint.id}
<div class="remove-confirm">
<p>Remove this blueprint? Existing servers and launch history are kept.</p>
<button disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>{removing === blueprint.id ? 'Removing…' : 'Remove blueprint'}</button>
<button disabled={!!removing} onclick={() => confirmRemove = ''}>Keep it</button>
</div>
{/if}
</article>
{/each}
</div>
{/if}
</section>
<ListFooter total={blueprints.length} bind:limit label="blueprints" />
{:else}
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collections.length}
{#if visibleCollections.length}
<section>
<h2>Collection launches</h2>
<div class="rows">{#each collections as collection (collection.id)}
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
<a class="deployment-row" href="/deployments/collections/{collection.id}">
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.failed ? 'danger' : collection.active ? 'info' : 'online'}>{collection.active ? 'In progress' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<p>{collection.done} of {collection.total} servers verified{collection.failed ? ` · ${collection.failed} need attention` : ''}{collection.active ? ` · ${collection.active} in progress` : ''}</p>
<span class="meta">Started {fmt(collection.created_at)}</span>
</a>
{/each}</div>
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} step={10} label="collections" />
</section>
{/if}
{#if tab === 'active'}
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; }}>Review history</Button></div>{/if}
<section>
<h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading deployments...</p>
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section>
{:else}
<section>
<h2>History</h2>
<div class="filters">
<label>Operation <select bind:value={action}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Destructions</option></select></label>
<label>Status <select bind:value={historyState}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
<label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
</div>
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="History is recorded for server operations performed after this update, including failures." />
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if}
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
<ListFooter total={history.length} bind:limit label="operations" />
</section>
{/if}
{/if}
</div>
</div>
{#snippet row(d: Deployment)}
<a class="deployment-row" href="/deployments/{d.id}">
<div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
<strong class="server">{d.server}</strong>
<strong class="server"><ServerIdentity name={d.server} /></strong>
</div>
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
<StatusBadge state={d.state} />
</div>
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server creation completed.' : 'Server destroyed.') : d.stage}</p>
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
@@ -180,22 +195,21 @@
{/snippet}
<style>
.fleet-shutdown { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
.fleet-shutdown p { margin: 5px 0 0; font-size: 13px; color: var(--text-secondary); }
.page { display: flex; flex-direction: column; gap: 28px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; }
.launch-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; border: 1px solid var(--border-strong); border-radius: 16px; padding: 28px; background: linear-gradient(120deg, color-mix(in oklab, var(--accent-primary) 10%, var(--surface)), var(--surface)); }
.launch-banner > div { max-width: 650px; }.launch-banner h2 { font-size: 25px; letter-spacing: -.03em; }.launch-banner p, .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.banner-actions { display: flex; gap: 8px; flex-wrap: wrap; }.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: 10px; color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: 11px; color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button, .remove-confirm button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.remove-confirm { border-top: 1px solid var(--border-subtle); margin-top: 14px; }.remove-confirm button { margin-right: 12px; }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: 12px; padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); }
.rows { display: grid; gap: 12px; }
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
@@ -204,4 +218,8 @@
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 0; } }
.panels { display:grid; gap:24px; }
.fleet-actions { position:relative; font-size:13px; }
.fleet-actions summary { padding:10px; cursor:pointer; }
.fleet-actions[open] :global(.btn) { position:absolute; right:0; top:100%; z-index:10; white-space:nowrap; box-shadow:var(--shadow-raised); }
</style>
+16 -34
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import OperationHistory from '$ui/components/OperationHistory.svelte';
import type { OperationStep } from '$lib/operation-flow.js';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -75,9 +80,6 @@
} catch (err) { error = (err as Error).message; }
finally { resuming = false; }
}
function stateLabel(state: string) {
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
}
async function load() {
if (refreshing) return;
refreshing = true;
@@ -91,23 +93,20 @@
finally { refreshing = false; }
}
$effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => {
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
return () => clearInterval(timer);
});
onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
</script>
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment ? serverTitle(deployment.server) : undefined}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
</SectionHeader>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if detail?.collection}<a href="/deployments/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
{#if deployment}
<section class="summary">
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
<div class="top"><StatusBadge state={deployment.state} />
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div>
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2>
@@ -120,38 +119,21 @@
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
</section>
<section>
<h2>Operation flow</h2>
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} class:failed={!active && ['failed', 'interrupted'].includes(deployment.state) && index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index < currentStep ? '✓' : index + 1}</span>{step}</li>{/each}</ol>
</section>
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
<section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if detail && detail.events.length > 0}
<ol class="timeline">{#each detail.events as event (event.id)}<li><time datetime={event.created_at}>{fmt(event.created_at)}</time><p>{event.message}</p></li>{/each}</ol>
{:else}<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown above.</p>{/if}
</section>
{:else if !error}<p>Loading deployment...</p>{/if}
</div>
<style>
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; overflow-wrap: anywhere; }
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
.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; }
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow li.reached { color: var(--text-primary); }
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
.flow li.failed { border-color: var(--state-danger); }
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
.timeline p { margin-top: 6px; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
.error { color: var(--state-danger); } .warning { color: var(--state-warning); }
@media (max-width: 640px) {
.timing { flex-direction: column; gap: var(--space-2); }
}
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -22,7 +25,7 @@
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
}
$effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => { const timer = setInterval(load, 2000); return () => { clearInterval(timer); requestNumber++; }; });
onMount(() => { const stop = pollVisible(load, 5000, false); return () => { stop(); requestNumber++; }; });
</script>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
@@ -41,7 +44,7 @@
<section class="servers" aria-label="Servers in this collection">
{#each detail.servers as server (server.id)}
<a class="server" href="/deployments/{server.id}">
<div class="top"><strong>{server.server}</strong><Badge variant={server.state === 'done' ? 'online' : ['failed', 'interrupted'].includes(server.state) ? 'danger' : 'info'}>{labels[server.state] ?? server.state}</Badge></div>
<div class="top"><strong><ServerIdentity name={server.server} /></strong><StatusBadge state={server.state} /></div>
<p>{server.stage}</p>
{#if server.error}<p class="error">{server.error}</p>{/if}
<div class="top muted"><span>Port {server.port} · {server.server.startsWith('t6_') ? 'Black Ops II' : 'Black Ops I'}</span><span>View operation →</span></div>
@@ -52,5 +55,5 @@
</div>
<style>
.page { display: grid; gap: 28px; }.summary { display: grid; gap: 20px; padding: 24px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, p { margin: 0; }h2 { font-size: 21px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }dl { display: flex; flex-wrap: wrap; gap: 24px 50px; margin: 0; }dt { color: var(--text-secondary); font-size: 12px; }dd { font-family: var(--font-mono); font-size: 28px; margin: 8px 0 0; }progress { width: 100%; height: 8px; accent-color: var(--accent-primary); }.servers { display: grid; gap: 14px; }.server { display: grid; gap: 14px; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 10px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }.server:hover { border-color: var(--border-strong); }.server strong { font-family: var(--font-mono); font-size: 14px; }.server p { font-size: 13px; line-height: 1.6; }.error { color: var(--state-danger); }
.page { display: grid; gap: 28px; }.summary { display: grid; gap: 20px; padding: 24px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, p { margin: 0; }h2 { font-size: 21px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }dl { display: flex; flex-wrap: wrap; gap: 24px 50px; margin: 0; }dt { color: var(--text-secondary); font-size: 12px; }dd { font-family: var(--font-mono); font-size: 28px; margin: 8px 0 0; }progress { width: 100%; height: 8px; accent-color: var(--accent-primary); }.servers { display: grid; gap: 14px; }.server { display: grid; gap: 14px; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: inherit; text-decoration: none; overflow-wrap: anywhere; }.server:hover { border-color: var(--border-strong); }.server strong { font-family: var(--font-mono); font-size: 14px; }.server p { font-size: 13px; line-height: 1.6; }.error { color: var(--state-danger); }
</style>
+94 -80
View File
@@ -1,5 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { mapLabel } from '$lib/script-library.js';
import PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
import PluginUpdates from '$ui/components/PluginUpdates.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
@@ -20,6 +25,7 @@
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
currentVersion: { id: number; version: number; release_version: string | null } | null;
kind: ArtifactKind;
latestVersion: {
id: number; version: number; sha256: string; size: number;
@@ -32,6 +38,18 @@
}
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
let addOpen = $state(false);
let method = $state('upload');
let updatesOpen = $state(false);
let search = $state('');
let game = $state('');
let status = $state('');
let sort = $state('name');
let limit = $state(25);
$effect(() => { search; game; status; sort; limit = 25; });
const visible = $derived.by(() => plugins.filter(a => (a.name + ' ' + a.target_rel_path).toLowerCase().includes(search.toLowerCase())
&& (!game || !a.scope_type || (a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? '')))
&& (!status || stateLabel(a) === status)).sort((a,b) => sort === 'recent' ? (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') : a.name.localeCompare(b.name)));
let plugins = $state<ArtifactRow[]>([]);
let importCount = $state(0);
let loading = $state(true);
@@ -61,11 +79,11 @@
fetch('/api/scope-options')
]);
if (!pluginRes.ok) {
flash = `Plugins API returned ${pluginRes.status}. If this is a dev environment, restart the server so the DB migration runs.`;
plugins = [];
throw new Error('Could not refresh plugins. Your last loaded results are still shown.');
} else {
plugins = ((await pluginRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
}
flash = null;
if (scopeRes.ok) {
scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} };
}
@@ -105,33 +123,22 @@
: null;
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
newReleaseVersion = '';
addOpen = false;
await load();
} finally {
} catch (error) { flash = (error as Error).message; } finally {
uploading = false;
}
}
function scopeLabel(a: ArtifactRow): string {
if (!a.scope_type) return 'unscoped';
return `${a.scope_type} · ${a.scope_value ?? '?'}`;
}
function stateVariantFor(a: ArtifactRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
if (!a.latestVersion) return null;
if (a.current_version_id === null) return 'neutral';
if (a.latestVersion.id !== a.current_version_id) {
if (a.latestVersion.state === 'untested') return 'neutral';
if (a.latestVersion.state === 'tested') return 'online';
if (a.latestVersion.state === 'test-failed') return 'danger';
}
return null;
return a.scope_type === 'map' ? mapLabel(a.scope_value ?? '') : a.scope_value === 't5' ? 'Black Ops I' : a.scope_value === 't6' ? 'Black Ops II' : a.scope_value ?? 'Unscoped';
}
function stateLabel(a: ArtifactRow): string {
if (!a.latestVersion) return '';
if (a.current_version_id === null) return 'untested';
if (a.latestVersion.id !== a.current_version_id) {
return `${artifactVersionLabel(a.latestVersion)} ${a.latestVersion.state.replace('-', ' ')}`;
}
return '';
if (a.runningBetaId) return 'testing';
if (!a.latestVersion) return 'empty';
if (a.latestVersion.id === a.current_version_id) return 'promoted';
return a.latestVersion.state;
}
let gameOptions = $derived([
@@ -150,20 +157,68 @@
</script>
<div class="page">
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." />
<PluginGithubImport onimport={async () => { importCount++; await load(); }} />
{#key importCount}<PluginUpdates />{/key}
<Card padding="lg">
<h2 class="panel-title">Upload a plugin</h2>
{#if successBanner}
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
</SectionHeader>
{#if successBanner}
<div class="banner">
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
<span>Open a beta on <a href="/betas">the betas page</a> to test it before promotion.</span>
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
</div>
{/if}
<form onsubmit={createPlugin} class="upload-form">
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
<div class="ui-toolbar">
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
</div>
<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>
<section class="section">
<div class="section-head">
<h2>All plugins</h2>
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if visible.length === 0 && !flash}
<EmptyState title={plugins.length ? "No matching plugins" : "No plugins yet"} description={plugins.length ? "Try different filters." : "Add a plugin to upload a file or import a GitHub release."} />
{:else}
<div class="rows">
{#each visible.slice(0,limit) as a (a.id)}
<a class="row" href="/plugins/{a.id}">
<div class="row-top">
<span class="name">{a.name}</span>
<Badge
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
size="sm"
>
{scopeLabel(a)}
</Badge>
<StatusBadge state={stateLabel(a)} />
</div>
<div class="row-meta">
<span class="path">{a.target_rel_path}</span>
<span>{a.currentVersion ? `Promoted ${artifactVersionLabel(a.currentVersion)}` : 'Never promoted'}</span>
<span>Latest {artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
{#if a.servers.length}
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
{/if}
</div>
</a>
{/each}
</div>
<ListFooter total={visible.length} bind:limit label="plugins" />
{/if}
</section>
</div>
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading} wide>
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading},{id:'github',label:'GitHub release',disabled:uploading}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport onimport={async () => { importCount++; addOpen = false; await load(); }} />
{:else} <form onsubmit={createPlugin} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field>
@@ -175,19 +230,19 @@
</Field>
<Field label="Scope" hint="Where this plugin deploys.">
<div class="scope-row">
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
{#if newScopeType === 'game' || newScopeType === 'map'}
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
<Select ariaLabel="Scope game" bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
{/if}
{#if newScopeType === 'map'}
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
{/if}
</div>
</Field>
<Field label="Plugin file (.dll)">
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
</Field>
<div class="form-actions">
<div class="form-actions ui-sticky-actions">
<Button
type="submit"
variant="primary"
@@ -199,56 +254,15 @@
{#if flash}<span class="flash">{flash}</span>{/if}
</div>
</form>
</Card>
<section class="section">
<div class="section-head">
<h2>All plugins</h2>
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if plugins.length === 0}
<EmptyState title="No plugins yet" description="Upload one above to get started." />
{:else}
<div class="rows">
{#each plugins as a (a.id)}
<a class="row" href="/plugins/{a.id}">
<div class="row-top">
<span class="name">{a.name}</span>
<Badge
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
size="sm"
>
{scopeLabel(a)}
</Badge>
{#if stateVariantFor(a)}
{@const v = stateVariantFor(a)!}
<Badge variant={v} size="sm">{stateLabel(a)}</Badge>
{/if}
{#if a.runningBetaId}
<Badge variant="warning" size="sm">β{a.runningBetaId} running</Badge>
{/if}
</div>
<div class="row-meta">
<span class="path">{a.target_rel_path}</span>
<span>{artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
{#if a.servers.length}
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</section>
</div>
{/if}
</div>
</Dialog>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
.panel-title { margin: 0 0 var(--space-4); font-family: var(--font-body); font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); }
.upload-form { margin-top: 20px; }
.banner {
display: flex; flex-direction: column; gap: 4px;
padding: var(--space-3) var(--space-4);
+12 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -8,6 +10,8 @@
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.js';
let limit = $state(25);
$effect(() => { search; status; game; map; sort; limit = 25; });
let scripts = $state<LibraryScript[]>([]);
let loading = $state(true);
let error = $state('');
@@ -38,7 +42,7 @@
return a.name.localeCompare(b.name);
}));
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
const selectable = $derived(visible.filter(canTest));
const selectable = $derived(visible.slice(0,limit).filter(canTest));
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
async function load() {
error = '';
@@ -71,7 +75,7 @@
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
<div class="page">
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
</SectionHeader>
<div class="status-grid" aria-label="Filter by script status">
@@ -102,12 +106,12 @@
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
{:else}
<div class="rows">
{#each visible as s (s.id)}
{#each visible.slice(0,limit) as s (s.id)}
{@const state = scriptStatus(s)}
<article class="script-row" class:selected={selected.includes(s.id)}>
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
<div class="script-info">
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></div>
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><StatusBadge state={state} /></div>
<div class="path">{s.target_rel_path}</div>
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
</div>
@@ -125,6 +129,7 @@
{/each}
</div>
{/if}
<ListFooter total={visible.length} bind:limit label="scripts" />
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
</section>
{#if selectedScripts.length}
@@ -164,4 +169,7 @@
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
.status-grid { display:flex; gap:8px; overflow-x:auto; }
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
.status-grid strong { font-size:13px; }
</style>
+56 -32
View File
@@ -1,10 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Button from '$lib/ui/components/primitives/Button.svelte';
import Field from '$lib/ui/components/primitives/Field.svelte';
import Input from '$lib/ui/components/primitives/Input.svelte';
interface SavedKey { server_id: string; deployed: number; updated_at: string }
let open = $state(false);
let search = $state('');
let limit = $state(25);
$effect(() => { search; limit = 25; });
let keys = $state<SavedKey[]>([]);
let serverId = $state('');
let plutoKey = $state('');
@@ -13,13 +24,15 @@
let loading = $state(true);
let error = $state('');
let notice = $state('');
const filtered = $derived(keys.filter(key => (key.server_id + ' ' + serverTitle(key.server_id)).toLowerCase().includes(search.toLowerCase())));
async function load() {
const res = await fetch('/api/server-keys');
if (!res.ok) throw new Error('Could not load server keys');
keys = (await res.json()).keys;
}
onMount(() => { load().catch(e => error = e.message).finally(() => loading = false); });
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
onMount(() => { void reload(); });
function reset() { serverId = ''; plutoKey = ''; editing = false; }
async function save(e: SubmitEvent) {
e.preventDefault();
@@ -31,12 +44,12 @@
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
reset(); notice = 'Server key saved.'; await load();
reset(); open = false; notice = 'Server key saved.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
async function remove(id: string) {
if (!confirm(`Remove the saved key for ${id}? Running servers are unaffected.`)) return;
if (!await confirmAction({title:'Remove saved key?',message:`Remove the key for ${serverTitle(id)} (${id})? Running servers are unaffected.`,confirmLabel:'Remove key',danger:true})) return;
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
@@ -52,10 +65,39 @@
</script>
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
<h1>Keys</h1>
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
<form onsubmit={save}>
<h2>{editing ? 'Update key' : 'Add key'}</h2>
<div class="page">
<SectionHeader title="Server keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
{#snippet actions()}<Button variant="primary" onclick={() => { reset(); error = ''; open = true; }}>Add key</Button>{/snippet}
</SectionHeader>
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} placeholder="Search servers…" /></div>
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
<h2>Saved keys</h2>
{#if loading}<p>Loading keys…</p>
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
{:else}
<div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
<tr>
<td><ServerIdentity name={key.server_id} /><code>{key.server_id}</code></td>
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
<td aria-label="Key saved">••••••••</td>
<td><div class="actions">
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
</div></td>
</tr>
{/each}</tbody>
</table></div>
{/if}
<ListFooter total={filtered.length} bind:limit label="keys" />
</div>
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1.">
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field>
@@ -64,37 +106,16 @@
</Field>
<div class="actions">
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
{#if editing}<Button onclick={reset} disabled={busy}>Cancel</Button>{/if}
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
</div>
</form>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
<h2>Saved keys</h2>
{#if loading}<p>Loading keys…</p>
{:else if keys.length === 0}<p>No saved keys yet. Add one above or save one when deploying a server.</p>
{:else}
<div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
<tbody>{#each keys as key (key.server_id)}
<tr>
<td><code>{key.server_id}</code></td>
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
<td aria-label="Key saved">••••••••</td>
<td><div class="actions">
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; }}>Update</Button>
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
</div></td>
</tr>
{/each}</tbody>
</table></div>
{/if}
</Dialog>
<style>
h1 { margin-top: 0; }
h2 { font-size: var(--text-md); }
p { color: var(--text-secondary); }
form { display: grid; gap: var(--space-4); max-width: 620px; padding: var(--space-6); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); margin: var(--space-6) 0; }
form h2 { margin: 0; }
.actions { display: flex; gap: var(--space-2); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; }
@@ -102,4 +123,7 @@
th { color: var(--text-tertiary); font-size: var(--text-sm); }
code { font-family: var(--font-mono); }
.error { color: var(--state-danger); }
.page { display:grid; gap:24px; }
form { margin:0; padding:0; border:0; }
code { display:block; font-size:12px; color:var(--text-secondary); margin-top:6px; }
</style>
+90 -85
View File
@@ -1,9 +1,17 @@
<script lang="ts">
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
import { onMount, onDestroy } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ConfigInspector from '$ui/components/ConfigInspector.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { page } from '$app/stores';
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
import { statusByServer, uiNow } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
@@ -68,7 +76,7 @@
game_started_at: string | null; game_ended_at: string | null;
}
interface InventoryEntry {
relPath: string; scriptVersionId: number | null;
relPath: string; kind?: string; scriptVersionId: number | null;
sha256: string; size: number; updatedAt: string; source: string | null;
}
interface Integrations {
@@ -79,11 +87,11 @@
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
}
let server = $state<Server | null>(null);
let integrations = $state<Integrations | null>(null);
let name = $derived($page.params.name ?? '');
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
let integrations = $derived($fleetIntegrations);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
@@ -91,54 +99,53 @@
let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false);
let destroyOpen = $state(false);
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
let activeTab = $state('roster');
let tabLoading = $state(false);
let tabError = $state('');
let historyLimit = $state(25);
let inventoryLimit = $state(25);
const operations = $derived($fleet.operations.filter(op => op.server === name));
async function loadServer(): Promise<void> {
const [srvRes, depRes, invRes] = await Promise.all([
fetch('/api/servers'),
fetch('/api/campaigns'),
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
]);
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
server = srvBody.servers.find((s) => s.name === name) ?? null;
integrations = srvBody.integrations;
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
if (invRes.ok) {
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
inventory = invBody.scripts;
}
async function loadTab(currentName: string, tab: string) {
if (tab !== 'history' && tab !== 'inventory') return;
try {
const response = await fetch(tab === 'history' ? '/api/campaigns?server=' + encodeURIComponent(currentName) : '/api/servers/' + encodeURIComponent(currentName) + '/scripts');
if (!response.ok) throw new Error('Could not refresh ' + tab + '.');
const body = await response.json();
if (name !== currentName || activeTab !== tab) return;
if (tab === 'history') history = body.deployments; else inventory = body.scripts;
tabError = '';
} catch (error) { if (name === currentName && activeTab === tab) tabError = (error as Error).message; }
finally { if (name === currentName && activeTab === tab) tabLoading = false; }
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadServer();
poll = setInterval(loadServer, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
$effect(() => {
const currentName = name;
const tab = activeTab;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'inventory';
return pollVisible(() => loadTab(currentName, tab), 10000);
});
$effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let live = $derived($statusByServer.get(name));
let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null);
let effectiveMap = $derived(live?.map ?? server?.last_map ?? null);
let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null);
let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null);
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
let effectiveErr = $derived(live ? live.lastError : server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
let effectiveMap = $derived(server?.last_map ?? null);
let effectiveGametype = $derived(server?.last_gametype ?? null);
let effectiveRoster = $derived(server?.roster ?? null);
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveSeen = $derived(server?.last_seen_at ?? null);
let effectiveErr = $derived(server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(server?.endpoint ?? null);
let decoded = $derived(decodeRconError(effectiveErr));
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
rconProbe?.rawReply ?? server?.replySnippet ?? null
);
let effectiveActiveSince = $derived<string | null>(live?.activeSince ?? server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(live?.inactiveSince ?? server?.inactive_since ?? null);
let effectiveGameStartedAt = $derived<string | null>(live?.gameStartedAt ?? server?.game_started_at ?? null);
let effectiveGameEndedAt = $derived<string | null>(live?.gameEndedAt ?? server?.game_ended_at ?? null);
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
@@ -146,13 +153,6 @@
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
// Auto-open the Diagnostics tab if there's a problem the operator should see.
$effect(() => {
if (hasDiagnosticIssue && activeTab === 'roster' && effectiveRoster === null) {
activeTab = 'diagnostics';
}
});
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
@@ -173,10 +173,7 @@
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' });
komodoProbe = (await res.json()) as KomodoProbeResult;
if (komodoProbe.ok && server) {
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
integrations = srvBody.integrations;
await refreshFleet();
}
} catch (err) {
komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message };
@@ -198,9 +195,7 @@
try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' });
rconProbe = (await res.json()) as RconProbeResult;
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
await refreshFleet();
} catch (err) {
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
} finally { probingRcon = false; }
@@ -209,6 +204,7 @@
let tabs = $derived([
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
{ id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Config' },
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'history', label: 'History', badge: history.length || null }
@@ -221,9 +217,9 @@
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<!-- Hero: name + status + player count + primary actions -->
<Card padding="lg" elevation="flat">
<SectionHeader title={server.name} size="lg">
<SectionHeader title={serverTitle(server.name)}>
{#snippet crumbs()}
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
{/snippet}
{#snippet actions()}
<Button variant="danger" onclick={() => destroyOpen = true}>
@@ -232,6 +228,7 @@
{/snippet}
</SectionHeader>
<CopyValue value={name} label="Server ID" />
<div class="metrics">
<div class="metric-cell name-cell">
<StatusPip health={effectiveHealth} size="md" />
@@ -244,14 +241,14 @@
<div class="metric-cell">
<Stat
label="Players"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? 0}
size="xl"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
size="md"
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
tween
/>
</div>
<div class="metric-cell">
<Stat label="Map" value={effectiveMap ?? '—'} size="md" tween={false} />
<Stat label="Map" value={fleetIdentity(server).map} size="md" tween={false} />
{#if gametypeLabel(effectiveGametype)}
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
{/if}
@@ -281,11 +278,14 @@
</Card>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
{#if operations.length}<section class="ui-panel"><h2>Current operations</h2><div class="server-operations">{#each operations as operation (operation.key)}<a href={operation.href}><strong>{operation.label}</strong><span>{operation.detail}</span><StatusBadge state={operation.state} /></a>{/each}</div></section>{/if}
<div class="tabs-row">
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" />
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
</div>
<div class="tab-panel">
<div class="tab-panel" role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
{#if activeTab === 'roster'}
<Card padding="lg">
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
@@ -300,22 +300,24 @@
]}
/>
</Card>
{:else if activeTab === 'config'}
{#key name}<ConfigInspector fixedServer={name} />{/key}
{:else if activeTab === 'inventory'}
<Card padding="lg">
<div class="panel-head">
<h2>Scripts on this server</h2>
<h2>Installed files</h2>
<span class="panel-count">{inventory.length}</span>
</div>
{#if inventory.length === 0}
{#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0}
<EmptyState
title="No scripts tracked yet"
description="Upload a script from the Scripts page and open a beta to deploy it here."
title="No files tracked yet"
description="Files appear here after scripts, configs, or plugins are deployed to this server."
/>
{:else}
<div class="inv-list">
{#each inventory as f (f.relPath)}
{#each inventory.slice(0,inventoryLimit) as f (f.relPath)}
<div class="inv-row">
<span class="rel">{f.relPath}</span>
<span class="rel">{f.relPath}<span class="file-kind">{f.kind ?? (f.relPath.endsWith(".dll") ? "Plugin" : f.relPath.endsWith(".cfg") ? "Config" : "Script")}</span></span>
<span class="size">{f.size} B</span>
<span class="src">{f.source ?? '—'}</span>
<span class="sha">{f.sha256.slice(0, 12)}</span>
@@ -324,6 +326,7 @@
</div>
{/if}
</Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
{:else if activeTab === 'diagnostics'}
<Card padding="lg">
<div class="panel-head">
@@ -489,29 +492,22 @@
{:else if activeTab === 'history'}
<Card padding="lg">
<div class="panel-head">
<h2>Campaign history</h2>
<h2>Rollout history</h2>
<span class="panel-count">{history.length}</span>
</div>
{#if history.length === 0}
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
<EmptyState
title="No campaigns yet"
title="No rollouts yet"
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/>
{:else}
<div class="history">
{#each history as d (d.id)}
{#each history.slice(0,historyLimit) as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
<span class="hist-action">{d.action}</span>
<span class="hist-state">
<Badge
variant={mine.restart_state === 'done' ? 'online' :
mine.restart_state === 'failed' ? 'danger' :
mine.restart_state === 'pending' ? 'warning' :
'neutral'}
size="sm"
uppercase
>{mine.restart_state}</Badge>
<StatusBadge state={mine.restart_state} />
{#if mine.players !== null && mine.players > 0}
<span class="hist-players">{mine.players}p</span>
{/if}
@@ -524,9 +520,14 @@
</div>
{/if}
</Card>
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" />
{/if}
</div>
</div>
{:else if $fleetError}
<div class="ui-error" role="alert">{$fleetError}<Button onclick={refreshFleet}>Retry</Button></div>
{:else if $fleet.receivedAt}
<EmptyState title="Server not found" description="This server may have been removed from the fleet." />
{:else}
<div class="loading">Loading…</div>
{/if}
@@ -823,4 +824,8 @@
gap: var(--space-1);
}
}
.server-operations { display:grid; gap:8px; }
.server-operations a { display:flex; gap:12px; flex-wrap:wrap; align-items:center; text-decoration:none; color:var(--text-primary); padding:10px 0; }
.server-operations a span { color:var(--text-secondary); font-size:13px; }
.file-kind { display:block; color:var(--text-secondary); font:12px var(--font-body); margin-top:5px; }
</style>