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
+28
View File
@@ -0,0 +1,28 @@
# UI refresh
The interface keeps the existing dark palette and blue accent, with shared controls and task-focused pages.
1. **Shared design system:** common headers, buttons, fields, dialogs, spacing, radii, and focus styles. Removed unused styles from retired layouts.
2. **Libraries:** Plugins opens on the searchable library. Add plugin opens Upload / GitHub tabs. Scripts uses compact status filters; Configs uses a compact summary and Library / Inspect tabs.
3. **Beta setup:** `/betas/new` guides Files → Servers → Test plan → Review. Exact versions, selected servers, and the plan survive Back / Continue. Existing beta selection links still redirect correctly.
4. **Fleet density:** compact totals and attention summary, with grouping, sorting, and display options behind View options.
5. **Server identity:** friendly map names and visible lobby numbers across cards, lists, beta setup, and operations. T6 variants retain their selected map name. Server details provides a copyable raw ID.
6. **Deployment organization:** Active / Blueprints / History views. Failures and warnings link to filtered history. Destroy fleet lives in Fleet actions and retains its explicit preview.
7. **Navigation:** Fleet / Library / Operations / Betas / Settings, with section navigation and a mobile menu. Existing route URLs remain usable.
8. **Status and progress:** shared status labels and tones distinguish ordinary waiting, work in progress, attention, passing tests, and promotion. Deployment, rollout, and beta details share operation history.
9. **Server detail hub:** live status, current operations, roster, logs, effective configuration, inventory, diagnostics, and scoped rollout history. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files.
10. **Dialogs and feedback:** in-app confirmations for deletion, rollback, reconciliation, and unsaved edits. File comparisons use a native modal dialog with Escape and focus restoration. Refresh errors retain loaded data and provide retry controls.
11. **Keyboard and forms:** labeled fields, roving keyboard tabs, visible focus, a skip link, and separate navigation/action targets. Removed nested buttons and buttons inside rollout links.
12. **Readability:** brighter secondary metadata, readable small labels, contrasting primary-button text, and full-opacity offline cards.
13. **Page states:** compact loading and empty states, actionable failures, and retained snapshots during refresh errors.
14. **Mobile:** wrapping identities, scrollable tab/filter rows, compact summaries, responsive forms, and sticky form actions.
15. **Loading and rendering:** shared Fleet data, detail tabs loaded on demand, no overlapping scheduled page requests, and polling paused in hidden tabs. Watches can keep Fleet monitoring active. Long libraries, histories, and evidence lists progressively render more items. Server history is filtered before the API limit.
## Maintaining the patterns
- Use `SectionHeader` for page titles and primary actions. The containing page owns spacing between sections.
- Use `Field` with `Input`, `Select`, or `Textarea` for associated labels, help, and errors. Give controls distinct `ariaLabel` values when several share one field.
- Use `Dialog` for new modal workflows and `confirmAction` for ordinary confirmations. Browser unload protection remains native.
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names. Preserve raw IDs where operators need to copy or troubleshoot them.
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists and `pollVisible` for scheduled page refreshes.
- Read live server data from the Fleet store instead of starting another `/api/servers` polling loop.
+1 -1
View File
@@ -37,7 +37,7 @@ export interface BetaSelection { scriptId: number; versionId: number; }
export function betaSelectionHref(selections: BetaSelection[]): string { export function betaSelectionHref(selections: BetaSelection[]): string {
const query = new URLSearchParams(); const query = new URLSearchParams();
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`); 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[] { export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
const values = query.getAll('artifact'); const values = query.getAll('artifact');
+62 -60
View File
@@ -1,5 +1,12 @@
<script lang="ts"> <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 { artifactVersionLabel } from '$lib/artifact-version.js';
import PluginGithubImport from './PluginGithubImport.svelte'; import PluginGithubImport from './PluginGithubImport.svelte';
import ConfigEditor from './ConfigEditor.svelte'; import ConfigEditor from './ConfigEditor.svelte';
@@ -60,9 +67,10 @@
let scopeDialog = $state<HTMLDialogElement | null>(null); let scopeDialog = $state<HTMLDialogElement | null>(null);
let uploadDialog = $state<HTMLDialogElement | null>(null); let uploadDialog = $state<HTMLDialogElement | null>(null);
let fleetServers: ServerRow[] = []; const fleetServers = $derived($sharedFleet);
let loadError = $state('');
let poll: ReturnType<typeof setInterval> | null = null; let limit = $state(25);
let request = 0;
const editedScopeValue = $derived.by(() => { const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame; if (scopeType === 'game') return scopeGame;
@@ -82,39 +90,27 @@
function scopeLabel(s: Artifact): string { function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped'; if (!s.scope_type) return 'unscoped';
return `${s.scope_type} · ${s.scope_value ?? '?'}`; 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';
}
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
return s === 'tested' ? 'online' :
s === 'test-failed' ? 'danger' :
'neutral';
} }
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> { async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
const [detailRes, serversRes, scopeRes] = await Promise.all([ const requestedId = id;
fetch(`/api/scripts/${id}`), const current = ++request;
fetch('/api/servers'), try {
fetch('/api/scope-options') const [detailRes, scopeRes] = await Promise.all([
fetch('/api/scripts/' + requestedId),
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
]); ]);
if (!detailRes.ok) { loading = false; return; } if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
const body = (await detailRes.json()) as { const body = await detailRes.json();
script: Artifact; versions: Version[]; const scopes = scopeRes?.ok ? await scopeRes.json() : null;
matchedServers: string[]; runningBetaId: number | null; if (requestedId !== id || current !== request) return;
}; artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
artifact = body.script; if (scopes) scopeOptions = scopes;
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(); if (seedForm) seedScopeForm();
loading = false; loadError = '';
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
finally { if (requestedId === id && current === request) loading = false; }
} }
function seedScopeForm(): void { function seedScopeForm(): void {
@@ -126,17 +122,14 @@
} else if (scopeType === 'map') { } else if (scopeType === 'map') {
scopeMap = artifact.scope_value ?? ''; scopeMap = artifact.scope_value ?? '';
const owner = fleetServers.find((sv) => sv.map === scopeMap); 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 { } else {
scopeGame = ''; scopeMap = ''; scopeGame = ''; scopeMap = '';
} }
} }
onMount(() => { $effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
load(); onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
poll = setInterval(() => load({ seedForm: false }), 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
function openScopeModal(): void { function openScopeModal(): void {
if (runningBetaId !== null) return; if (runningBetaId !== null) return;
@@ -163,7 +156,10 @@
} }
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void { function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
if (!dialog) return; 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 { function pickVersionFile(e: Event): void {
@@ -189,7 +185,7 @@
versionFile = null; versionNotes = ''; versionFile = null; versionNotes = '';
uploadDialog?.close(); uploadDialog?.close();
await load(); await load();
} finally { } catch (error) { flash = (error as Error).message; } finally {
uploadingVersion = false; uploadingVersion = false;
} }
} }
@@ -218,17 +214,19 @@
} }
scopeDialog?.close(); scopeDialog?.close();
await load(); await load();
} finally { } catch (error) { flash = (error as Error).message; } finally {
updatingScope = false; updatingScope = false;
} }
} }
async function deleteArtifact(): Promise<void> { async function deleteArtifact(): Promise<void> {
if (!confirm(`Delete this ${family}?`)) return; if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' }); try {
const body = (await res.json()) as { ok: boolean; error?: string }; const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; } const body = await response.json();
goto(basePath); 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([ let gameOptions = $derived([
@@ -246,11 +244,12 @@
]; ];
</script> </script>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
{#if loading} {#if loading}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
{:else if !artifact} {:else if !artifact && !loadError}
<EmptyState title="Not found" description="This {family} may have been deleted." /> <EmptyState title="Not found" description="This {family} may have been deleted." />
{:else} {:else if artifact}
{@const a = artifact} {@const a = artifact}
<div class="page"> <div class="page">
<SectionHeader title={a.name} subtitle={a.target_rel_path}> <SectionHeader title={a.name} subtitle={a.target_rel_path}>
@@ -303,7 +302,7 @@
{:else if matchedServers.length === 0} {:else if matchedServers.length === 0}
<span class="warn">No matching servers</span> <span class="warn">No matching servers</span>
{:else} {:else}
{matchedServers.join(', ')} {#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
{/if} {/if}
</span> </span>
</div> </div>
@@ -323,7 +322,7 @@
<span class="k">Latest version</span> <span class="k">Latest version</span>
<span class="v mono"> <span class="v mono">
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)} {artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge> <StatusBadge state={latestVersion.state} size="sm" />
</span> </span>
</div> </div>
{/if} {/if}
@@ -344,7 +343,7 @@
<EmptyState title="No versions yet" description="Upload a first version to get started." /> <EmptyState title="No versions yet" description="Upload a first version to get started." />
{:else} {:else}
<div class="version-list"> <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}> <div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span> <span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-sha">{v.sha256.slice(0, 12)}</span> <span class="v-sha">{v.sha256.slice(0, 12)}</span>
@@ -352,16 +351,18 @@
<span class="v-notes">{v.notes ?? ''} <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} {#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span> </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} {#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {: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} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<ListFooter total={versions.length} bind:limit label="versions" />
</Card> </Card>
{#if family === 'plugin'} {#if family === 'plugin'}
@@ -376,20 +377,20 @@
{#if flash}<div class="alert">{flash}</div>{/if} {#if flash}<div class="alert">{flash}</div>{/if}
<!-- Scope dialog --> <!-- 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}> <form class="dlg" onsubmit={saveScope}>
<header class="dlg-head"> <header class="dlg-head">
<h3>Edit scope</h3> <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> </header>
<Field label="Scope"> <Field label="Scope">
<div class="scope-row"> <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'} {#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}
{#if scopeType === 'map'} {#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} {/if}
</div> </div>
</Field> </Field>
@@ -404,11 +405,11 @@
</dialog> </dialog>
<!-- Upload 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}> <form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head"> <header class="dlg-head">
<h3>Upload new version</h3> <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> </header>
<Field label="File"> <Field label="File">
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} /> <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; } .panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
.dlg { padding: var(--space-4); } .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> </style>
+22 -17
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { 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'; import { coverageSummary, needsSessionTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props(); let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
@@ -11,6 +12,7 @@
let saving = $state(false); let saving = $state(false);
let editingId = $state(''); let editingId = $state('');
let appliedPresetName = $state(''); let appliedPresetName = $state('');
let choosingPreset = $state(false);
const matchingPreset = $derived(presets.find(p => p.name === appliedPresetName && JSON.stringify(p.plan) === JSON.stringify(plan)) 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))); ?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name))); 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. // Validation returns an independent plain object, even when the source is a Svelte proxy.
plan = parseBetaPlan(preset.plan); plan = parseBetaPlan(preset.plan);
appliedPresetName = name; appliedPresetName = name;
choosingPreset = false;
editingId = ''; error = ''; editingId = ''; error = '';
message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`; message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`;
} catch (e) { error = (e as Error).message; } } catch (e) { error = (e as Error).message; }
@@ -61,8 +64,9 @@
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div> <div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
<span class="pill">Manual promotion</span> <span class="pill">Manual promotion</span>
</header> </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"> <div class="presets" role="group" aria-label="Start from a preset">
{#each builtInPlans as preset, index (preset.name)} {#each builtInPlans as preset, index (preset.name)}
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name} <button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
@@ -83,7 +87,8 @@
</select> </select>
</label> </label>
{/if} {/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} {#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="active-plan"> <div class="active-plan">
@@ -194,17 +199,17 @@
</section> </section>
<style> <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, h4, h5, p, dl, dd { margin: 0; }
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; } h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
h4 { font-size: 19px; font-weight: 600; } h4 { font-size: 19px; font-weight: 600; }
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; } h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } 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; } .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; } .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: 11px; color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; } .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; } .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: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.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; } .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 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 + li { margin-top: 5px; }
.preset li::marker { color: var(--accent-primary-hover); } .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); } .preset-footer > span { color: var(--accent-primary-hover); }
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; } .feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
.error { color: var(--state-danger); } .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); } .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 { 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; } .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 { 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; } .telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
.requirements { display: grid; gap: 10px; } .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-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; } .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 { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
.remove:hover { color: var(--state-danger); } .remove:hover { color: var(--state-danger); }
.editor { border-top: 1px solid var(--border-subtle); } .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 > summary > span { color: var(--text-secondary); margin-left: 10px; }
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; } .editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; } .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 { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); } .secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
.policy-settings { display: grid; gap: 8px; } .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 > 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; } .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: 11px; } .edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; } .policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
.policy-body.fields { display: flex; } .policy-body.fields { display: flex; }
summary { cursor: pointer; } 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; } .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: 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%; } } @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"> <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'; import type { BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props(); let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
let notes = $state<Record<string, string>>({}); let notes = $state<Record<string, string>>({});
let evidenceOpen = $state(false);
let evidenceLimit = $state(25);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
async function action(action: string, extra: Record<string, string> = {}) { async function action(action: string, extra: Record<string, string> = {}) {
@@ -17,23 +24,22 @@
</script> </script>
<section aria-label="Test plan progress"> <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} {#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. <p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'} <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>
Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.</p>
{#if !closed} {#if !closed}
<div class="actions"> <div class="actions">
{#if test.outcome === 'paused'}<button disabled={busy} onclick={() => action('resume')}>Resume</button>{/if} {#if test.outcome === 'paused'}<Button size="sm" 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 ['active', 'passed'].includes(test.outcome)}<Button size="sm" 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 ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
</div> </div>
{/if} {/if}
{#if error}<p role="alert" class="failed">{error}</p>{/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} {#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)} {#each test.servers as server (server.server)}
<article> <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)} {#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!} {@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
<div class="requirement" class:failed={progress.failed}> <div class="requirement" class:failed={progress.failed}>
@@ -47,19 +53,22 @@
{#if requirement.kind === 'manual' && !progress.passed && !closed} {#if requirement.kind === 'manual' && !progress.passed && !closed}
<div class="actions"> <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}`]} /> <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> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
</article> </article>
{/each} {/each}
<details> <details ontoggle={event => evidenceOpen = event.currentTarget.open}>
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary> <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> <div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
{/each} {/each}
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if} {#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
{/if}
</details> </details>
</section> </section>
@@ -70,12 +79,12 @@
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } 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, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.heading { justify-content: space-between; } .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 { display: grid; gap: 8px; margin-top: 16px; }
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; } .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); } 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 { 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 { flex: 1; min-width: 160px; } summary { cursor: pointer; }
.failed { color: var(--state-danger); } .failed { color: var(--state-danger); }
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); } .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; } pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js'; import type { BlueprintSummary } from '$lib/deployment-plan.js';
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js'; import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
@@ -63,7 +64,7 @@
} }
</script> </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"> <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> <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> <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> </dialog>
<style> <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; } 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; } 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; } .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: 8px; background: var(--surface-sunken); color: inherit; font: inherit; } form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-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: 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; } .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) { @media (max-width: 620px) {
.builder { padding: 20px; } .builder { padding: 20px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; } header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
+19 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte'; import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.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; if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
} catch { /* Draft storage is optional. */ } } 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 = ''; } } function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
$effect(() => { $effect(() => {
const version = selected; const version = selected;
@@ -54,10 +64,11 @@
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ } 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 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; const target = event.currentTarget as HTMLSelectElement;
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; } const next = target.value; target.value = selected;
clearDraft(); selected = target.value; success = ''; 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() { function restoreDraft() {
if (!recovered) return; if (!recovered) return;
@@ -65,7 +76,7 @@
// recovered draft was started from a version that has since been deleted. // recovered draft was started from a version that has since been deleted.
text = recovered.text; notes = recovered.notes; recovered = null; 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) { async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement; const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0]; 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> <label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
</div> </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 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 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> {#if loading}<p class="muted" role="status">Loading config contents…</p>
{:else if loadedVersion} {:else if loadedVersion}
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} /> <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> <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="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> <Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
</div> </div>
+7 -6
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js'; import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte'; 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 servers = $state<ConfigServer[]>([]);
let selected = $state(''); let selected = $state('');
let inspection = $state<ConfigInspection | null>(null); let inspection = $state<ConfigInspection | null>(null);
@@ -13,7 +14,7 @@
let raw = $state(false); let raw = $state(false);
let reload = $state(0); let reload = $state(0);
let ready = $state(false); 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 settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0); const 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}` : ''}`; } 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> <Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div> </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> <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}> <label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each} {#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if} {#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label> </select></label>
{#if error}<div class="error" role="alert">{error}</div> {#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; } .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; } code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
strong { color: var(--accent-primary-hover); } 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: '▾'; } .source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
b { font-weight: 400; color: var(--state-warning); } 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 { 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 { 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); } 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; } .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; } .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; } .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
+25 -19
View File
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { goto } from '$app/navigation';
import SectionHeader from './primitives/SectionHeader.svelte'; import SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -22,7 +25,9 @@
let game = $state(''); let game = $state('');
let scope = $state(''); let scope = $state('');
let status = $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 dialog: HTMLDialogElement;
let kind = $state<'baseline' | 'config'>('config'); let kind = $state<'baseline' | 'config'>('config');
let name = $state(''); let name = $state('');
@@ -94,15 +99,12 @@
</script> </script>
<div class="page"> <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} {#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
</SectionHeader> </SectionHeader>
<div class="overview"> <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><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></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><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div> <div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
<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>
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if} {#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'} {#if view === 'inspect'}
<ConfigInspector /> <ConfigInspector />
@@ -128,15 +130,17 @@
{/if} {/if}
{#if filteredOverlays.length} {#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> <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)} <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_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> <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> {/each}</div>
</section> </section>
{/if} {/if}
{/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 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} {/if}
</div> </div>
</div>
{#snippet versionBadges(artifact: Artifact)} {#snippet versionBadges(artifact: Artifact)}
{#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if} {#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if}
@@ -163,14 +167,11 @@
<style> <style>
.page { display: flex; flex-direction: column; gap: var(--space-6); } .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); } .navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.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 { justify-content: space-between; } .navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; } .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 { 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.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; } button:disabled { opacity: .5; cursor: default; }
@@ -190,7 +191,7 @@
.overlay-list { display: flex; flex-direction: column; gap: 8px; } .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-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-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; } .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); } .footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); } .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); } 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; } .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); } .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: 900px) { .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: 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> </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 { 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; } .setting span { min-width: 0; overflow-wrap: anywhere; }
code { font: var(--text-sm) var(--font-mono); } 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); } .hint { color: var(--text-secondary); font-size: var(--text-xs); }
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; } .warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
summary { cursor: pointer; } 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"> <script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte'; import LaunchRecipe from './LaunchRecipe.svelte';
@@ -46,7 +47,7 @@
} }
</script> </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"> <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> <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} {#if error}<p class="error" role="alert">{error}</p>{/if}
@@ -80,9 +81,9 @@
</dialog> </dialog>
<style> <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; } 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: 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; } 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: 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; } .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: 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); } 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; } } @media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
</style> </style>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte'; import LaunchRecipe from './LaunchRecipe.svelte';
@@ -103,7 +104,7 @@
} }
</script> </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"> <div class="composer">
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header> <header><div><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> <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} /> <DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<style> <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); } 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; } .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; } 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; } .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); } .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; } 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: 11px; } .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: 12px; padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); } .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); } .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; } .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; } 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> </style>
@@ -32,7 +32,7 @@
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}> <form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Destroy container?</h2> <h2 id="destroy-title">Destroy container?</h2>
<p class="name">{name}</p> <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"> <div class="notice">
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if} {#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. 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> </dialog>
<style> <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); } dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; } form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
p { line-height: 1.6; color: var(--text-secondary); } p { line-height: 1.6; color: var(--text-secondary); }
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; } .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; } strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
.irreversible { font-size: 13px; } .error { color: var(--state-danger); } .irreversible { font-size: 13px; } .error { color: var(--state-danger); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; } footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props(); let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
@@ -44,9 +45,9 @@
} }
</script> </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"> <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>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p> <p>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} {#if error}<p class="error" role="alert">{error}</p>{/if}
@@ -63,12 +64,12 @@
</dialog> </dialog>
<style> <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); } 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; } .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; } 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; } 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; } .actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style> </style>
+2 -2
View File
@@ -21,13 +21,13 @@
<style> <style>
.recipe { display: grid; gap: 16px; min-width: 0; } .recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .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; } 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; } .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; } 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); } .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; } 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; } 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; } p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
</style> </style>
+38 -65
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <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 { interface TabDef {
key: string; // matches the log key in KNOWN_LOGS 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 // Default to the first tab once props are populated; don't overwrite
// the user's later selection. // the user's later selection.
$effect(() => { $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 loading = $state(false);
let autoRefresh = $state(true); 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 bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true); let stickToBottom = $state(true);
async function fetchLog(key: string): Promise<void> { 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 { try {
const res = await fetch( const res = await fetch(
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}` `/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
); );
const body = (await res.json()) as LogResponse; 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) { } catch (err) {
data = { ...data, [key]: { ok: false, reason: (err as Error).message } }; if (server === currentServer && key === active) refreshError = (err as Error).message;
} finally { } finally {
loading = false; pending.delete(requestKey);
if (stickToBottom) { if (server === currentServer && key === active) loading = false;
if (stickToBottom && server === currentServer && key === active) {
await tick(); await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
} }
@@ -67,37 +77,18 @@
stickToBottom = nearBottom; stickToBottom = nearBottom;
} }
function switchTab(key: string): void { function toggleAuto() { autoRefresh = !autoRefresh; }
active = key; $effect(() => { server; data = {}; refreshError = ''; });
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
$effect(() => { $effect(() => {
if (active && !data[active]) void fetchLog(active); 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);
}); });
// Reschedule when autoRefresh or active changes
$effect(() => {
schedulePoll();
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
}); });
function ago(iso: string | null | undefined): string { function ago(iso: string | null | undefined): string {
@@ -122,11 +113,7 @@
<div class="log-panel"> <div class="log-panel">
<div class="tabs"> <div class="tabs">
{#each tabs as t (t.key)} <Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
{t.label}
</button>
{/each}
<div class="right"> <div class="right">
{#if current?.mtime} {#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span> <span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
@@ -141,18 +128,20 @@
</div> </div>
</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> <div class="empty">loading…</div>
{:else if !current.ok} {:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div> <div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current.missing} {:else if current?.missing}
<div class="empty"> <div class="empty">
log file not written yet log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if} {#if current.path}<br /><code>{current.path}</code>{/if}
</div> </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> <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> <pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
{#if current.truncated} {#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div> <div class="foot">showing last {current.lines.length} lines (file is larger)</div>
@@ -161,6 +150,7 @@
{/if} {/if}
{/if} {/if}
</div> </div>
</div>
<style> <style>
.log-panel { .log-panel {
@@ -176,24 +166,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding-bottom: 8px; 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 { .right {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js'; 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 { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
let limit = $state(25);
</script> </script>
<section> <section>
@@ -17,8 +19,9 @@
<section> <section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div> <div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if events.length} {#if events.length}
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
<ol class="timeline"> <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> <li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
{/each} {/each}
</ol> </ol>
@@ -34,7 +37,7 @@
time, .muted { color: var(--text-secondary); font-size: 13px; } time, .muted { color: var(--text-secondary); font-size: 13px; }
p { margin: 0; line-height: 1.6; } p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } .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 span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
.flow .done { color: var(--text-primary); } .flow .done { color: var(--text-primary); }
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); } .flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
+9 -16
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import RoundTelemetry from './RoundTelemetry.svelte'; import RoundTelemetry from './RoundTelemetry.svelte';
import ServerIdentity from './ServerIdentity.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js'; import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js'; import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js'; import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.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 Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
import Stat from './primitives/Stat.svelte'; import Stat from './primitives/Stat.svelte';
@@ -71,6 +72,7 @@
let showRoster = $derived(health === 'online' || health === 'stale'); let showRoster = $derived(health === 'online' || health === 'stale');
let title = $derived(serverTitle(name)); let title = $derived(serverTitle(name));
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map)); let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
let gtLabel = $derived(gametypeLabel(gametype)); let gtLabel = $derived(gametypeLabel(gametype));
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null); let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
@@ -101,7 +103,7 @@
<div class="head"> <div class="head">
<div class="name-row"> <div class="name-row">
<StatusPip {health} /> <StatusPip {health} />
<strong class="name" title={name}>{title}</strong> <strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
</div> </div>
{#if health === 'starting'} {#if health === 'starting'}
<Badge variant="info" uppercase>Starting</Badge> <Badge variant="info" uppercase>Starting</Badge>
@@ -119,13 +121,7 @@
{/if} {/if}
</div> </div>
<div class="meta"> {#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}
<span class="map">{mapLabel}</span>
{#if gtLabel}
<span class="dot" aria-hidden="true">·</span>
<span class="gt">{gtLabel}</span>
{/if}
</div>
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/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); outline: 1px solid rgba(255, 176, 32, 0.28);
} }
:global(.card.offline) { :global(.card.offline) {
opacity: 0.62; border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
} }
.restart-bar { .restart-bar {
@@ -232,9 +228,7 @@
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
color: var(--text-primary); color: var(--text-primary);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
overflow: hidden; min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
} }
.players { flex-shrink: 0; } .players { flex-shrink: 0; }
@@ -249,9 +243,8 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
.gt { color: var(--text-tertiary); } .gt { color: var(--text-tertiary); }
.dot { color: var(--text-quaternary); }
.timing { .timing {
display: flex; display: flex;
@@ -265,7 +258,7 @@
gap: 2px; gap: 2px;
} }
.metric-label { .metric-label {
font-size: 10px; font-size: var(--text-xs);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: var(--tracking-label); 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} aria-label={ariaLabel}
{title} {title}
href={disabled || loading ? undefined : href} href={disabled || loading ? undefined : href}
role="button" tabindex={disabled || loading ? -1 : undefined}
tabindex="0" aria-busy={loading}
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
> >
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if} {#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span> <span class="label">{@render children?.()}</span>
@@ -60,6 +61,7 @@
{type} {type}
{title} {title}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-busy={loading}
disabled={disabled || loading} disabled={disabled || loading}
{onclick} {onclick}
> >
@@ -81,7 +83,8 @@
font-family: var(--font-body); font-family: var(--font-body);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
line-height: 1; line-height: 1.35;
min-height: 40px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
border: 1px solid transparent; border: 1px solid transparent;
@@ -105,7 +108,7 @@
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); } .btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
/* Sizes */ /* 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-md { padding: 9px 16px; font-size: var(--text-sm); }
.s-lg { padding: 12px 22px; font-size: var(--text-base); } .s-lg { padding: 12px 22px; font-size: var(--text-base); }
+6 -12
View File
@@ -45,6 +45,7 @@
{tabindex} {tabindex}
{style} {style}
{onclick} {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?.()} {@render children?.()}
</svelte:element> </svelte:element>
@@ -56,7 +57,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: var(--radius-lg); 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); color: var(--text-primary);
text-decoration: none; text-decoration: none;
transition: transition:
@@ -77,12 +79,6 @@
/* Backdrop blur only where the browser supports it — some rendering /* Backdrop blur only where the browser supports it — some rendering
backends downgrade poorly, and we don't want blurred noise. */ 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 */ /* Padding scale */
.p-none { padding: 0; } .p-none { padding: 0; }
@@ -91,23 +87,21 @@
.p-lg { padding: var(--space-6); } .p-lg { padding: var(--space-6); }
/* Elevation */ /* Elevation */
.e-flat { box-shadow: var(--shadow-ambient); } .e-flat { box-shadow: none; }
.e-raised { box-shadow: var(--shadow-raised); } .e-raised { box-shadow: var(--shadow-raised); }
.e-interactive { .e-interactive {
box-shadow: var(--shadow-ambient); box-shadow: none;
cursor: pointer; cursor: pointer;
} }
.e-interactive:hover { .e-interactive:hover {
transform: translateY(-2px); border-color: var(--border-strong);
box-shadow: var(--shadow-raised);
background: color-mix(in oklab, var(--surface-raised) 92%, transparent); background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
} }
.e-interactive:focus-visible { .e-interactive:focus-visible {
outline: none; outline: none;
box-shadow: var(--focus-ring), var(--shadow-raised); box-shadow: var(--focus-ring), var(--shadow-raised);
} }
.e-interactive:active { transform: translateY(-1px); }
@media (max-width: 480px) { @media (max-width: 480px) {
.p-md, .p-lg { padding: var(--space-4); } .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; description?: string | null;
icon?: Snippet; icon?: Snippet;
actions?: Snippet; actions?: Snippet;
spacious?: boolean;
} }
let { title, description = null, icon, actions }: Props = $props(); let { title, description = null, icon, actions, spacious = false }: Props = $props();
</script> </script>
<div class="empty" role="status"> <div class="empty" class:spacious role="status">
{#if icon} {#if icon}
<div class="icon">{@render icon()}</div> <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} {/if}
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
{#if description}<p class="desc">{description}</p>{/if} {#if description}<p class="desc">{description}</p>{/if}
@@ -32,12 +27,13 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: var(--space-16) var(--space-6); padding: var(--space-6);
gap: var(--space-3); gap: var(--space-3);
color: var(--text-secondary); color: var(--text-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 60%, transparent); background: color-mix(in oklab, var(--surface) 60%, transparent);
} }
.spacious { padding: var(--space-12) var(--space-6); }
.icon { .icon {
color: var(--text-tertiary); color: var(--text-tertiary);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
+13 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import { setContext, type Snippet } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
interface Props { interface Props {
label?: string; label?: string;
@@ -11,22 +12,28 @@
} }
let { label, hint = null, error = null, required = false, forId, children }: Props = $props(); 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> </script>
<div class="field"> <div class="field">
{#if label} {#if label}
<label class="label" for={forId}> <svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
{label} {label}
{#if required}<span class="req" aria-hidden="true">*</span>{/if} {#if required}<span class="req" aria-hidden="true">*</span>{/if}
</label> </svelte:element>
{/if} {/if}
<div class="control" class:has-error={!!error}> <div class="control" class:has-error={!!error}>
{@render children?.()} {@render children?.()}
</div> </div>
{#if error} {#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} {:else if hint}
<div class="msg msg-hint">{hint}</div> <div id={`${id}-message`} class="msg msg-hint">{hint}</div>
{/if} {/if}
</div> </div>
@@ -40,9 +47,7 @@
.label { .label {
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
text-transform: uppercase; color: var(--text-secondary);
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
} }
.req { color: var(--state-danger); margin-left: 3px; } .req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; } .control { display: flex; flex-direction: column; }
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props { interface Props {
value?: string; value?: string;
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file'; type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
@@ -13,6 +16,7 @@
onchange?: (e: Event) => void; onchange?: (e: Event) => void;
oninput?: (e: Event) => void; oninput?: (e: Event) => void;
accept?: string; accept?: string;
ariaLabel?: string;
} }
let { let {
@@ -28,7 +32,8 @@
mono = false, mono = false,
onchange, onchange,
oninput, oninput,
accept accept,
ariaLabel
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -44,6 +49,10 @@
{readonly} {readonly}
{autocomplete} {autocomplete}
{accept} {accept}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value bind:value
{onchange} {onchange}
{oninput} {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; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
margin-bottom: var(--space-8); margin-bottom: 0;
} }
.crumbs { .crumbs {
font-size: var(--text-xs); font-size: var(--text-xs);
+10 -1
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Option { interface Option {
value: string; value: string;
label: string; label: string;
@@ -14,6 +17,7 @@
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
onchange?: (e: Event) => void; onchange?: (e: Event) => void;
ariaLabel?: string;
} }
let { let {
@@ -24,7 +28,8 @@
required = false, required = false,
disabled = false, disabled = false,
placeholder, placeholder,
onchange onchange,
ariaLabel
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -35,6 +40,10 @@
{name} {name}
{required} {required}
{disabled} {disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value bind:value
{onchange} {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; onchange?: (id: string) => void;
ariaLabel?: string; ariaLabel?: string;
size?: 'sm' | 'md'; 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) { function select(id: string, disabled: boolean | undefined) {
if (disabled) return; if (disabled) return;
@@ -30,7 +45,11 @@
class="tab" class="tab"
class:active={value === t.id} class:active={value === t.id}
role="tab" role="tab"
id={`${idPrefix}-${t.id}`}
aria-selected={value === t.id} aria-selected={value === t.id}
aria-controls={panelId}
tabindex={value === t.id ? 0 : -1}
onkeydown={onKey}
disabled={t.disabled} disabled={t.disabled}
onclick={() => select(t.id, t.disabled)} onclick={() => select(t.id, t.disabled)}
> >
@@ -52,7 +71,7 @@
gap: 2px; gap: 2px;
padding: 4px; padding: 4px;
background: color-mix(in oklab, var(--surface) 80%, transparent); 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); border: 1px solid var(--border-subtle);
} }
.tab { .tab {
@@ -68,7 +87,7 @@
font-family: var(--font-body); font-family: var(--font-body);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-pill); border-radius: var(--radius-sm);
transition: transition:
color var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard); background-color var(--dur-fast) var(--ease-standard);
@@ -97,7 +116,7 @@
background: var(--surface-overlay); background: var(--surface-overlay);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 10px; font-size: var(--text-xs);
} }
.tab.active .badge { .tab.active .badge {
background: var(--accent-primary); background: var(--accent-primary);
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte';
import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext);
interface Props { interface Props {
value?: string; value?: string;
placeholder?: string; placeholder?: string;
@@ -10,6 +13,7 @@
readonly?: boolean; readonly?: boolean;
mono?: boolean; mono?: boolean;
oninput?: (e: Event) => void; oninput?: (e: Event) => void;
ariaLabel?: string;
} }
let { let {
@@ -22,7 +26,8 @@
disabled = false, disabled = false,
readonly = false, readonly = false,
mono = false, mono = false,
oninput oninput,
ariaLabel
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -36,6 +41,10 @@
{required} {required}
{disabled} {disabled}
{readonly} {readonly}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value bind:value
{oninput} {oninput}
></textarea> ></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); const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`; 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 fleetError = writable('');
export const fleetRefreshing = writable(false); 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 lobbyWatches = writable<LobbyWatch[]>([]);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) => 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; const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
return status ? { ...server, last_players: status.players, last_map: status.map, return status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError, 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, inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt } : server; 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.'); 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())); const [servers, operations] = await Promise.all(responses.map(response => response.json()));
fleetError.set(''); fleetError.set('');
fleetIntegrations.set(servers.integrations);
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt }); fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
} catch (error) { } catch (error) {
fleetError.set(error instanceof Error && error.name !== 'AbortError' 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. */ } try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
}); });
void refreshFleet(); 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(); }; const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus); document.addEventListener('visibilitychange', onFocus);
const check = () => { 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; if (!browser || source) return;
source = new EventSource('/api/events/pipeline'); 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 }); 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; let source: EventSource | null = null;
export function startServerStream(): void { export function startServerStream(): (() => void) | undefined {
if (!browser || source) return; if (!browser || source) return;
source = new EventSource('/api/events/servers'); source = new EventSource('/api/events/servers');
source.addEventListener('server-telemetry', (evt) => { source.addEventListener('server-telemetry', (evt) => {
@@ -73,4 +73,5 @@ export function startServerStream(): void {
if (tickInterval === null) { if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000); 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-950: #060b16;
--blue-900: #0d1826; --blue-900: #0d1826;
--blue-700: #1c3d6b; --blue-700: #1c3d6b;
--blue-500: #2e86d1; --blue-500: #4da3e6;
--blue-300: #7cc0f5; --blue-300: #7cc0f5;
/* Neutral gray — cool-tinted, distinct from the blue ramp so surfaces /* Neutral gray — cool-tinted, distinct from the blue ramp so surfaces
@@ -62,9 +62,9 @@
/* Text — four steps of hierarchy on dark. */ /* Text — four steps of hierarchy on dark. */
--text-primary: var(--gray-050); --text-primary: var(--gray-050);
--text-secondary: var(--gray-300); --text-secondary: var(--gray-300);
--text-tertiary: var(--gray-500); --text-tertiary: #98a4b8;
--text-quaternary: var(--gray-700); --text-quaternary: var(--gray-400);
--text-on-accent: #ffffff; --text-on-accent: #08111f;
/* Border — hairlines, no more. */ /* Border — hairlines, no more. */
--border-subtle: rgba(255, 255, 255, 0.06); --border-subtle: rgba(255, 255, 255, 0.06);
@@ -74,7 +74,7 @@
/* Accent — one color, one voice. */ /* Accent — one color, one voice. */
--accent-primary: var(--blue-500); --accent-primary: var(--blue-500);
--accent-primary-hover: var(--blue-300); --accent-primary-hover: var(--blue-300);
--accent-primary-active: #256cae; --accent-primary-active: #398fcf;
/* Typography — Inter everywhere, mono for data. /* Typography — Inter everywhere, mono for data.
Display face lives inside BrandMark; no global --font-display token. */ Display face lives inside BrandMark; no global --font-display token. */
+34 -16
View File
@@ -7,6 +7,9 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.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'; import { enableViewTransitions } from '$ui/motion/viewTransition.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so // Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build. // the referenced woff/woff2 files get emitted into the build.
@@ -17,7 +20,7 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let menuButton: HTMLButtonElement; let menuButton: HTMLButtonElement;
afterNavigate(() => { menuOpen = false; }); afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) { function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && menuOpen) { if (event.key === 'Escape' && menuOpen) {
@@ -27,8 +30,8 @@
} }
onMount(() => { onMount(() => {
startPipelineStream(); const stopPipeline = startPipelineStream();
startServerStream(); const stopServers = startServerStream();
const stopFleetMonitor = startFleetMonitor(); const stopFleetMonitor = startFleetMonitor();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments, // Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
@@ -42,7 +45,7 @@
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; 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 (e.metaKey || e.ctrlKey || e.altKey) return;
if (!armed && e.key === 'g') { if (!armed && e.key === 'g') {
@@ -64,6 +67,8 @@
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
stopFleetMonitor(); stopFleetMonitor();
stopPipeline?.();
stopServers?.();
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer); if (armTimer) clearTimeout(armTimer);
}; };
@@ -74,20 +79,23 @@
onNavigate(enableViewTransitions); onNavigate(enableViewTransitions);
const tabs = [ const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' }, { href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') }, { href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') }, { href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') }, { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') }, { href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
{ 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') }
]; ];
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> </script>
<svelte:window onkeydown={closeMenu} /> <svelte:window onkeydown={closeMenu} />
<div class="shell"> <div class="shell">
<a class="skip-link" href="#main-content">Skip to content</a>
<nav aria-label="Primary"> <nav aria-label="Primary">
<a class="brand-link" href="/" aria-label="Electric Cherry home"> <a class="brand-link" href="/" aria-label="Electric Cherry home">
<BrandMark size="sm" /> <BrandMark size="sm" />
@@ -112,10 +120,12 @@
</div> </div>
<div class="strut" aria-hidden="true"></div> <div class="strut" aria-hidden="true"></div>
</nav> </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()} {@render children()}
</main> </main>
<Toaster /> <Toaster />
<Confirmation />
</div> </div>
<style> <style>
@@ -140,7 +150,8 @@
color: var(--text-on-accent); color: var(--text-on-accent);
} }
:global(:focus-visible) { :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 /* Ambient background — a faint radial gradient hints at depth without
@@ -223,14 +234,14 @@
main { main {
min-width: 0; min-width: 0;
overflow-wrap: anywhere; 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; max-width: 1440px;
margin: 0 auto; margin: 0 auto;
view-transition-name: main; view-transition-name: main;
} }
/* The full navigation needs more room than a phone or portrait tablet. */ /* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1000px) { @media (max-width: 800px) {
nav { nav {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
@@ -271,6 +282,13 @@
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; } :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; } :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. */ /* View-transition tuning — cross-fade routes, ease the shared nav pill. */
:global(::view-transition-old(root)), :global(::view-transition-old(root)),
+51 -40
View File
@@ -3,7 +3,7 @@
import ServerCard from '$ui/components/ServerCard.svelte'; import ServerCard from '$ui/components/ServerCard.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Button from '$ui/components/primitives/Button.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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte'; import StatusPip from '$ui/components/primitives/StatusPip.svelte';
@@ -22,6 +22,7 @@
let view = $state<'cards' | 'table'>('cards'); let view = $state<'cards' | 'table'>('cards');
let grouping = $state<'map' | 'game' | 'none'>('none'); let grouping = $state<'map' | 'game' | 'none'>('none');
let operationsOpen = $state(false); let operationsOpen = $state(false);
let viewOptionsOpen = $state(false);
let pinned = $state<string[]>([]); let pinned = $state<string[]>([]);
let preferencesReady = $state(false); let preferencesReady = $state(false);
let deployOpen = $state(false); let deployOpen = $state(false);
@@ -110,10 +111,10 @@
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head> <svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
<section class="page"> <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()} {#snippet actions()}
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button> <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} {/snippet}
</SectionHeader> </SectionHeader>
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} /> <DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
@@ -126,21 +127,21 @@
{#if !$fleet.receivedAt} {#if !$fleet.receivedAt}
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if} {#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
{:else} {:else}
<div class="overview"> <div class="overview" aria-label="Fleet summary">
<div><Stat label="Players online" value={players} size="lg" tone={players > 0 ? 'online' : 'primary'} tween /><span class="hint">From fresh server reports</span></div> <span><strong>{healthy.length ? players : '—'}</strong> players online</span>
<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> <span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
<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> <button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
<a class="metric-link" href="/campaigns"><Stat label="Active campaigns" value={campaigns} size="lg" tween /><span class="hint">View progress →</span></a> <a class="metric-link" href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
</div> </div>
{#if servers.length || activeOperations.length} {#if problems.length || attentionOperations.length || starting}
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}> <div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span> <span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
<div class="attention-copy"> <div class="attention-copy">
{#if problems.length} {#if problems.length}
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button> <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} {: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> </div>
{#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if} {#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if}
</div> </div>
@@ -154,6 +155,9 @@
</a> </a>
{/if} {/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} {#if $lobbyWatches.length}
<div class="watches" aria-label="Lobby watches"> <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> <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> </div>
{/if} {/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)} <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> <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> {/each}</div>
@@ -177,24 +178,28 @@
{:else} {:else}
<div class="toolbar"> <div class="toolbar">
<div class="search-row"> <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 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>
<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> <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>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> <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="filter-row">
<div class="chips" aria-label="Filter servers">{#each filters as item} <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> <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> {/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>
<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> </div>
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if} {#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)} {#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}> <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} {#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div> </div>
{#if view === 'cards'} {#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> <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)} {#each group.servers as server (server.name)}
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)} {@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><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>{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> <td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
@@ -247,48 +252,52 @@
{/snippet} {/snippet}
<style> <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, input, select { font: inherit; }
button { cursor: pointer; } 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; } 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; } .overview > * { min-width: 0; }
.metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 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; } .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); } .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 { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
.has-issues .attention-mark { color: var(--state-warning); } .has-issues .attention-mark { color: var(--state-warning); }
.attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; } .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 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); } .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 > div { flex: 1; min-width: 190px; }
.spotlight strong { display: block; margin-top: 6px; } .spotlight strong { display: block; margin-top: 6px; }
.spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; } .spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
.round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); } .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; } .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: 18px; } .toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
.search-row { display: flex; gap: 12px; flex-wrap: wrap; } .search-row { display: flex; gap: 12px; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; } label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
.search { flex: 1; min-width: min(280px, 100%); } .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); } input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } .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, .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; } .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); } 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 { 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; } .map-group { display: flex; flex-direction: column; gap: 16px; }
.group-heading { display: flex; align-items: center; justify-content: space-between; 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; } h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
.group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; } .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; } .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.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.server-tile :global(.card) { border-radius: 0; } .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); } .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 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 small { display: block; margin-top: 5px; color: var(--text-secondary); }
.server-operations .issue, .operation-list .issue { color: var(--state-warning); } .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; } summary { cursor: pointer; }
.operation-list { display: grid; gap: 10px; margin-top: 16px; } .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; } .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; } .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; } .error button { margin-left: 8px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: 16px; } .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: 12px; } .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; } 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 { 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); } th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
td small { display: block; color: var(--text-secondary); margin-top: 6px; } td small { display: block; color: var(--text-secondary); margin-top: 6px; }
td .server-operations { padding: 0; min-width: 180px; } 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; } .health { display: flex; align-items: center; gap: 8px; text-transform: capitalize; }
.table-actions { display: flex; align-items: center; gap: 8px; } .table-actions { display: flex; align-items: center; gap: 8px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .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: 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> </style>
+11 -5
View File
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const activeOnly = url.searchParams.get('active') === 'true'; const activeOnly = url.searchParams.get('active') === 'true';
const server = url.searchParams.get('server');
const deployments = db() const deployments = db()
.prepare( .prepare(
@@ -14,10 +15,11 @@ export async function GET({ url }: { url: URL }) {
LEFT JOIN scripts s ON s.id = d.script_id 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 sv ON sv.id = d.script_version_id
LEFT JOIN script_versions pv ON pv.id = d.prior_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 ORDER BY d.started_at DESC
LIMIT 500` LIMIT 500`
) )
.all() as Array<{ .all(server, server) as Array<{
id: string; id: string;
action: string; action: string;
started_at: string; started_at: string;
@@ -33,12 +35,15 @@ export async function GET({ url }: { url: URL }) {
prior_version: number | null; 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() const serversByDep = db()
.prepare( .prepare(
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error `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; deployment_id: string;
server: string; server: string;
files_changed: number; files_changed: number;
@@ -64,9 +69,10 @@ export async function GET({ url }: { url: URL }) {
FROM deployment_scripts ds FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id 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 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; deployment_id: string;
script_id: number; script_id: number;
script_name: string | null; script_name: string | null;
@@ -1,12 +1,18 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js'; import { getServer } from '$lib/server/fleet/servers.js';
import { listServerScripts } from '$lib/server/fleet/inventory.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 } }) { export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name); const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 }); 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) => ({ const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path, 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, scriptVersionId: r.script_version_id,
sha256: r.sha256, sha256: r.sha256,
size: r.size, size: r.size,
+40 -488
View File
@@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte"; import { onMount } from 'svelte';
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
import { onMount, onDestroy } from 'svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { parseBetaSelection } from '$lib/script-library.js'; import { pollVisible } from '$ui/poll.js';
import Card from '$ui/components/primitives/Card.svelte'; import { artifactVersionLabel } from '$lib/artifact-version.js';
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 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 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'; type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface BetaListRow { interface BetaListRow {
id: number; id: number;
@@ -27,495 +24,50 @@
}>; }>;
servers: Array<{ server: string; stage: string; error: string | null }>; 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 betas = $state<BetaListRow[]>([]);
let loading = $state(true); let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]); let error = $state('');
let fleetServers = $state<ServerRow[]>([]); let tab = $state('active');
let detailById = $state<Record<number, ScriptDetail>>({}); let search = $state('');
let nextKey = 0; let limit = $state(25);
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]); const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
let pickedServers = $state<Set<string>>(new Set()); const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
let testPlan = $state(defaultPlan()); const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
let waitForHealth = $state(true); $effect(() => { search; tab; limit = 25; });
let waitForMatch = $state(false); async function load() {
let openingBeta = $state(false); try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; error = ''; }
let flash = $state<string | null>(null); catch (e) { error = (e as Error).message; } finally { loading = false; }
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;
}
}
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(() => { 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 query = new URLSearchParams(window.location.search);
const selections = parseBetaSelection(query); if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
if (!selections.length) return; return pollVisible(load, 5000);
// 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)));
}); });
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> </script>
<div class="page"> <div class="page">
<SectionHeader <SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
title="Betas" {#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
subtitle="Test unpromoted versions on selected servers before rolling them out." </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 flash}<p class="flash" role="alert">{flash}</p>{/if} {#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} {#if loading}<EmptyState title="Loading betas…" />
<div class="loading">Loading…</div> {: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} {:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
<Card padding="lg"> <a href={'/betas/' + beta.id} class="row">
<h2 class="panel-title">Open a beta</h2> <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>
{#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> </a>
{/each} {/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
</div> </div>
</section>
{/if}
{/each}
{/if}
{/if}
</div> </div>
<style> <style>
.page { display: flex; flex-direction: column; gap: var(--space-6); } .page { display:grid; gap:24px; }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); } .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; }
.panel-title { .row:hover { border-color:var(--border-strong); }
margin: 0 0 var(--space-4); .files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
font-family: var(--font-body); .meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
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; }
}
</style> </style>
+14 -28
View File
@@ -2,7 +2,11 @@
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte"; import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
import type { BetaTestView } from "$lib/beta-plan.js"; 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 { page } from '$app/stores';
import Card from '$ui/components/primitives/Card.svelte'; import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
@@ -63,11 +67,7 @@
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); }); $effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
let poll: ReturnType<typeof setInterval> | null = null; onMount(() => pollVisible(load, 5000, false));
onMount(() => {
poll = setInterval(load, 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
async function promote(): Promise<void> { async function promote(): Promise<void> {
acting = 'promote'; flash = null; acting = 'promote'; flash = null;
@@ -76,7 +76,7 @@
const body = (await res.json()) as { ok: boolean; error?: string }; const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; } if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
await load(); await load();
} finally { acting = null; } } catch (error) { flash = (error as Error).message; } finally { acting = null; }
} }
async function rollback(): Promise<void> { async function rollback(): Promise<void> {
const isReject = beta?.state === 'ready-promote'; 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.` ? `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.`; : `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.' : ''; 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; acting = 'rollback'; flash = null;
try { try {
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' }); const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string }; const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; } if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; }
await load(); await load();
} finally { acting = null; } } catch (error) { flash = (error as Error).message; } finally { acting = null; }
} }
async function retry(server: string): Promise<void> { async function retry(server: string): Promise<void> {
acting = `retry:${server}`; flash = null; acting = `retry:${server}`; flash = null;
@@ -104,21 +104,7 @@
const body = (await res.json()) as { ok: boolean; error?: string }; const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; } if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
await load(); await load();
} finally { acting = null; } } catch (error) { flash = (error as Error).message; } 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';
} }
</script> </script>
@@ -137,7 +123,7 @@
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} /> <Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Badge variant={betaStateVariant(b.state)} size="md" uppercase>{b.state}</Badge> <StatusBadge state={b.state} />
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
@@ -163,7 +149,7 @@
{#each scripts as s (s.id)} {#each scripts as s (s.id)}
<div class="script-row"> <div class="script-row">
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge> <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-target">{s.target_rel_path}</span>
<span class="s-ver">{artifactVersionLabel(s.version)}</span> <span class="s-ver">{artifactVersionLabel(s.version)}</span>
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span> <span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
@@ -202,8 +188,8 @@
<div class="srv-list"> <div class="srv-list">
{#each servers as bs (bs.server)} {#each servers as bs (bs.server)}
<div class="srv-row"> <div class="srv-row">
<a class="srv-name" href="/servers/{bs.server}">{bs.server}</a> <a class="srv-name" href="/servers/{bs.server}"><ServerIdentity name={bs.server} /></a>
<Badge variant={stageVariant(bs.stage)} size="sm" uppercase>{bs.stage}</Badge> <StatusBadge state={bs.stage} />
<div class="ticks" aria-label="pipeline progress"> <div class="ticks" aria-label="pipeline progress">
<span class:on={bs.deployed_at}>Deploy</span> <span class:on={bs.deployed_at}>Deploy</span>
<span class:on={bs.restarted_at || bs.stage === 'restarting'}>Empty</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>
+55 -49
View File
@@ -1,5 +1,11 @@
<script lang="ts"> <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 Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -47,6 +53,10 @@
interface DriftServer { server: string; files: DriftFile[] } interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[] } 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 deployments = $state<Deployment[]>([]);
let loading = $state(true); let loading = $state(true);
let drift = $state<DriftPreview | null>(null); let drift = $state<DriftPreview | null>(null);
@@ -54,20 +64,28 @@
let running = $state(false); let running = $state(false);
let rollingBack = $state<string | null>(null); let rollingBack = $state<string | null>(null);
let flash = $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. // Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false); let driftOpen = $state(false);
let driftFilter = $state(''); let driftFilter = $state('');
async function loadCampaigns(): Promise<void> { async function loadCampaigns(): Promise<void> {
try {
const res = await fetch('/api/campaigns'); const res = await fetch('/api/campaigns');
const body = (await res.json()) as { deployments: Deployment[] }; if (!res.ok) throw new Error('Could not refresh rollouts.');
deployments = body.deployments; deployments = (await res.json()).deployments; loadError = '';
} catch (error) { loadError = (error as Error).message; }
finally { loading = false; }
} }
async function scanDrift(): Promise<void> { async function scanDrift(): Promise<void> {
if (scanning) return;
scanning = true; scanning = true;
try { try {
const res = await fetch('/api/drift'); const res = await fetch('/api/drift');
if (!res.ok) throw new Error('Could not scan drift.');
drift = (await res.json()) as DriftPreview; drift = (await res.json()) as DriftPreview;
driftError = '';
} catch (error) { driftError = (error as Error).message;
} finally { } finally {
scanning = false; scanning = false;
} }
@@ -97,7 +115,7 @@
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`); 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.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing 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; running = true; flash = null;
try { try {
const res = await fetch('/api/drift', { const res = await fetch('/api/drift', {
@@ -117,20 +135,12 @@
if (restored > 0) parts2.push(`installed ${restored}`); if (restored > 0) parts2.push(`installed ${restored}`);
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`; flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
await Promise.all([loadCampaigns(), scanDrift()]); await Promise.all([loadCampaigns(), scanDrift()]);
} finally { } catch (error) { flash = (error as Error).message; } finally {
running = false; running = false;
} }
} }
let poll: ReturnType<typeof setInterval> | null = null; onMount(() => { void scanDrift(); return pollVisible(loadCampaigns, 5000); });
onMount(async () => {
await Promise.all([loadCampaigns(), scanDrift()]);
loading = false;
poll = setInterval(loadCampaigns, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
function isActive(d: Deployment): boolean { function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed'); 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`; if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d 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 active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d))); let history = $derived(deployments.filter((d) => !isActive(d)));
@@ -169,16 +172,16 @@
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote'; 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; rollingBack = d.id;
flash = null; flash = null;
try { try {
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' }); const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string }; const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; } if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = 'Rollback campaign opened'; flash = 'Rollback started';
await loadCampaigns(); await loadCampaigns();
} finally { } catch (error) { flash = (error as Error).message; } finally {
rollingBack = null; rollingBack = null;
} }
} }
@@ -186,7 +189,7 @@
// History grouped by day for a nice timeline read. // History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => { let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>(); 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(); const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d); groups.get(key)!.push(d);
@@ -197,29 +200,27 @@
// Drift filtering — spare the operator's eyes on huge fleets. // Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived( let filteredDriftServers = $derived(
drift?.servers.filter((s) => drift?.servers.filter((s) =>
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase()) !driftFilter || (s.server + ' ' + serverTitle(s.server)).toLowerCase().includes(driftFilter.toLowerCase())
) ?? [] ) ?? []
); );
</script> </script>
<div class="page"> <div class="page">
<SectionHeader <SectionHeader
title="Campaigns" title="Rollouts"
subtitle="Fleet campaigns, drift reconciliation, and rollbacks. Servers restart in stages as earlier servers recover." 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} {#if loading}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
{:else} {:else}
<!-- Drift summary — collapsed by default when there's no problem. --> <!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg"> <Card padding="lg">
<button {#if driftError}<div class="ui-error" role="alert">{driftError}<Button size="sm" onclick={scanDrift} disabled={scanning}>Retry scan</Button></div>{/if}
type="button" <div class="drift-summary">
class="drift-summary" <button class="drift-summary-l" aria-expanded={driftOpen} aria-controls="drift-body" onclick={() => driftOpen = !driftOpen}>
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span> <span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span> <span class="drift-title">Drift</span>
{#if drift === null} {#if drift === null}
@@ -240,8 +241,8 @@
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'} across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span> </span>
{/if} {/if}
</div> </button>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none"> <div class="drift-summary-r">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}> <Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan Rescan
</Button> </Button>
@@ -255,21 +256,21 @@
Reconcile Reconcile
</Button> </Button>
</div> </div>
</button> </div>
{#if driftOpen} {#if driftOpen}
<div class="drift-body"> <div class="drift-body" id="drift-body">
{#if drift && drift.totalDrift > 0} {#if drift && drift.totalDrift > 0}
<input <input
class="drift-filter" class="drift-filter" aria-label="Filter drift by server"
type="search" type="search"
placeholder="Filter by server name…" placeholder="Filter by server name…"
bind:value={driftFilter} 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">
<div class="drift-server-head"> <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> <span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div> </div>
<div class="drift-files"> <div class="drift-files">
@@ -286,6 +287,7 @@
</div> </div>
</div> </div>
{/each} {/each}
<ListFooter total={filteredDriftServers.length} bind:limit={driftLimit} label="servers" />
{:else if drift?.totalDrift === 0} {:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." /> <EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else} {:else}
@@ -308,7 +310,7 @@
{#if active.length === 0} {#if active.length === 0}
<EmptyState <EmptyState
title="Nothing in flight" 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} {:else}
<div class="deploy-list"> <div class="deploy-list">
@@ -327,7 +329,7 @@
</div> </div>
{#if history.length === 0} {#if history.length === 0}
<EmptyState <EmptyState
title="No completed campaigns in the retention window" title="No completed rollouts in the retention window"
description="Retention is 7 days by default." description="Retention is 7 days by default."
/> />
{:else} {:else}
@@ -343,14 +345,15 @@
{/each} {/each}
{/if} {/if}
</section> </section>
<ListFooter total={history.length} bind:limit label="rollouts" />
{/if} {/if}
</div> </div>
{#snippet deployRow(d: Deployment)} {#snippet deployRow(d: Deployment)}
<a class="deploy-row" href="/campaigns/{d.id}"> <article class="deploy-row">
<div class="row-head"> <div class="row-head">
<div class="row-head-l"> <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'} {#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span> <span class="target">{scriptSummary(d)}</span>
{/if} {/if}
@@ -381,7 +384,7 @@
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}" title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
> >
<span class="dot"></span> <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> </span>
{/each} {/each}
</div> </div>
@@ -391,12 +394,12 @@
{#each ['done','pending','failed','held','manual'] as state} {#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length} {@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0} {#if n > 0}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge> <span class="state-count">{n} <StatusBadge {state} size="sm" /></span>
{/if} {/if}
{/each} {/each}
</div> </div>
</div> </div>
</a> </article>
{/snippet} {/snippet}
<style> <style>
@@ -664,4 +667,7 @@
.drift-summary { flex-direction: column; align-items: flex-start; } .drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; } .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> </style>
+35 -86
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <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 { page } from '$app/stores';
import DiffViewer from '$ui/components/DiffViewer.svelte'; import DiffViewer from '$ui/components/DiffViewer.svelte';
import Card from '$ui/components/primitives/Card.svelte'; import Card from '$ui/components/primitives/Card.svelte';
@@ -35,6 +39,10 @@
let detail = $state<Detail | null>(null); let detail = $state<Detail | null>(null);
let loadError = $state(''); let loadError = $state('');
let request = 0; 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); let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
async function loadDetail(): Promise<void> { async function loadDetail(): Promise<void> {
@@ -42,7 +50,7 @@
const current = ++request; const current = ++request;
try { try {
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`); 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; const body = await res.json() as Detail;
if (current !== request || id !== $page.params.id) return; if (current !== request || id !== $page.params.id) return;
detail = body; loadError = ''; 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(() => pollVisible(loadDetail, 5000, false));
onMount(() => {
poll = setInterval(loadDetail, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
async function loadDiff(f: FileRow) { async function loadDiff(f: FileRow) {
const res = await fetch(`/api/campaigns/${$page.params.id}/file/${f.id}`); const id = $page.params.id;
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string }; const current = ++diffRequest;
openFile = { id: f.id, ...body }; 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(() => { let byServer = $derived.by(() => {
@@ -76,14 +85,6 @@
} }
return m; 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' { function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
return v === 'added' ? 'online' : return v === 'added' ? 'online' :
v === 'updated' ? 'info' : v === 'updated' ? 'info' :
@@ -100,7 +101,7 @@
} }
</script> </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 loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
{#if detail} {#if detail}
@@ -109,7 +110,7 @@
<SectionHeader title={action} subtitle={detail.deployment.id}> <SectionHeader title={action} subtitle={detail.deployment.id}>
{#snippet crumbs()} {#snippet crumbs()}
<Breadcrumbs items={[ <Breadcrumbs items={[
{ label: 'Campaigns', href: '/campaigns' }, { label: 'Rollouts', href: '/campaigns' },
{ label: action } { label: action }
]} /> ]} />
{/snippet} {/snippet}
@@ -139,13 +140,13 @@
{#each detail.servers as s (s.server)} {#each detail.servers as s (s.server)}
<div class="srv-row"> <div class="srv-row">
<div class="srv-l"> <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} {#if s.error}
<span class="err">{s.error}</span> <span class="err">{s.error}</span>
{/if} {/if}
</div> </div>
<div class="srv-r"> <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} {#if s.players !== null && s.players > 0}
<span class="players">{s.players}p</span> <span class="players">{s.players}p</span>
{/if} {/if}
@@ -165,11 +166,11 @@
<Badge variant="neutral" size="sm">{detail.files.length}</Badge> <Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div> </div>
{#if detail.files.length === 0} {#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} {:else}
{#each [...byServer.entries()] as [srv, files] (srv)} {#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group"> <div class="file-group">
<h3>{srv}</h3> <h3><ServerIdentity name={srv} /></h3>
<div class="file-rows"> <div class="file-rows">
{#each files as f (f.id)} {#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}> <button class="file-row" onclick={() => loadDiff(f)}>
@@ -188,29 +189,11 @@
{/if} {/if}
</section> </section>
{#if openFile} <Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
<div {#if diffError}<p class="ui-error" role="alert">{diffError}</p>
class="diff-scrim" {:else if diffLoading}<EmptyState title="Loading comparison…" />
onclick={() => (openFile = null)} {:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} </Dialog>
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}
</div> </div>
{:else if !loadError} {:else if !loadError}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
@@ -341,44 +324,10 @@
} }
/* Diff modal */ /* 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) { @media (max-width: 720px) {
.srv-row { align-items: flex-start; flex-direction: column; } .srv-row { align-items: flex-start; flex-direction: column; }
.srv-l, .file-row { flex-wrap: wrap; } .srv-l, .file-row { flex-wrap: wrap; }
.diff-modal { padding: var(--space-4); max-height: 92dvh; }
} }
</style> </style>
+61 -43
View File
@@ -1,5 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -31,13 +38,23 @@
let blueprintsLoading = $state(true); let blueprintsLoading = $state(true);
let blueprintError = $state(''); let blueprintError = $state('');
let removing = $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 action = $state('all');
let search = $state(''); let search = $state('');
let refreshing = false; let refreshing = false;
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state))); 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) && 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() { async function load() {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
@@ -54,12 +71,7 @@
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name)); blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; } if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
} }
onMount(() => { onMount(() => pollVisible(load, 5000));
void load();
void loadBlueprints();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
async function loadBlueprints() { async function loadBlueprints() {
try { try {
const res = await fetch('/api/blueprints'); const res = await fetch('/api/blueprints');
@@ -70,11 +82,12 @@
finally { blueprintsLoading = false; } finally { blueprintsLoading = false; }
} }
async function removeBlueprint(id: string) { 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 = ''; removing = id; blueprintError = '';
try { try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' }); const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove blueprint.'); 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; } } catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; } finally { removing = ''; }
} }
@@ -87,20 +100,18 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head> <svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." /> <SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
<div class="launch-banner"> {#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}
<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> </SectionHeader>
<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> <Tabs {tabs} bind:value={tab} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
</div>
<DeployServerDialog bind:open onblueprint={loadBlueprints} /> <DeployServerDialog bind:open onblueprint={loadBlueprints} />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} /> <BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} /> <DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<div class="fleet-shutdown"> <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(); }} />
<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(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if} {#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> <section>
<h2>Your blueprints <span>{blueprints.length}</span></h2> <h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if} {#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 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} {:else}
<div class="blueprints"> <div class="blueprints">
{#each blueprints as blueprint (blueprint.id)} {#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint"> <article class="blueprint">
<div class="blueprint-top"> <div class="blueprint-top">
<span class="eyebrow">Server collection</span> <span class="eyebrow">Server collection</span>
@@ -120,57 +131,61 @@
<div class="blueprint-footer"> <div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button> <Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</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> </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> </article>
{/each} {/each}
</div> </div>
{/if} {/if}
</section> </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 error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collections.length} {#if visibleCollections.length}
<section> <section>
<h2>Collection launches</h2> <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}"> <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> <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> <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> <span class="meta">Started {fmt(collection.created_at)}</span>
</a> </a>
{/each}</div> {/each}</div>
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} step={10} label="collections" />
</section> </section>
{/if} {/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> <section>
<h2>In flight <span>{active.length}</span></h2> <h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading deployments...</p> {#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 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} {:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section> </section>
{:else}
<section> <section>
<h2>History</h2> <h2>History</h2>
<div class="filters"> <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>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> <label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
</div> </div>
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="History is recorded for server operations performed after this update, including failures." /> {#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if} {: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> </section>
{/if}
{/if}
</div>
</div> </div>
{#snippet row(d: Deployment)} {#snippet row(d: Deployment)}
<a class="deployment-row" href="/deployments/{d.id}"> <a class="deployment-row" href="/deployments/{d.id}">
<div class="row-head"> <div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge> <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> </div>
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge> <StatusBadge state={d.state} />
</div> </div>
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server creation completed.' : 'Server destroyed.') : d.stage}</p> <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> <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} {/snippet}
<style> <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; } .page { display: flex; flex-direction: column; gap: 28px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; } .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; } .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; } .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; } .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: 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; } .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, .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; } .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; } section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); } p { margin: 8px 0; } a { color: var(--accent); }
.rows { display: grid; gap: 12px; } .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:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; } .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; } .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; } 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%; } 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; } } @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> </style>
+16 -34
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -75,9 +80,6 @@
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { resuming = false; } 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() { async function load() {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
@@ -91,23 +93,20 @@
finally { refreshing = false; } finally { refreshing = false; }
} }
$effect(() => { $page.params.id; detail = null; void load(); }); $effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => { onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
return () => clearInterval(timer);
});
function fmt(iso: string) { return new Date(iso).toLocaleString(); } function fmt(iso: string) { return new Date(iso).toLocaleString(); }
</script> </script>
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head> <svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment?.server}> <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?.server ?? 'Details' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
</SectionHeader> </SectionHeader>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if} {#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 detail?.collection}<a href="/deployments/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
{#if deployment} {#if deployment}
<section class="summary"> <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} {#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div> </div>
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2> <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} {#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> <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>
<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} />
<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>
{#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} {#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} {:else if !error}<p>Loading deployment...</p>{/if}
</div> </div>
<style> <style>
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; } .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); } .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: 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; } .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; } .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); } 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; } 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; }
.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; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
.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); }
@media (max-width: 640px) { @media (max-width: 640px) {
.timing { flex-direction: column; gap: var(--space-2); } .timing { flex-direction: column; gap: var(--space-2); }
} }
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -22,7 +25,7 @@
} catch (err) { if (current === requestNumber) error = (err as Error).message; } } catch (err) { if (current === requestNumber) error = (err as Error).message; }
} }
$effect(() => { $page.params.id; detail = null; void load(); }); $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> </script>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head> <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"> <section class="servers" aria-label="Servers in this collection">
{#each detail.servers as server (server.id)} {#each detail.servers as server (server.id)}
<a class="server" href="/deployments/{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> <p>{server.stage}</p>
{#if server.error}<p class="error">{server.error}</p>{/if} {#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> <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> </div>
<style> <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> </style>
+91 -77
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
import PluginUpdates from '$ui/components/PluginUpdates.svelte'; import PluginUpdates from '$ui/components/PluginUpdates.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
@@ -20,6 +25,7 @@
scope_type: 'game' | 'map' | null; scope_type: 'game' | 'map' | null;
scope_value: string | null; scope_value: string | null;
current_version_id: number | null; current_version_id: number | null;
currentVersion: { id: number; version: number; release_version: string | null } | null;
kind: ArtifactKind; kind: ArtifactKind;
latestVersion: { latestVersion: {
id: number; version: number; sha256: string; size: number; id: number; version: number; sha256: string; size: number;
@@ -32,6 +38,18 @@
} }
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; } 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 plugins = $state<ArtifactRow[]>([]);
let importCount = $state(0); let importCount = $state(0);
let loading = $state(true); let loading = $state(true);
@@ -61,11 +79,11 @@
fetch('/api/scope-options') fetch('/api/scope-options')
]); ]);
if (!pluginRes.ok) { if (!pluginRes.ok) {
flash = `Plugins API returned ${pluginRes.status}. If this is a dev environment, restart the server so the DB migration runs.`; throw new Error('Could not refresh plugins. Your last loaded results are still shown.');
plugins = [];
} else { } else {
plugins = ((await pluginRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? []; plugins = ((await pluginRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
} }
flash = null;
if (scopeRes.ok) { if (scopeRes.ok) {
scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} }; scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} };
} }
@@ -105,33 +123,22 @@
: null; : null;
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null; newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
newReleaseVersion = ''; newReleaseVersion = '';
addOpen = false;
await load(); await load();
} finally { } catch (error) { flash = (error as Error).message; } finally {
uploading = false; uploading = false;
} }
} }
function scopeLabel(a: ArtifactRow): string { function scopeLabel(a: ArtifactRow): string {
if (!a.scope_type) return 'unscoped'; if (!a.scope_type) return 'unscoped';
return `${a.scope_type} · ${a.scope_value ?? '?'}`; 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 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;
} }
function stateLabel(a: ArtifactRow): string { function stateLabel(a: ArtifactRow): string {
if (!a.latestVersion) return ''; if (a.runningBetaId) return 'testing';
if (a.current_version_id === null) return 'untested'; if (!a.latestVersion) return 'empty';
if (a.latestVersion.id !== a.current_version_id) { if (a.latestVersion.id === a.current_version_id) return 'promoted';
return `${artifactVersionLabel(a.latestVersion)} ${a.latestVersion.state.replace('-', ' ')}`; return a.latestVersion.state;
}
return '';
} }
let gameOptions = $derived([ let gameOptions = $derived([
@@ -150,20 +157,68 @@
</script> </script>
<div class="page"> <div class="page">
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." /> <SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
<PluginGithubImport onimport={async () => { importCount++; await load(); }} /> </SectionHeader>
{#key importCount}<PluginUpdates />{/key}
<Card padding="lg">
<h2 class="panel-title">Upload a plugin</h2>
{#if successBanner} {#if successBanner}
<div class="banner"> <div class="banner">
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong> <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> </div>
{/if} {/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"> <Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} /> <Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field> </Field>
@@ -175,19 +230,19 @@
</Field> </Field>
<Field label="Scope" hint="Where this plugin deploys."> <Field label="Scope" hint="Where this plugin deploys.">
<div class="scope-row"> <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'} {#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}
{#if newScopeType === 'map'} {#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} {/if}
</div> </div>
</Field> </Field>
<Field label="Plugin file (.dll)"> <Field label="Plugin file (.dll)">
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} /> <Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
</Field> </Field>
<div class="form-actions"> <div class="form-actions ui-sticky-actions">
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
@@ -199,56 +254,15 @@
{#if flash}<span class="flash">{flash}</span>{/if} {#if flash}<span class="flash">{flash}</span>{/if}
</div> </div>
</form> </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} {/if}
</div> </div>
<div class="row-meta"> </Dialog>
<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>
<style> <style>
.page { display: flex; flex-direction: column; gap: var(--space-6); } .page { display: flex; flex-direction: column; gap: var(--space-6); }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); } .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 { .banner {
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
+12 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.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 { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.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 scripts = $state<LibraryScript[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
@@ -38,7 +42,7 @@
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
})); }));
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s))); 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))); const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
async function load() { async function load() {
error = ''; error = '';
@@ -71,7 +75,7 @@
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} /> ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
<div class="page"> <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} {#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
</SectionHeader> </SectionHeader>
<div class="status-grid" aria-label="Filter by script status"> <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." /> <EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
{:else} {:else}
<div class="rows"> <div class="rows">
{#each visible as s (s.id)} {#each visible.slice(0,limit) as s (s.id)}
{@const state = scriptStatus(s)} {@const state = scriptStatus(s)}
<article class="script-row" class:selected={selected.includes(s.id)}> <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)} /> <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="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="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 class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
</div> </div>
@@ -125,6 +129,7 @@
{/each} {/each}
</div> </div>
{/if} {/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> <p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
</section> </section>
{#if selectedScripts.length} {#if selectedScripts.length}
@@ -164,4 +169,7 @@
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .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: 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; } } @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> </style>
+55 -31
View File
@@ -1,10 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 Button from '$lib/ui/components/primitives/Button.svelte';
import Field from '$lib/ui/components/primitives/Field.svelte'; import Field from '$lib/ui/components/primitives/Field.svelte';
import Input from '$lib/ui/components/primitives/Input.svelte'; import Input from '$lib/ui/components/primitives/Input.svelte';
interface SavedKey { server_id: string; deployed: number; updated_at: string } 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 keys = $state<SavedKey[]>([]);
let serverId = $state(''); let serverId = $state('');
let plutoKey = $state(''); let plutoKey = $state('');
@@ -13,13 +24,15 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let notice = $state(''); let notice = $state('');
const filtered = $derived(keys.filter(key => (key.server_id + ' ' + serverTitle(key.server_id)).toLowerCase().includes(search.toLowerCase())));
async function load() { async function load() {
const res = await fetch('/api/server-keys'); const res = await fetch('/api/server-keys');
if (!res.ok) throw new Error('Could not load server keys'); if (!res.ok) throw new Error('Could not load server keys');
keys = (await res.json()).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; } function reset() { serverId = ''; plutoKey = ''; editing = false; }
async function save(e: SubmitEvent) { async function save(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@@ -31,12 +44,12 @@
}); });
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save key'); 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; } } catch (e) { error = (e as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
async function remove(id: string) { 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 = ''; busy = true; error = ''; notice = '';
try { try {
const res = await fetch('/api/server-keys', { const res = await fetch('/api/server-keys', {
@@ -52,10 +65,39 @@
</script> </script>
<svelte:head><title>Keys · Electric Cherry</title></svelte:head> <svelte:head><title>Keys · Electric Cherry</title></svelte:head>
<h1>Keys</h1> <div class="page">
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p> <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}> <form onsubmit={save}>
<h2>{editing ? 'Update key' : 'Add key'}</h2>
<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."> <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 /> <Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field> </Field>
@@ -64,37 +106,16 @@
</Field> </Field>
<div class="actions"> <div class="actions">
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button> <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> </div>
</form> </form>
{#if error}<p role="alert" class="error">{error}</p>{/if} </Dialog>
{#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}
<style> <style>
h1 { margin-top: 0; }
h2 { font-size: var(--text-md); } h2 { font-size: var(--text-md); }
p { color: var(--text-secondary); } 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 { 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); } .actions { display: flex; gap: var(--space-2); }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; } table { width: 100%; border-collapse: collapse; text-align: left; }
@@ -102,4 +123,7 @@
th { color: var(--text-tertiary); font-size: var(--text-sm); } th { color: var(--text-tertiary); font-size: var(--text-sm); }
code { font-family: var(--font-mono); } code { font-family: var(--font-mono); }
.error { color: var(--state-danger); } .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> </style>
+90 -85
View File
@@ -1,9 +1,17 @@
<script lang="ts"> <script lang="ts">
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte'; 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 { page } from '$app/stores';
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte'; 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 type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
import PlayerRoster from '$ui/components/PlayerRoster.svelte'; import PlayerRoster from '$ui/components/PlayerRoster.svelte';
@@ -68,7 +76,7 @@
game_started_at: string | null; game_ended_at: string | null; game_started_at: string | null; game_ended_at: string | null;
} }
interface InventoryEntry { interface InventoryEntry {
relPath: string; scriptVersionId: number | null; relPath: string; kind?: string; scriptVersionId: number | null;
sha256: string; size: number; updatedAt: string; source: string | null; sha256: string; size: number; updatedAt: string; source: string | null;
} }
interface Integrations { interface Integrations {
@@ -79,11 +87,11 @@
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>; servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
} }
let server = $state<Server | null>(null); let name = $derived($page.params.name ?? '');
let integrations = $state<Integrations | null>(null); let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
let integrations = $derived($fleetIntegrations);
let history = $state<DeploymentRow[]>([]); let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]); let inventory = $state<InventoryEntry[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null); let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false); let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null); let netDiagnosis = $state<NetDiagnosis | null>(null);
@@ -91,54 +99,53 @@
let rconProbe = $state<RconProbeResult | null>(null); let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false); let probingRcon = $state(false);
let destroyOpen = $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> { async function loadTab(currentName: string, tab: string) {
const [srvRes, depRes, invRes] = await Promise.all([ if (tab !== 'history' && tab !== 'inventory') return;
fetch('/api/servers'), try {
fetch('/api/campaigns'), const response = await fetch(tab === 'history' ? '/api/campaigns?server=' + encodeURIComponent(currentName) : '/api/servers/' + encodeURIComponent(currentName) + '/scripts');
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`) if (!response.ok) throw new Error('Could not refresh ' + tab + '.');
]); const body = await response.json();
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations }; if (name !== currentName || activeTab !== tab) return;
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] }; if (tab === 'history') history = body.deployments; else inventory = body.scripts;
server = srvBody.servers.find((s) => s.name === name) ?? null; tabError = '';
integrations = srvBody.integrations; } catch (error) { if (name === currentName && activeTab === tab) tabError = (error as Error).message; }
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name)); finally { if (name === currentName && activeTab === tab) tabLoading = false; }
if (invRes.ok) {
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
inventory = invBody.scripts;
} }
} $effect(() => {
const currentName = name;
let poll: ReturnType<typeof setInterval> | null = null; const tab = activeTab;
onMount(() => { tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
loadServer(); tabLoading = tab === 'history' || tab === 'inventory';
poll = setInterval(loadServer, 5000); return pollVisible(() => loadTab(currentName, tab), 10000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
}); });
$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(fresh ? server?.last_players ?? null : null);
let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null); let effectiveMap = $derived(server?.last_map ?? null);
let effectiveMap = $derived(live?.map ?? server?.last_map ?? null); let effectiveGametype = $derived(server?.last_gametype ?? null);
let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null); let effectiveRoster = $derived(server?.roster ?? null);
let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null); let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline'); let effectiveSeen = $derived(server?.last_seen_at ?? null);
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null); let effectiveErr = $derived(server?.last_error ?? null);
let effectiveErr = $derived(live ? live.lastError : server?.last_error ?? null); let effectiveEndpoint = $derived<RconEndpoint | null>(server?.endpoint ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
let decoded = $derived(decodeRconError(effectiveErr)); 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 errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>( 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 effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(live?.inactiveSince ?? server?.inactive_since ?? null); let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
let effectiveGameStartedAt = $derived<string | null>(live?.gameStartedAt ?? server?.game_started_at ?? null); let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
let effectiveGameEndedAt = $derived<string | null>(live?.gameEndedAt ?? server?.game_ended_at ?? null); let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null); let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null); let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince); let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
@@ -146,13 +153,6 @@
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting'); 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 { function ago(iso: string | null): string {
if (!iso) return 'never'; if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime(); const d = Date.now() - new Date(iso).getTime();
@@ -173,10 +173,7 @@
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' }); const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' });
komodoProbe = (await res.json()) as KomodoProbeResult; komodoProbe = (await res.json()) as KomodoProbeResult;
if (komodoProbe.ok && server) { if (komodoProbe.ok && server) {
const srvRes = await fetch('/api/servers'); await refreshFleet();
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
integrations = srvBody.integrations;
} }
} catch (err) { } catch (err) {
komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message }; komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message };
@@ -198,9 +195,7 @@
try { try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' }); const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' });
rconProbe = (await res.json()) as RconProbeResult; rconProbe = (await res.json()) as RconProbeResult;
const srvRes = await fetch('/api/servers'); await refreshFleet();
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
} catch (err) { } catch (err) {
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message }; rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
} finally { probingRcon = false; } } finally { probingRcon = false; }
@@ -209,6 +204,7 @@
let tabs = $derived([ let tabs = $derived([
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null }, { id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
{ id: 'logs', label: 'Logs' }, { id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Config' },
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null }, { id: 'inventory', label: 'Inventory', badge: inventory.length || null },
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null }, { id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'history', label: 'History', badge: history.length || 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, '-')};"> <div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<!-- Hero: name + status + player count + primary actions --> <!-- Hero: name + status + player count + primary actions -->
<Card padding="lg" elevation="flat"> <Card padding="lg" elevation="flat">
<SectionHeader title={server.name} size="lg"> <SectionHeader title={serverTitle(server.name)}>
{#snippet crumbs()} {#snippet crumbs()}
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} /> <Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button variant="danger" onclick={() => destroyOpen = true}> <Button variant="danger" onclick={() => destroyOpen = true}>
@@ -232,6 +228,7 @@
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<CopyValue value={name} label="Server ID" />
<div class="metrics"> <div class="metrics">
<div class="metric-cell name-cell"> <div class="metric-cell name-cell">
<StatusPip health={effectiveHealth} size="md" /> <StatusPip health={effectiveHealth} size="md" />
@@ -244,14 +241,14 @@
<div class="metric-cell"> <div class="metric-cell">
<Stat <Stat
label="Players" label="Players"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? 0} value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
size="xl" size="md"
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'} tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
tween tween
/> />
</div> </div>
<div class="metric-cell"> <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)} {#if gametypeLabel(effectiveGametype)}
<div class="sub">{gametypeLabel(effectiveGametype)}</div> <div class="sub">{gametypeLabel(effectiveGametype)}</div>
{/if} {/if}
@@ -281,11 +278,14 @@
</Card> </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"> <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>
<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'} {#if activeTab === 'roster'}
<Card padding="lg"> <Card padding="lg">
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} /> <PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
@@ -300,22 +300,24 @@
]} ]}
/> />
</Card> </Card>
{:else if activeTab === 'config'}
{#key name}<ConfigInspector fixedServer={name} />{/key}
{:else if activeTab === 'inventory'} {:else if activeTab === 'inventory'}
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Scripts on this server</h2> <h2>Installed files</h2>
<span class="panel-count">{inventory.length}</span> <span class="panel-count">{inventory.length}</span>
</div> </div>
{#if inventory.length === 0} {#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0}
<EmptyState <EmptyState
title="No scripts tracked yet" title="No files tracked yet"
description="Upload a script from the Scripts page and open a beta to deploy it here." description="Files appear here after scripts, configs, or plugins are deployed to this server."
/> />
{:else} {:else}
<div class="inv-list"> <div class="inv-list">
{#each inventory as f (f.relPath)} {#each inventory.slice(0,inventoryLimit) as f (f.relPath)}
<div class="inv-row"> <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="size">{f.size} B</span>
<span class="src">{f.source ?? '—'}</span> <span class="src">{f.source ?? '—'}</span>
<span class="sha">{f.sha256.slice(0, 12)}</span> <span class="sha">{f.sha256.slice(0, 12)}</span>
@@ -324,6 +326,7 @@
</div> </div>
{/if} {/if}
</Card> </Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
{:else if activeTab === 'diagnostics'} {:else if activeTab === 'diagnostics'}
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
@@ -489,29 +492,22 @@
{:else if activeTab === 'history'} {:else if activeTab === 'history'}
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Campaign history</h2> <h2>Rollout history</h2>
<span class="panel-count">{history.length}</span> <span class="panel-count">{history.length}</span>
</div> </div>
{#if history.length === 0} {#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
<EmptyState <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." description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/> />
{:else} {:else}
<div class="history"> <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)!} {@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}"> <a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
<span class="hist-action">{d.action}</span> <span class="hist-action">{d.action}</span>
<span class="hist-state"> <span class="hist-state">
<Badge <StatusBadge state={mine.restart_state} />
variant={mine.restart_state === 'done' ? 'online' :
mine.restart_state === 'failed' ? 'danger' :
mine.restart_state === 'pending' ? 'warning' :
'neutral'}
size="sm"
uppercase
>{mine.restart_state}</Badge>
{#if mine.players !== null && mine.players > 0} {#if mine.players !== null && mine.players > 0}
<span class="hist-players">{mine.players}p</span> <span class="hist-players">{mine.players}p</span>
{/if} {/if}
@@ -524,9 +520,14 @@
</div> </div>
{/if} {/if}
</Card> </Card>
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" />
{/if} {/if}
</div> </div>
</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} {:else}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
{/if} {/if}
@@ -823,4 +824,8 @@
gap: var(--space-1); 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> </style>
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
describe('server detail data', () => {
it('filters history before the global limit and includes the matching rollout members', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target'), ('other', 'other')");
handle.transaction(() => {
const deployment = handle.prepare("INSERT INTO deployments (id, action, started_at) VALUES (?, 'promote', ?)");
const member = handle.prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES (?, ?, 1, 'done')");
deployment.run('older-target', '2026-01-01');
member.run('older-target', 'target'); member.run('older-target', 'other');
for (let n = 0; n < 501; n++) { deployment.run('other-' + n, '2026-09-18'); member.run('other-' + n, 'other'); }
})();
const { GET } = await import('../../src/routes/api/campaigns/+server.js');
const all = await (await GET({ url: new URL('http://localhost/api/campaigns') })).json();
expect(all.deployments).toHaveLength(500);
const filtered = await (await GET({ url: new URL('http://localhost/api/campaigns?server=target') })).json();
expect(filtered.deployments.map((row: { id: string }) => row.id)).toEqual(['older-target']);
expect(filtered.deployments[0].servers).toHaveLength(2);
expect((await (await GET({ url: new URL('http://localhost/api/campaigns?server=unknown') })).json()).deployments).toEqual([]);
});
it('identifies plugins, scripts, composed configs and unknown inventory files', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db().exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target')");
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
const { version } = createScript({ name:'Plugin', kind:'plugin', targetRelPath:'native.bin', content:Buffer.from('plugin') });
recordScript('target','native.bin',version.id,'hash',6,'beta');
recordScript('target','dedicated_zm.cfg',null,'hash',10,'compose');
recordScript('target','main.gsc',null,'hash',10,'beta');
recordScript('target','unknown.dat',null,'hash',10,'manual');
const { GET } = await import('../../src/routes/api/servers/[name]/scripts/+server.js');
const body = await (await GET({ params: {name:'target'} })).json();
expect(Object.fromEntries(body.scripts.map((file: {relPath:string;kind:string}) => [file.relPath,file.kind]))).toEqual({
'dedicated_zm.cfg':'config','main.gsc':'script','native.bin':'plugin','unknown.dat':'file'
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { serverIdentity } from '../../src/lib/ui/server-labels.js';
import { statusPresentation } from '../../src/lib/ui/status.js';
describe('operator-facing identities and states', () => {
it('preserves lobby numbers and distinguishes T6 variants sharing an engine map', () => {
expect(serverIdentity('t6_zombie_town_12')).toEqual({ map:'Town Survival', lobby:12, game:'Black Ops II' });
expect(serverIdentity('t6_zombie_towngrief_2')).toEqual({ map:'Town Grief', lobby:2, game:'Black Ops II' });
expect(serverIdentity('custom-server')).toEqual({ map:'custom-server', lobby:null, game:'' });
});
it('keeps normal waiting neutral and distinguishes test success from promotion', () => {
expect(statusPresentation('awaiting-empty').tone).toBe('neutral');
expect(statusPresentation('failed').tone).toBe('danger');
expect(statusPresentation('tested').label).toBe('Test passed');
expect(statusPresentation('promoted').label).toBe('Promoted');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { pollVisible } from '../../src/lib/ui/poll.js';
let documentStub: EventTarget & { visibilityState: string };
let stop: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
documentStub = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('document', documentStub);
});
afterEach(() => { stop?.(); stop = undefined; vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
const visibility = (state: string) => { documentStub.visibilityState = state; documentStub.dispatchEvent(new Event('visibilitychange')); };
describe('visible page polling', () => {
it('waits for a request to finish before scheduling the next request', async () => {
let finish!: () => void;
const task = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
stop = pollVisible(task, 1000);
await vi.advanceTimersByTimeAsync(5000);
visibility('visible');
expect(task).toHaveBeenCalledTimes(1);
finish(); await vi.advanceTimersByTimeAsync(999);
expect(task).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(task).toHaveBeenCalledTimes(2);
});
it('pauses when hidden and refreshes on return, including hiding during a request', async () => {
let finish!: () => void;
const task = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
stop = pollVisible(task, 1000);
visibility('hidden'); finish();
await vi.advanceTimersByTimeAsync(5000);
expect(task).toHaveBeenCalledTimes(1);
visibility('visible');
expect(task).toHaveBeenCalledTimes(2);
finish(); stop();
visibility('visible'); await vi.advanceTimersByTimeAsync(5000);
expect(task).toHaveBeenCalledTimes(2);
});
it('recovers after a failed request and supports a delayed initial refresh', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const task = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
stop = pollVisible(task, 1000, false);
expect(task).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(task).toHaveBeenCalledTimes(2);
});
});