Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -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.
|
||||
@@ -37,7 +37,7 @@ export interface BetaSelection { scriptId: number; versionId: number; }
|
||||
export function betaSelectionHref(selections: BetaSelection[]): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
|
||||
return `/betas?${query}`;
|
||||
return `/betas/new?${query}`;
|
||||
}
|
||||
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
|
||||
const values = query.getAll('artifact');
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||||
import ConfigEditor from './ConfigEditor.svelte';
|
||||
@@ -60,9 +67,10 @@
|
||||
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
let uploadDialog = $state<HTMLDialogElement | null>(null);
|
||||
let fleetServers: ServerRow[] = [];
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
const fleetServers = $derived($sharedFleet);
|
||||
let loadError = $state('');
|
||||
let limit = $state(25);
|
||||
let request = 0;
|
||||
|
||||
const editedScopeValue = $derived.by(() => {
|
||||
if (scopeType === 'game') return scopeGame;
|
||||
@@ -82,39 +90,27 @@
|
||||
|
||||
function scopeLabel(s: Artifact): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
|
||||
return s === 'tested' ? 'online' :
|
||||
s === 'test-failed' ? 'danger' :
|
||||
'neutral';
|
||||
return s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : s.scope_value === 't5' ? 'Black Ops I' : s.scope_value === 't6' ? 'Black Ops II' : s.scope_value ?? 'Unscoped';
|
||||
}
|
||||
|
||||
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
|
||||
const [detailRes, serversRes, scopeRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
script: Artifact; versions: Version[];
|
||||
matchedServers: string[]; runningBetaId: number | null;
|
||||
};
|
||||
artifact = body.script;
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
fleetServers = s.servers;
|
||||
}
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
}
|
||||
if (seedForm) seedScopeForm();
|
||||
loading = false;
|
||||
const requestedId = id;
|
||||
const current = ++request;
|
||||
try {
|
||||
const [detailRes, scopeRes] = await Promise.all([
|
||||
fetch('/api/scripts/' + requestedId),
|
||||
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
|
||||
]);
|
||||
if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
|
||||
const body = await detailRes.json();
|
||||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||||
if (requestedId !== id || current !== request) return;
|
||||
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
|
||||
if (scopes) scopeOptions = scopes;
|
||||
if (seedForm) seedScopeForm();
|
||||
loadError = '';
|
||||
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
|
||||
finally { if (requestedId === id && current === request) loading = false; }
|
||||
}
|
||||
|
||||
function seedScopeForm(): void {
|
||||
@@ -126,17 +122,14 @@
|
||||
} else if (scopeType === 'map') {
|
||||
scopeMap = artifact.scope_value ?? '';
|
||||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||||
scopeGame = owner?.game ?? '';
|
||||
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
|
||||
} else {
|
||||
scopeGame = ''; scopeMap = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
poll = setInterval(() => load({ seedForm: false }), 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
|
||||
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
|
||||
|
||||
function openScopeModal(): void {
|
||||
if (runningBetaId !== null) return;
|
||||
@@ -163,7 +156,10 @@
|
||||
}
|
||||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||||
if (!dialog) return;
|
||||
if (e.target === dialog) dialog.close();
|
||||
if (e.target === dialog && !uploadingVersion && !updatingScope) {
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
@@ -189,7 +185,7 @@
|
||||
versionFile = null; versionNotes = '';
|
||||
uploadDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
@@ -218,17 +214,19 @@
|
||||
}
|
||||
scopeDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteArtifact(): Promise<void> {
|
||||
if (!confirm(`Delete this ${family}?`)) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; }
|
||||
goto(basePath);
|
||||
if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
|
||||
try {
|
||||
const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
|
||||
await goto(basePath);
|
||||
} catch (error) { flash = (error as Error).message; }
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
@@ -246,11 +244,12 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if !artifact}
|
||||
{:else if !artifact && !loadError}
|
||||
<EmptyState title="Not found" description="This {family} may have been deleted." />
|
||||
{:else}
|
||||
{:else if artifact}
|
||||
{@const a = artifact}
|
||||
<div class="page">
|
||||
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
|
||||
@@ -303,7 +302,7 @@
|
||||
{:else if matchedServers.length === 0}
|
||||
<span class="warn">No matching servers</span>
|
||||
{:else}
|
||||
{matchedServers.join(', ')}
|
||||
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -323,7 +322,7 @@
|
||||
<span class="k">Latest version</span>
|
||||
<span class="v mono">
|
||||
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
||||
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
|
||||
<StatusBadge state={latestVersion.state} size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,7 +343,7 @@
|
||||
<EmptyState title="No versions yet" description="Upload a first version to get started." />
|
||||
{:else}
|
||||
<div class="version-list">
|
||||
{#each versions as v (v.id)}
|
||||
{#each versions.slice(0,limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||
@@ -352,16 +351,18 @@
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<span class="v-when">{v.uploaded_at}</span>
|
||||
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
<Badge variant={stateVariant(v.state)} size="sm">{v.state.replace('-', ' ')}</Badge>
|
||||
<StatusBadge state={v.state} size="sm" />
|
||||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListFooter total={versions.length} bind:limit label="versions" />
|
||||
</Card>
|
||||
|
||||
{#if family === 'plugin'}
|
||||
@@ -376,20 +377,20 @@
|
||||
{#if flash}<div class="alert">{flash}</div>{/if}
|
||||
|
||||
<!-- Scope dialog -->
|
||||
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<form class="dlg" onsubmit={saveScope}>
|
||||
<header class="dlg-head">
|
||||
<h3>Edit scope</h3>
|
||||
<button type="button" class="close" onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="Scope">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
{#if scopeType === 'game' || scopeType === 'map'}
|
||||
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
{/if}
|
||||
{#if scopeType === 'map'}
|
||||
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
@@ -404,11 +405,11 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Upload dialog -->
|
||||
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<dialog aria-label="Upload new version" oncancel={event => { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<form class="dlg" onsubmit={uploadVersion}>
|
||||
<header class="dlg-head">
|
||||
<h3>Upload new version</h3>
|
||||
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="File">
|
||||
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
@@ -589,4 +590,5 @@
|
||||
.panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
|
||||
.dlg { padding: var(--space-4); }
|
||||
}
|
||||
.matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||
import { coverageSummary, needsSessionTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
|
||||
@@ -11,6 +12,7 @@
|
||||
let saving = $state(false);
|
||||
let editingId = $state('');
|
||||
let appliedPresetName = $state('');
|
||||
let choosingPreset = $state(false);
|
||||
const matchingPreset = $derived(presets.find(p => p.name === appliedPresetName && JSON.stringify(p.plan) === JSON.stringify(plan))
|
||||
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
|
||||
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
|
||||
@@ -32,6 +34,7 @@
|
||||
// Validation returns an independent plain object, even when the source is a Svelte proxy.
|
||||
plan = parseBetaPlan(preset.plan);
|
||||
appliedPresetName = name;
|
||||
choosingPreset = false;
|
||||
editingId = ''; error = '';
|
||||
message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`;
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
@@ -61,8 +64,9 @@
|
||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
||||
<span class="pill">Manual promotion</span>
|
||||
</header>
|
||||
<p>Choose a starting point, then tailor what each server needs to prove.</p>
|
||||
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
|
||||
|
||||
{#if choosingPreset}
|
||||
<div class="presets" role="group" aria-label="Start from a preset">
|
||||
{#each builtInPlans as preset, index (preset.name)}
|
||||
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
|
||||
@@ -83,7 +87,8 @@
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="feedback" role="status" aria-live="polite">{message}</div>
|
||||
{/if}
|
||||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="active-plan">
|
||||
@@ -194,17 +199,17 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
||||
h4 { font-size: 19px; font-weight: 600; }
|
||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: 11px; color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: var(--text-xs); color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
.presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
|
||||
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
|
||||
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
|
||||
.preset:hover { border-color: var(--accent-primary-hover); }
|
||||
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
|
||||
.preset-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
@@ -216,27 +221,27 @@
|
||||
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
|
||||
.preset li + li { margin-top: 5px; }
|
||||
.preset li::marker { color: var(--accent-primary-hover); }
|
||||
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: 11px; color: var(--text-secondary); }
|
||||
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.preset-footer > span { color: var(--accent-primary-hover); }
|
||||
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
|
||||
.error { color: var(--state-danger); }
|
||||
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: 12px; }
|
||||
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
|
||||
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
|
||||
dt { color: var(--text-secondary); font-size: 11px; margin-bottom: 6px; }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); margin-bottom: 6px; }
|
||||
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
dd small { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
|
||||
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
|
||||
.plan-caption { font-size: 12px; }
|
||||
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
|
||||
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
|
||||
.requirements { display: grid; gap: 10px; }
|
||||
.requirement { border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface-sunken); min-width: 0; overflow: hidden; }
|
||||
.requirement { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); min-width: 0; overflow: hidden; }
|
||||
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
|
||||
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.requirement-copy .eyebrow { font-size: 9px; margin-bottom: 4px; }
|
||||
.requirement-copy .eyebrow { font-size: var(--text-xs); margin-bottom: 4px; }
|
||||
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
|
||||
.remove:hover { color: var(--state-danger); }
|
||||
.editor { border-top: 1px solid var(--border-subtle); }
|
||||
.editor > summary { padding: 10px 16px; font-size: 11px; color: var(--accent-primary-hover); }
|
||||
.editor > summary { padding: 10px 16px; font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
.editor > summary > span { color: var(--text-secondary); margin-left: 10px; }
|
||||
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
|
||||
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
@@ -250,14 +255,14 @@
|
||||
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
|
||||
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
|
||||
.policy-settings { display: grid; gap: 8px; }
|
||||
.policy { border: 1px solid var(--border-subtle); border-radius: 9px; }
|
||||
.policy { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
|
||||
.policy small { display: block; font-size: 11px; color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: 11px; }
|
||||
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
|
||||
.policy-body.fields { display: flex; }
|
||||
summary { cursor: pointer; }
|
||||
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: 8px; }
|
||||
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
|
||||
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
|
||||
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
|
||||
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { coverageSummary } from '$lib/beta-plan-summary.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let evidenceOpen = $state(false);
|
||||
let evidenceLimit = $state(25);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
async function action(action: string, extra: Record<string, string> = {}) {
|
||||
@@ -17,23 +24,22 @@
|
||||
</script>
|
||||
|
||||
<section aria-label="Test plan progress">
|
||||
<div class="heading"><h2>Test plan</h2><strong class:failed={['failed', 'inconclusive'].includes(test.outcome)}>{test.outcome}</strong></div>
|
||||
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
|
||||
{#if test.reason}<p>{test.reason}</p>{/if}
|
||||
<p>Coverage: {test.plan.scope === 'quorum' ? `${test.plan.quorum} servers` : test.plan.scope}. Window: {test.plan.timeoutHours} hours.
|
||||
{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'}
|
||||
Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.</p>
|
||||
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}</p></details>
|
||||
{#if !closed}
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused'}<button disabled={busy} onclick={() => action('resume')}>Resume</button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('pause')}>Pause test</button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</button>{/if}
|
||||
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
|
||||
{#each test.servers as server (server.server)}
|
||||
<article>
|
||||
<h3>{server.server} <small>Recovery cycles: {server.phase}</small></h3>
|
||||
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
|
||||
<div class="requirement" class:failed={progress.failed}>
|
||||
@@ -47,19 +53,22 @@
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
<div class="actions">
|
||||
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
|
||||
<button disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</button>
|
||||
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
{/each}
|
||||
<details>
|
||||
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
|
||||
{#each test.evidence as event (event.id)}
|
||||
{#if evidenceOpen}
|
||||
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
|
||||
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
|
||||
{/each}
|
||||
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
|
||||
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -70,12 +79,12 @@
|
||||
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.heading { justify-content: space-between; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
.requirement { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
|
||||
input, button { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } button, summary { cursor: pointer; } button:disabled { opacity: .5; }
|
||||
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
|
||||
.failed { color: var(--state-danger); }
|
||||
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
||||
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
|
||||
@@ -63,7 +64,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="builder">
|
||||
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
|
||||
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same blueprint.</p>
|
||||
@@ -93,12 +94,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 16px; }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: 10px; }.number { font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||
@media (max-width: 620px) {
|
||||
.builder { padding: 20px; }
|
||||
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -30,7 +31,16 @@
|
||||
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
|
||||
} catch { /* Draft storage is optional. */ }
|
||||
});
|
||||
beforeNavigate(({ cancel }) => { if (dirty && !confirm('Leave this editor? Your draft is kept in this browser when storage is available.')) cancel(); });
|
||||
let allowNavigation = false;
|
||||
beforeNavigate(({ cancel, to, type }) => {
|
||||
if (!dirty || allowNavigation || type === 'leave' || !to) return;
|
||||
cancel();
|
||||
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
|
||||
if (!accepted) return;
|
||||
allowNavigation = true;
|
||||
try { await goto(to.url); } finally { allowNavigation = false; }
|
||||
});
|
||||
});
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
|
||||
$effect(() => {
|
||||
const version = selected;
|
||||
@@ -54,10 +64,11 @@
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
|
||||
});
|
||||
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
|
||||
function changeVersion(event: Event) {
|
||||
async function changeVersion(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; }
|
||||
clearDraft(); selected = target.value; success = '';
|
||||
const next = target.value; target.value = selected;
|
||||
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
|
||||
clearDraft(); selected = next; success = '';
|
||||
}
|
||||
function restoreDraft() {
|
||||
if (!recovered) return;
|
||||
@@ -65,7 +76,7 @@
|
||||
// recovered draft was started from a version that has since been deleted.
|
||||
text = recovered.text; notes = recovered.notes; recovered = null;
|
||||
}
|
||||
function discard() { if (confirm('Discard your unsaved changes?')) { text = original; notes = ''; clearDraft(); } }
|
||||
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -100,13 +111,13 @@
|
||||
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
|
||||
</div>
|
||||
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href="/betas">Open betas →</a></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
|
||||
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
|
||||
{#if loading}<p class="muted" role="status">Loading config contents…</p>
|
||||
{:else if loadedVersion}
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||
<div class="actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
||||
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { artifactId, draftText, scopeType, scopeValue }: { artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let servers = $state<ConfigServer[]>([]);
|
||||
let selected = $state('');
|
||||
let inspection = $state<ConfigInspection | null>(null);
|
||||
@@ -13,7 +14,7 @@
|
||||
let raw = $state(false);
|
||||
let reload = $state(0);
|
||||
let ready = $state(false);
|
||||
const eligible = $derived(servers.filter(s => !artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)));
|
||||
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
|
||||
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
|
||||
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
|
||||
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
|
||||
@@ -66,8 +67,8 @@
|
||||
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
|
||||
</div>
|
||||
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
|
||||
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each}
|
||||
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label>
|
||||
{#if error}<div class="error" role="alert">{error}</div>
|
||||
@@ -129,13 +130,13 @@
|
||||
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
strong { color: var(--accent-primary-hover); }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
|
||||
b { font-weight: 400; color: var(--state-warning); }
|
||||
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
|
||||
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
ol li.winner { color: var(--text-primary); }
|
||||
small { display: block; font-size: 10px; color: var(--text-secondary); margin-top: 3px; }
|
||||
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
|
||||
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import { mapLabel } from '$lib/script-library.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -22,7 +25,9 @@
|
||||
let game = $state('');
|
||||
let scope = $state('');
|
||||
let status = $state('');
|
||||
let view = $state<'library' | 'inspect'>('library');
|
||||
let view = $state('library');
|
||||
let limit = $state(25);
|
||||
$effect(() => { query; game; scope; status; limit = 25; });
|
||||
let dialog: HTMLDialogElement;
|
||||
let kind = $state<'baseline' | 'config'>('config');
|
||||
let name = $state('');
|
||||
@@ -94,15 +99,12 @@
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Configs" eyebrow="Fleet configuration" subtitle="Build on a baseline. Fine-tune with overlays. Know where every value comes from.">
|
||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="overview">
|
||||
<div><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></span></div>
|
||||
<div><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div>
|
||||
<div><span class="metric" class:pending={pending > 0}>{pending}</span><span>Awaiting promotion<small>Latest versions outside the promoted set</small></span></div>
|
||||
</div>
|
||||
<div class="navigation"><div class="view-switch" aria-label="Configs view"><button class:active={view === 'library'} aria-pressed={view === 'library'} onclick={() => view = 'library'}>Library</button><button class:active={view === 'inspect'} aria-pressed={view === 'inspect'} onclick={() => view = 'inspect'}>Inspect a server <span aria-hidden="true">↗</span></button></div><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh library</Button></div>
|
||||
<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>
|
||||
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
||||
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
{#if view === 'inspect'}
|
||||
<ConfigInspector />
|
||||
@@ -128,14 +130,16 @@
|
||||
{/if}
|
||||
{#if filteredOverlays.length}
|
||||
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
|
||||
<div class="overlay-list">{#each filteredOverlays as artifact (artifact.id)}
|
||||
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_value}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
|
||||
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
|
||||
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
|
||||
{/each}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
|
||||
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet versionBadges(artifact: Artifact)}
|
||||
@@ -163,14 +167,11 @@
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.overview > div { display: flex; align-items: center; gap: 18px; padding: 24px; } .overview > div + div { border-left: 1px solid var(--border-subtle); }
|
||||
.metric { font-size: 32px; font-weight: 600; letter-spacing: -.04em; } .metric.pending { color: var(--state-warning); }
|
||||
.overview > div > span:last-child { font-size: var(--text-sm); } small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 5px; }
|
||||
.navigation, .view-switch, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
|
||||
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
|
||||
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
|
||||
.view-switch { gap: 4px; }
|
||||
|
||||
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
@@ -190,7 +191,7 @@
|
||||
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
|
||||
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
|
||||
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
|
||||
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-md); background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
|
||||
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
|
||||
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
||||
@@ -210,6 +211,11 @@
|
||||
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
|
||||
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
@media (max-width: 900px) { .overview > div { padding: 16px; gap: 12px; } .overview small { display: none; } .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
|
||||
@media (max-width: 600px) { .overview { grid-template-columns: 1fr; } .overview > div + div { border-left: 0; border-top: 1px solid var(--border-subtle); } .overview > div { padding: 12px 16px; } .metric { font-size: 24px; min-width: 30px; } .overview small { display: block; } .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
|
||||
@media (max-width: 900px) { .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
|
||||
@media (max-width: 600px) { .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
|
||||
.config-panel { display:grid; gap:20px; }
|
||||
.baseline-card { padding:16px; border-radius:var(--radius-md); background:var(--surface); }
|
||||
.card-footer { padding-top:12px; margin-top:12px; }
|
||||
.welcome { padding:24px; }
|
||||
.loading { padding:24px 0; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||
code { font: var(--text-sm) var(--font-mono); }
|
||||
small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
|
||||
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
|
||||
summary { cursor: pointer; }
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { toast } from '../toast.js';
|
||||
let { value, label = 'ID' }: { value: string; label?: string } = $props();
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(value); toast(`${label} copied.`, { variant: 'success' }); }
|
||||
catch { toast('Copy unavailable. Select and copy the text directly.', { variant: 'warning' }); }
|
||||
}
|
||||
</script>
|
||||
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button></div>
|
||||
<style>.copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">Your lineup, ready to launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
@@ -80,9 +81,9 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: 11px; }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: 11px; }
|
||||
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
@@ -103,7 +104,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
|
||||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
||||
@@ -144,19 +145,19 @@
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
|
||||
<style>
|
||||
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
|
||||
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
.eyebrow { font-size: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
||||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
||||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
||||
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 8px; padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: 11px; }
|
||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
||||
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
||||
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
|
||||
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: 8px; font-size: 13px; }.success { color: var(--accent-primary-hover); }
|
||||
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); }
|
||||
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
|
||||
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: 10px; } h2 { font-size: 22px; } }
|
||||
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Destroy container?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
||||
@@ -47,12 +47,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { line-height: 1.6; color: var(--text-secondary); }
|
||||
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: 8px; line-height: 1.6; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
|
||||
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
|
||||
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
|
||||
@@ -44,9 +45,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="House Party Protocol" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="content">
|
||||
<header><span class="eyebrow">Call it a night</span><h2>House Party Protocol</h2></header>
|
||||
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</h2></header>
|
||||
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
@@ -63,12 +64,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
||||
.eyebrow { display: block; color: var(--state-danger); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
||||
.eyebrow { display: block; color: var(--state-danger); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: 8px; }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
||||
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
<style>
|
||||
.recipe { display: grid; gap: 16px; min-width: 0; }
|
||||
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
|
||||
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
|
||||
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.mono, code, .fingerprint { font-family: var(--font-mono); }
|
||||
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
|
||||
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: 11px; } li span, small { color: var(--text-secondary); }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
@@ -32,27 +36,33 @@
|
||||
// Default to the first tab once props are populated; don't overwrite
|
||||
// the user's later selection.
|
||||
$effect(() => {
|
||||
if (!active && tabs.length > 0) active = tabs[0].key;
|
||||
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
|
||||
});
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let refreshError = $state('');
|
||||
const pending = new Set<string>();
|
||||
let bodyEl = $state<HTMLPreElement | null>(null);
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
async function fetchLog(key: string): Promise<void> {
|
||||
loading = true;
|
||||
const currentServer = server;
|
||||
const requestKey = currentServer + ':' + key;
|
||||
if (pending.has(requestKey)) return;
|
||||
pending.add(requestKey); loading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
|
||||
);
|
||||
const body = (await res.json()) as LogResponse;
|
||||
data = { ...data, [key]: body };
|
||||
if (!res.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
|
||||
if (server === currentServer) { data = { ...data, [key]: body }; if (key === active) refreshError = ''; }
|
||||
} catch (err) {
|
||||
data = { ...data, [key]: { ok: false, reason: (err as Error).message } };
|
||||
if (server === currentServer && key === active) refreshError = (err as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
if (stickToBottom) {
|
||||
pending.delete(requestKey);
|
||||
if (server === currentServer && key === active) loading = false;
|
||||
if (stickToBottom && server === currentServer && key === active) {
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
@@ -67,37 +77,18 @@
|
||||
stickToBottom = nearBottom;
|
||||
}
|
||||
|
||||
function switchTab(key: string): void {
|
||||
active = key;
|
||||
if (!data[key]) void fetchLog(key);
|
||||
}
|
||||
|
||||
function schedulePoll(): void {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (autoRefresh) {
|
||||
pollTimer = setInterval(() => {
|
||||
if (active) void fetchLog(active);
|
||||
}, pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAuto(): void {
|
||||
autoRefresh = !autoRefresh;
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
function toggleAuto() { autoRefresh = !autoRefresh; }
|
||||
$effect(() => { server; data = {}; refreshError = ''; });
|
||||
$effect(() => {
|
||||
if (active && !data[active]) void fetchLog(active);
|
||||
});
|
||||
|
||||
// Reschedule when autoRefresh or active changes
|
||||
$effect(() => {
|
||||
schedulePoll();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
server;
|
||||
const key = active;
|
||||
const auto = autoRefresh;
|
||||
const interval = pollMs;
|
||||
if (!key) return;
|
||||
return untrack(() => {
|
||||
if (auto) return pollVisible(() => fetchLog(key), interval);
|
||||
void fetchLog(key);
|
||||
});
|
||||
});
|
||||
|
||||
function ago(iso: string | null | undefined): string {
|
||||
@@ -122,11 +113,7 @@
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="tabs">
|
||||
{#each tabs as t (t.key)}
|
||||
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||
@@ -141,18 +128,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !current}
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">loading…</div>
|
||||
{:else if !current.ok}
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current.missing}
|
||||
{:else if current?.missing}
|
||||
<div class="empty">
|
||||
log file not written yet
|
||||
{#if current.path}<br /><code>{current.path}</code>{/if}
|
||||
</div>
|
||||
{:else if !current.lines || current.lines.length === 0}
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else}
|
||||
{:else if current?.lines}
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
|
||||
{#if current.truncated}
|
||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||
@@ -160,6 +149,7 @@
|
||||
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -176,24 +166,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
color: var(--ink-300);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm) var(--r-sm) 0 0;
|
||||
padding: 8px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--cream); background: var(--bg-card); }
|
||||
.tab.on {
|
||||
color: var(--cream);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
border-bottom-color: var(--bg-card);
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
|
||||
let limit = $state(25);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -17,8 +19,9 @@
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if events.length}
|
||||
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
|
||||
<ol class="timeline">
|
||||
{#each events as event (event.id)}
|
||||
{#each events.slice(-limit) as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -34,7 +37,7 @@
|
||||
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
|
||||
.flow .done { color: var(--text-primary); }
|
||||
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import RoundTelemetry from './RoundTelemetry.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { mapTitle, serverTitle } from '$ui/server-labels.js';
|
||||
import { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Stat from './primitives/Stat.svelte';
|
||||
@@ -71,6 +72,7 @@
|
||||
let showRoster = $derived(health === 'online' || health === 'stale');
|
||||
let title = $derived(serverTitle(name));
|
||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
|
||||
let gtLabel = $derived(gametypeLabel(gametype));
|
||||
|
||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
||||
@@ -101,7 +103,7 @@
|
||||
<div class="head">
|
||||
<div class="name-row">
|
||||
<StatusPip {health} />
|
||||
<strong class="name" title={name}>{title}</strong>
|
||||
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
||||
</div>
|
||||
{#if health === 'starting'}
|
||||
<Badge variant="info" uppercase>Starting</Badge>
|
||||
@@ -119,13 +121,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="map">{mapLabel}</span>
|
||||
{#if gtLabel}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="gt">{gtLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
||||
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
||||
|
||||
@@ -194,7 +190,7 @@
|
||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||
}
|
||||
:global(.card.offline) {
|
||||
opacity: 0.62;
|
||||
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.restart-bar {
|
||||
@@ -232,9 +228,7 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.players { flex-shrink: 0; }
|
||||
|
||||
@@ -249,9 +243,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
|
||||
.gt { color: var(--text-tertiary); }
|
||||
.dot { color: var(--text-quaternary); }
|
||||
|
||||
.timing {
|
||||
display: flex;
|
||||
@@ -265,7 +258,7 @@
|
||||
gap: 2px;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { serverIdentity } from '../server-labels.js';
|
||||
let { name, mapLabel, showGame = true }: { name: string; mapLabel?: string; showGame?: boolean } = $props();
|
||||
const identity = $derived(serverIdentity(name));
|
||||
</script>
|
||||
<span class="identity" title={name}><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span class="lobby">Lobby {identity.lobby}</span>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span>
|
||||
<style>
|
||||
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
|
||||
.map { overflow-wrap: anywhere; }
|
||||
.lobby { white-space: nowrap; font-size: .85em; color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
|
||||
</style>
|
||||
@@ -45,8 +45,9 @@
|
||||
aria-label={ariaLabel}
|
||||
{title}
|
||||
href={disabled || loading ? undefined : href}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex={disabled || loading ? -1 : undefined}
|
||||
aria-busy={loading}
|
||||
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
|
||||
>
|
||||
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
|
||||
<span class="label">{@render children?.()}</span>
|
||||
@@ -60,6 +61,7 @@
|
||||
{type}
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
@@ -81,7 +83,8 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
line-height: 1;
|
||||
line-height: 1.35;
|
||||
min-height: 40px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
@@ -105,7 +108,7 @@
|
||||
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
|
||||
|
||||
/* Sizes */
|
||||
.s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
|
||||
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
{tabindex}
|
||||
{style}
|
||||
{onclick}
|
||||
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -56,7 +57,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 90%, transparent);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
@@ -77,12 +79,6 @@
|
||||
|
||||
/* Backdrop blur only where the browser supports it — some rendering
|
||||
backends downgrade poorly, and we don't want blurred noise. */
|
||||
@supports (backdrop-filter: blur(4px)) {
|
||||
.card {
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Padding scale */
|
||||
.p-none { padding: 0; }
|
||||
@@ -91,23 +87,21 @@
|
||||
.p-lg { padding: var(--space-6); }
|
||||
|
||||
/* Elevation */
|
||||
.e-flat { box-shadow: var(--shadow-ambient); }
|
||||
.e-flat { box-shadow: none; }
|
||||
.e-raised { box-shadow: var(--shadow-raised); }
|
||||
|
||||
.e-interactive {
|
||||
box-shadow: var(--shadow-ambient);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.e-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-raised);
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
|
||||
}
|
||||
.e-interactive:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring), var(--shadow-raised);
|
||||
}
|
||||
.e-interactive:active { transform: translateY(-1px); }
|
||||
@media (max-width: 480px) {
|
||||
.p-md, .p-lg { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
|
||||
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
|
||||
const id = $props.id();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
function close() { if (!busy) open = false; }
|
||||
async function closed() {
|
||||
open = false;
|
||||
await tick();
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
|
||||
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
|
||||
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
|
||||
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
|
||||
<div class="body">{@render children?.()}</div>
|
||||
{#if actions}<footer>{@render actions()}</footer>{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
|
||||
dialog.wide { width: min(880px, calc(100vw - 32px)); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.body { padding: var(--space-6); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
|
||||
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
|
||||
</style>
|
||||
@@ -6,20 +6,15 @@
|
||||
description?: string | null;
|
||||
icon?: Snippet;
|
||||
actions?: Snippet;
|
||||
spacious?: boolean;
|
||||
}
|
||||
|
||||
let { title, description = null, icon, actions }: Props = $props();
|
||||
let { title, description = null, icon, actions, spacious = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty" role="status">
|
||||
<div class="empty" class:spacious role="status">
|
||||
{#if icon}
|
||||
<div class="icon">{@render icon()}</div>
|
||||
{:else}
|
||||
<div class="icon default">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="title">{title}</h3>
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
@@ -32,12 +27,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
padding: var(--space-6);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
.spacious { padding: var(--space-12) var(--space-6); }
|
||||
.icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
@@ -11,22 +12,28 @@
|
||||
}
|
||||
|
||||
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
|
||||
const id = $props.id();
|
||||
setContext<FieldContext>(fieldContext, {
|
||||
get labelId() { return `${id}-label`; },
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
{#if label}
|
||||
<label class="label" for={forId}>
|
||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
||||
{label}
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
</label>
|
||||
</svelte:element>
|
||||
{/if}
|
||||
<div class="control" class:has-error={!!error}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="msg msg-error" role="alert">{error}</div>
|
||||
<div id={`${id}-message`} class="msg msg-error" role="alert">{error}</div>
|
||||
{:else if hint}
|
||||
<div class="msg msg-hint">{hint}</div>
|
||||
<div id={`${id}-message`} class="msg msg-hint">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -40,9 +47,7 @@
|
||||
.label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.req { color: var(--state-danger); margin-left: 3px; }
|
||||
.control { display: flex; flex-direction: column; }
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
|
||||
@@ -13,6 +16,7 @@
|
||||
onchange?: (e: Event) => void;
|
||||
oninput?: (e: Event) => void;
|
||||
accept?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -28,7 +32,8 @@
|
||||
mono = false,
|
||||
onchange,
|
||||
oninput,
|
||||
accept
|
||||
accept,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -44,6 +49,10 @@
|
||||
{readonly}
|
||||
{autocomplete}
|
||||
{accept}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
{oninput}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
|
||||
</script>
|
||||
{#if total > step}
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
{/if}
|
||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||
@@ -37,7 +37,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-8);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.crumbs {
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -14,6 +17,7 @@
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +28,8 @@
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
onchange
|
||||
onchange,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -35,6 +40,10 @@
|
||||
{name}
|
||||
{required}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Badge from './Badge.svelte';
|
||||
import { statusPresentation } from '../../status.js';
|
||||
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
|
||||
const status = $derived(statusPresentation(state));
|
||||
</script>
|
||||
<Badge variant={status.tone} {size}>{status.label}</Badge>
|
||||
@@ -12,9 +12,24 @@
|
||||
onchange?: (id: string) => void;
|
||||
ariaLabel?: string;
|
||||
size?: 'sm' | 'md';
|
||||
panelId?: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
|
||||
const uid = $props.id();
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const enabled = tabs.filter(t => !t.disabled);
|
||||
if (!enabled.length) return;
|
||||
const index = enabled.findIndex(t => t.id === value);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
|
||||
select(enabled[next].id, false);
|
||||
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
|
||||
}
|
||||
|
||||
function select(id: string, disabled: boolean | undefined) {
|
||||
if (disabled) return;
|
||||
@@ -30,7 +45,11 @@
|
||||
class="tab"
|
||||
class:active={value === t.id}
|
||||
role="tab"
|
||||
id={`${idPrefix}-${t.id}`}
|
||||
aria-selected={value === t.id}
|
||||
aria-controls={panelId}
|
||||
tabindex={value === t.id ? 0 : -1}
|
||||
onkeydown={onKey}
|
||||
disabled={t.disabled}
|
||||
onclick={() => select(t.id, t.disabled)}
|
||||
>
|
||||
@@ -52,7 +71,7 @@
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: color-mix(in oklab, var(--surface) 80%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tab {
|
||||
@@ -68,7 +87,7 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
background-color var(--dur-fast) var(--ease-standard);
|
||||
@@ -97,7 +116,7 @@
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.tab.active .badge {
|
||||
background: var(--accent-primary);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
@@ -10,6 +13,7 @@
|
||||
readonly?: boolean;
|
||||
mono?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,7 +26,8 @@
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
mono = false,
|
||||
oninput
|
||||
oninput,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -36,6 +41,10 @@
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{oninput}
|
||||
></textarea>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const fieldContext = Symbol('field');
|
||||
export interface FieldContext {
|
||||
readonly labelId: string;
|
||||
readonly messageId: string | undefined;
|
||||
readonly invalid: boolean;
|
||||
}
|
||||
@@ -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); };
|
||||
}
|
||||
@@ -13,3 +13,13 @@ export function serverTitle(name: string): string {
|
||||
const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
|
||||
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
|
||||
}
|
||||
|
||||
export function serverIdentity(name: string): { map: string; lobby: number | null; game: string } {
|
||||
const parsed = parseServerName(name);
|
||||
if (!parsed || !['t5', 't6'].includes(parsed.game.toLowerCase()) || !parsed.mapCanonical || (parsed.gameMode && parsed.gameMode !== 'zombie')) return { map: name, lobby: null, game: '' };
|
||||
return {
|
||||
map: zombieMaps(parsed.game).find(entry => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical),
|
||||
lobby: parsed.lobby,
|
||||
game: parsed.game === 't6' ? 'Black Ops II' : 'Black Ops I'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
|
||||
});
|
||||
export const fleetError = writable('');
|
||||
export const fleetRefreshing = writable(false);
|
||||
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||
export const lobbyWatches = writable<LobbyWatch[]>([]);
|
||||
|
||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
|
||||
@@ -17,7 +18,7 @@ export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], (
|
||||
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
|
||||
return status ? { ...server, last_players: status.players, last_map: status.map,
|
||||
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
|
||||
roster: status.roster, health: status.health, active_since: status.activeSince,
|
||||
roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
|
||||
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
|
||||
game_ended_at: status.gameEndedAt } : server;
|
||||
}));
|
||||
@@ -39,6 +40,7 @@ export function refreshFleet(): Promise<void> {
|
||||
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.');
|
||||
const [servers, operations] = await Promise.all(responses.map(response => response.json()));
|
||||
fleetError.set('');
|
||||
fleetIntegrations.set(servers.integrations);
|
||||
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
|
||||
} catch (error) {
|
||||
fleetError.set(error instanceof Error && error.name !== 'AbortError'
|
||||
@@ -79,7 +81,9 @@ export function startFleetMonitor(): () => void {
|
||||
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
|
||||
});
|
||||
void refreshFleet();
|
||||
const interval = setInterval(() => void refreshFleet(), 10_000);
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet();
|
||||
}, 10_000);
|
||||
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
|
||||
document.addEventListener('visibilitychange', onFocus);
|
||||
const check = () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
|
||||
});
|
||||
}
|
||||
|
||||
export function startPipelineStream(): void {
|
||||
export function startPipelineStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
source = new EventSource('/api/events/pipeline');
|
||||
|
||||
@@ -49,4 +49,5 @@ export function startPipelineStream(): void {
|
||||
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
|
||||
});
|
||||
});
|
||||
return () => { source?.close(); source = null; };
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let source: EventSource | null = null;
|
||||
|
||||
export function startServerStream(): void {
|
||||
export function startServerStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
source = new EventSource('/api/events/servers');
|
||||
source.addEventListener('server-telemetry', (evt) => {
|
||||
@@ -73,4 +73,5 @@ export function startServerStream(): void {
|
||||
if (tickInterval === null) {
|
||||
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
|
||||
}
|
||||
return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; };
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
--blue-950: #060b16;
|
||||
--blue-900: #0d1826;
|
||||
--blue-700: #1c3d6b;
|
||||
--blue-500: #2e86d1;
|
||||
--blue-500: #4da3e6;
|
||||
--blue-300: #7cc0f5;
|
||||
|
||||
/* Neutral gray — cool-tinted, distinct from the blue ramp so surfaces
|
||||
@@ -62,9 +62,9 @@
|
||||
/* Text — four steps of hierarchy on dark. */
|
||||
--text-primary: var(--gray-050);
|
||||
--text-secondary: var(--gray-300);
|
||||
--text-tertiary: var(--gray-500);
|
||||
--text-quaternary: var(--gray-700);
|
||||
--text-on-accent: #ffffff;
|
||||
--text-tertiary: #98a4b8;
|
||||
--text-quaternary: var(--gray-400);
|
||||
--text-on-accent: #08111f;
|
||||
|
||||
/* Border — hairlines, no more. */
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
@@ -74,7 +74,7 @@
|
||||
/* Accent — one color, one voice. */
|
||||
--accent-primary: var(--blue-500);
|
||||
--accent-primary-hover: var(--blue-300);
|
||||
--accent-primary-active: #256cae;
|
||||
--accent-primary-active: #398fcf;
|
||||
|
||||
/* Typography — Inter everywhere, mono for data.
|
||||
Display face lives inside BrandMark; no global --font-display token. */
|
||||
|
||||
+34
-16
@@ -7,6 +7,9 @@
|
||||
import { page } from '$app/stores';
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Toaster from '$ui/components/Toaster.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
// Side-effect import — puts @fontsource CSS on Vite's module graph so
|
||||
// the referenced woff/woff2 files get emitted into the build.
|
||||
@@ -17,7 +20,7 @@
|
||||
let menuOpen = $state(false);
|
||||
let menuButton: HTMLButtonElement;
|
||||
|
||||
afterNavigate(() => { menuOpen = false; });
|
||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
@@ -27,8 +30,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startPipelineStream();
|
||||
startServerStream();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
const stopFleetMonitor = startFleetMonitor();
|
||||
|
||||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
|
||||
@@ -42,7 +45,7 @@
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
if (!armed && e.key === 'g') {
|
||||
@@ -64,6 +67,8 @@
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
stopServers?.();
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (armTimer) clearTimeout(armTimer);
|
||||
};
|
||||
@@ -74,20 +79,23 @@
|
||||
onNavigate(enableViewTransitions);
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' },
|
||||
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
|
||||
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
|
||||
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
|
||||
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
|
||||
{ href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
|
||||
{ href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
|
||||
];
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
|
||||
<div class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<nav aria-label="Primary">
|
||||
<a class="brand-link" href="/" aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
@@ -112,10 +120,12 @@
|
||||
</div>
|
||||
<div class="strut" aria-hidden="true"></div>
|
||||
</nav>
|
||||
<main>
|
||||
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||
<main id="main-content" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Toaster />
|
||||
<Confirmation />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -140,7 +150,8 @@
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
:global(:focus-visible) {
|
||||
outline: none;
|
||||
outline: 2px solid var(--accent-primary-hover);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Ambient background — a faint radial gradient hints at depth without
|
||||
@@ -223,14 +234,14 @@
|
||||
main {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-10) var(--space-10) var(--space-24);
|
||||
padding: var(--space-6) var(--space-8) var(--space-16);
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
view-transition-name: main;
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 1000px) {
|
||||
@media (max-width: 800px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
@@ -271,6 +282,13 @@
|
||||
|
||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
||||
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
|
||||
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
|
||||
.subnav a { text-decoration: none; white-space: nowrap; font-size: var(--text-sm); color: var(--text-secondary); padding: var(--space-3) 0; border-bottom: 2px solid transparent; }
|
||||
.subnav a[aria-current] { color: var(--text-primary); border-bottom-color: var(--accent-primary); }
|
||||
@media (max-width: 720px) { .subnav > div { padding-inline: var(--space-4); } }
|
||||
|
||||
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
|
||||
:global(::view-transition-old(root)),
|
||||
|
||||
+51
-40
@@ -3,7 +3,7 @@
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
||||
@@ -22,6 +22,7 @@
|
||||
let view = $state<'cards' | 'table'>('cards');
|
||||
let grouping = $state<'map' | 'game' | 'none'>('none');
|
||||
let operationsOpen = $state(false);
|
||||
let viewOptionsOpen = $state(false);
|
||||
let pinned = $state<string[]>([]);
|
||||
let preferencesReady = $state(false);
|
||||
let deployOpen = $state(false);
|
||||
@@ -110,10 +111,10 @@
|
||||
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<SectionHeader title="Fleet" subtitle="Your lobbies. Every player. The next move.">
|
||||
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
|
||||
<Button variant="primary" onclick={() => launch()}>Deploy new server</Button>
|
||||
<Button variant="primary" onclick={() => launch()}>Create server</Button>
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
|
||||
@@ -126,21 +127,21 @@
|
||||
{#if !$fleet.receivedAt}
|
||||
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
||||
{:else}
|
||||
<div class="overview">
|
||||
<div><Stat label="Players online" value={players} size="lg" tone={players > 0 ? 'online' : 'primary'} tween /><span class="hint">From fresh server reports</span></div>
|
||||
<div><Stat label="Healthy servers" value={`${healthy.length}/${servers.length}`} size="lg" tween={false} /><span class="hint">{starting ? `${starting} starting` : 'Live fleet status'}</span></div>
|
||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><Stat label="Empty lobbies" value={empty} size="lg" tween /><span class="hint">Find an open lobby →</span></button>
|
||||
<a class="metric-link" href="/campaigns"><Stat label="Active campaigns" value={campaigns} size="lg" tween /><span class="hint">View progress →</span></a>
|
||||
<div class="overview" aria-label="Fleet summary">
|
||||
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
||||
<a class="metric-link" href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||
</div>
|
||||
|
||||
{#if servers.length || activeOperations.length}
|
||||
{#if problems.length || attentionOperations.length || starting}
|
||||
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
||||
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
|
||||
<div class="attention-copy">
|
||||
{#if problems.length}
|
||||
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button>
|
||||
{:else}<strong>{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}</strong>{/if}
|
||||
<span>{problems.length ? 'Open a server to inspect its connection and diagnostics.' : 'Live health, match reports, and operation progress in one place.'}</span>
|
||||
|
||||
</div>
|
||||
{#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if}
|
||||
</div>
|
||||
@@ -154,6 +155,9 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if activeOperations.length || $lobbyWatches.length}
|
||||
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
|
||||
<summary>Activity · {activeOperations.length} operations{ $lobbyWatches.length ? ` · ${$lobbyWatches.length} lobby watches` : ''}{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
|
||||
{#if $lobbyWatches.length}
|
||||
<div class="watches" aria-label="Lobby watches">
|
||||
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
|
||||
@@ -163,9 +167,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeOperations.length}
|
||||
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
|
||||
<summary>{activeOperations.length} server {activeOperations.length === 1 ? 'operation' : 'operations'} in progress{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
|
||||
<div class="operation-list">{#each activeOperations as operation (operation.key)}
|
||||
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
|
||||
{/each}</div>
|
||||
@@ -177,24 +178,28 @@
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
<label class="search"><span>Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search players, maps, or server names…" /></label>
|
||||
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">T5 · Black Ops</option><option value="t6">T6 · Black Ops II</option></select></label>
|
||||
<label class="search"><span class="sr-only">Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search lobbies, maps, or players…" /></label>
|
||||
<button class="view-options-toggle" aria-expanded={viewOptionsOpen} aria-controls="fleet-view-options" onclick={() => viewOptionsOpen = !viewOptionsOpen}>View options{game ? ' · filtered' : ''}</button>
|
||||
</div>
|
||||
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
|
||||
<label>Game<select bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Group by<select bind:value={grouping}><option value="map">Map</option><option value="game">Game</option><option value="none">None</option></select></label>
|
||||
<label>Sort by<select bind:value={sort}><option value="name">Lobby name</option><option value="players">Most players</option><option value="round">Highest round</option><option value="duration">Longest match</option></select></label>
|
||||
</div>
|
||||
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
|
||||
</div>{/if}
|
||||
<div class="filter-row">
|
||||
<div class="chips" aria-label="Filter servers">{#each filters as item}
|
||||
<button class:selected={filter === item.id} aria-pressed={filter === item.id} onclick={() => filter = item.id}>{item.label}<span>{servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span></button>
|
||||
{/each}</div>
|
||||
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
|
||||
|
||||
</div>
|
||||
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}<span class="hint">Pinned lobbies appear first in each group.</span></div>
|
||||
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}</div>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
|
||||
{#each groups as [key, group] (key)}
|
||||
<section class="map-group" aria-label={group.title}>
|
||||
<div class="group-heading"><div><span class="eyebrow">{grouping === 'map' ? group.game.toUpperCase() : 'Fleet lineup'}</span><h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0)} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||
<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
||||
</div>
|
||||
{#if view === 'cards'}
|
||||
@@ -214,7 +219,7 @@
|
||||
<div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody>
|
||||
{#each group.servers as server (server.name)}
|
||||
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)}
|
||||
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}">{serverTitle(server.name)}</a><small>{fleetIdentity(server).map}</small></td>
|
||||
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}"><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td>
|
||||
<td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td>
|
||||
<td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td>
|
||||
<td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
|
||||
@@ -247,48 +252,52 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: 24px; }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
||||
.overview { display: grid; grid-template-columns: repeat(4, 1fr); padding: 24px; gap: 24px; background: var(--surface); border-radius: 16px; border: 1px solid var(--border-subtle); }
|
||||
.overview { display: flex; align-items: center; flex-wrap: wrap; padding: 12px 16px; gap: 8px 24px; background: var(--surface); border-radius: var(--radius-md); border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.overview strong { color: var(--text-primary); font-variant-numeric: tabular-nums; margin-right: 4px; }
|
||||
.view-options { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius-md); }
|
||||
.view-options label { flex: 1 1 140px; }
|
||||
.view-options-toggle { background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); padding: 8px 12px; font-size: var(--text-sm); }
|
||||
.overview > * { min-width: 0; }
|
||||
.metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 0; }
|
||||
.metric-link:hover .hint { color: var(--accent-primary-hover); }
|
||||
|
||||
.hint { display: block; color: var(--text-secondary); font-size: 12px; margin-top: 6px; }
|
||||
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
|
||||
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.attention-mark { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; background: var(--surface-raised); color: var(--state-online); }
|
||||
.has-issues { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
|
||||
.has-issues .attention-mark { color: var(--state-warning); }
|
||||
.attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
||||
.attention-copy span { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.attention button, .result-count button, .error button { background: none; color: var(--accent-primary-hover); border: 0; padding: 0; text-align: left; }
|
||||
.attention a { font-size: 13px; color: var(--state-warning); }
|
||||
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 20px 24px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: 14px; color: var(--text-primary); text-decoration: none; }
|
||||
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 12px 16px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); color: var(--text-primary); text-decoration: none; }
|
||||
.spotlight > div { flex: 1; min-width: 190px; }
|
||||
.spotlight strong { display: block; margin-top: 6px; }
|
||||
.spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
|
||||
.round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); }
|
||||
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: 10px; letter-spacing: .12em; font-weight: 600; }
|
||||
.toolbar { display: flex; flex-direction: column; gap: 18px; }
|
||||
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: var(--text-xs); letter-spacing: .12em; font-weight: 600; }
|
||||
.toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
|
||||
.search { flex: 1; min-width: min(280px, 100%); }
|
||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||
input::placeholder { color: var(--text-secondary); }
|
||||
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 8px; padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
|
||||
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
|
||||
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
|
||||
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
|
||||
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
|
||||
.result-count .hint { margin: 0 0 0 auto; }
|
||||
|
||||
.map-group { display: flex; flex-direction: column; gap: 16px; }
|
||||
.group-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
|
||||
.group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); gap: 16px; align-items: start; }
|
||||
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: 14px; overflow: hidden; background: var(--surface); }
|
||||
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
|
||||
.server-tile.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
|
||||
.server-tile :global(.card) { border-radius: 0; }
|
||||
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
|
||||
@@ -298,27 +307,29 @@
|
||||
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
|
||||
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
|
||||
.server-operations .issue, .operation-list .issue { color: var(--state-warning); }
|
||||
.operations { border: 1px solid var(--border-subtle); padding: 16px 20px; border-radius: 12px; font-size: 13px; }
|
||||
.operations { border: 1px solid var(--border-subtle); padding: 16px 20px; border-radius: var(--radius-md); font-size: 13px; }
|
||||
summary { cursor: pointer; }
|
||||
.operation-list { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: 8px; color: var(--text-primary); }
|
||||
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
|
||||
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.watches { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.watches { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.watches > div { margin-right: auto; }
|
||||
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: 12px; font-size: 13px; line-height: 1.7; }
|
||||
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
|
||||
.error button { margin-left: 8px; }
|
||||
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: 16px; }
|
||||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: 12px; }
|
||||
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
|
||||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; text-align: left; }
|
||||
th { color: var(--text-secondary); font-weight: 500; white-space: nowrap; font-size: 12px; }
|
||||
th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td small { display: block; color: var(--text-secondary); margin-top: 6px; }
|
||||
td .server-operations { padding: 0; min-width: 180px; }
|
||||
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; white-space: nowrap; }
|
||||
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; }
|
||||
.health { display: flex; align-items: center; gap: 8px; text-transform: capitalize; }
|
||||
.table-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, 1fr); } .attention { flex-wrap: wrap; } }
|
||||
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .result-count .hint { width: 100%; margin: 0; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } .operation-list a { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } .operation-list a { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .chips button { white-space: nowrap; flex-shrink: 0; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const activeOnly = url.searchParams.get('active') === 'true';
|
||||
const server = url.searchParams.get('server');
|
||||
|
||||
const deployments = db()
|
||||
.prepare(
|
||||
@@ -14,10 +15,11 @@ export async function GET({ url }: { url: URL }) {
|
||||
LEFT JOIN scripts s ON s.id = d.script_id
|
||||
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
|
||||
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
|
||||
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id = d.id AND target.server = ?))
|
||||
ORDER BY d.started_at DESC
|
||||
LIMIT 500`
|
||||
)
|
||||
.all() as Array<{
|
||||
.all(server, server) as Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
started_at: string;
|
||||
@@ -33,12 +35,15 @@ export async function GET({ url }: { url: URL }) {
|
||||
prior_version: number | null;
|
||||
}>;
|
||||
|
||||
const ids = deployments.map(deployment => deployment.id);
|
||||
if (!ids.length) return json({ deployments: [] });
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const serversByDep = db()
|
||||
.prepare(
|
||||
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
|
||||
FROM deployment_servers`
|
||||
FROM deployment_servers WHERE deployment_id IN (${placeholders})`
|
||||
)
|
||||
.all() as Array<{
|
||||
.all(...ids) as Array<{
|
||||
deployment_id: string;
|
||||
server: string;
|
||||
files_changed: number;
|
||||
@@ -64,9 +69,10 @@ export async function GET({ url }: { url: URL }) {
|
||||
FROM deployment_scripts ds
|
||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id`
|
||||
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id
|
||||
WHERE ds.deployment_id IN (${placeholders})`
|
||||
)
|
||||
.all() as Array<{
|
||||
.all(...ids) as Array<{
|
||||
deployment_id: string;
|
||||
script_id: number;
|
||||
script_name: string | null;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { listServerScripts } from '$lib/server/fleet/inventory.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
const server = getServer(params.name);
|
||||
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
const metadata = db().prepare(`SELECT i.rel_path, s.kind FROM server_scripts i
|
||||
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
|
||||
WHERE i.server = ?`).all(params.name) as Array<{ rel_path: string; kind: string }>;
|
||||
const kinds = new Map(metadata.map(row => [row.rel_path, row.kind]));
|
||||
const scripts = listServerScripts(params.name).map((r) => ({
|
||||
relPath: r.rel_path,
|
||||
kind: kinds.get(r.rel_path) ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
|
||||
scriptVersionId: r.script_version_id,
|
||||
sha256: r.sha256,
|
||||
size: r.size,
|
||||
|
||||
+42
-490
@@ -1,17 +1,14 @@
|
||||
<script lang="ts">
|
||||
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
|
||||
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { parseBetaSelection } from '$lib/script-library.js';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
interface BetaListRow {
|
||||
id: number;
|
||||
@@ -27,495 +24,50 @@
|
||||
}>;
|
||||
servers: Array<{ server: string; stage: string; error: string | null }>;
|
||||
}
|
||||
interface ScriptListRow {
|
||||
id: number; name: string; target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
||||
current_version_id: number | null; kind: ArtifactKind;
|
||||
latestVersion: {
|
||||
id: number; version: number; sha256: string;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
} | null;
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
|
||||
interface ServerRow { name: string; game: string | null; map: string | null; }
|
||||
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
|
||||
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
|
||||
|
||||
let betas = $state<BetaListRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let scripts = $state<ScriptListRow[]>([]);
|
||||
let fleetServers = $state<ServerRow[]>([]);
|
||||
let detailById = $state<Record<number, ScriptDetail>>({});
|
||||
let nextKey = 0;
|
||||
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
|
||||
let pickedServers = $state<Set<string>>(new Set());
|
||||
let testPlan = $state(defaultPlan());
|
||||
let waitForHealth = $state(true);
|
||||
let waitForMatch = $state(false);
|
||||
let openingBeta = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [betasRes, scriptsRes, serversRes] = await Promise.all([
|
||||
fetch('/api/betas'),
|
||||
fetch('/api/scripts'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
if (!betasRes.ok || !scriptsRes.ok || !serversRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
|
||||
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
|
||||
betas = bBody.betas;
|
||||
if (scriptsRes.ok) {
|
||||
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
|
||||
scripts = sBody.scripts;
|
||||
}
|
||||
if (serversRes.ok) {
|
||||
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
fleetServers = svBody.servers;
|
||||
}
|
||||
let error = $state('');
|
||||
let tab = $state('active');
|
||||
let search = $state('');
|
||||
let limit = $state(25);
|
||||
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
|
||||
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
|
||||
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
||||
$effect(() => { search; tab; limit = 25; });
|
||||
async function load() {
|
||||
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; error = ''; }
|
||||
catch (e) { error = (e as Error).message; } finally { loading = false; }
|
||||
}
|
||||
async function refreshBetas(): Promise<void> {
|
||||
const res = await fetch('/api/betas');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { betas: BetaListRow[] };
|
||||
betas = body.betas;
|
||||
}
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
void load().then(seedSelection).catch(error => { flash = (error as Error).message; }).finally(() => { loading = false; });
|
||||
poll = setInterval(refreshBetas, 5000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (poll) clearInterval(poll);
|
||||
});
|
||||
|
||||
const eligibleScripts = $derived(
|
||||
scripts.filter(
|
||||
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|
||||
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
|
||||
)
|
||||
);
|
||||
|
||||
function eligibleForRow(rowIdx: number): ScriptListRow[] {
|
||||
const takenIds = new Set(
|
||||
picks.map((p, i) => (i === rowIdx ? null : p.scriptId)).filter((id): id is number => id !== null)
|
||||
);
|
||||
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
||||
}
|
||||
|
||||
async function seedSelection(): Promise<void> {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const selections = parseBetaSelection(query);
|
||||
if (!selections.length) return;
|
||||
// Validate all selections before populating; never substitute a newer upload.
|
||||
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
|
||||
const res = await fetch(`/api/scripts/${scriptId}`);
|
||||
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
|
||||
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
|
||||
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|
||||
|| !detail.versions.some(v => v.id === versionId)) {
|
||||
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
|
||||
}
|
||||
return detail;
|
||||
}));
|
||||
for (const detail of details) {
|
||||
detailById[detail.script.id] = detail;
|
||||
}
|
||||
picks = selections.map(s => ({ key: nextKey++, ...s }));
|
||||
pickedServers = new Set();
|
||||
}
|
||||
|
||||
async function onPickScript(rowIdx: number): Promise<void> {
|
||||
flash = null;
|
||||
const pick = picks[rowIdx];
|
||||
pick.versionId = null;
|
||||
picks = [...picks];
|
||||
if (pick.scriptId === null) return;
|
||||
if (!detailById[pick.scriptId]) {
|
||||
const res = await fetch(`/api/scripts/${pick.scriptId}`);
|
||||
if (!res.ok) { flash = 'Failed to load script detail'; return; }
|
||||
const body = (await res.json()) as ScriptDetail;
|
||||
detailById = { ...detailById, [pick.scriptId]: body };
|
||||
}
|
||||
const detail = detailById[pick.scriptId];
|
||||
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
|
||||
pick.versionId = eligibleVersions[0]?.id ?? null;
|
||||
picks = [...picks];
|
||||
// Start with nothing selected — user opts in each server explicitly.
|
||||
pickedServers = new Set();
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
|
||||
}
|
||||
function removeRow(idx: number): void {
|
||||
picks = picks.filter((_, i) => i !== idx);
|
||||
// Start with nothing selected — user opts in each server explicitly.
|
||||
pickedServers = new Set();
|
||||
}
|
||||
function toggleServer(name: string): void {
|
||||
const next = new Set(pickedServers);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
pickedServers = next;
|
||||
}
|
||||
function versionsForRow(row: Pick): Version[] {
|
||||
if (row.scriptId === null) return [];
|
||||
const detail = detailById[row.scriptId];
|
||||
if (!detail) return [];
|
||||
return detail.versions.filter((v) => v.id !== detail.script.current_version_id);
|
||||
}
|
||||
function candidatesFor(row: Pick): string[] | null {
|
||||
if (row.scriptId === null) return null;
|
||||
const detail = detailById[row.scriptId];
|
||||
if (!detail) return null;
|
||||
return detail.script.scope_type ? detail.matchedServers : fleetServers.map((s) => s.name);
|
||||
}
|
||||
const candidateServers = $derived.by(() => {
|
||||
const perPick: string[][] = [];
|
||||
for (const p of picks) {
|
||||
const c = candidatesFor(p);
|
||||
if (c === null) continue;
|
||||
perPick.push(c);
|
||||
}
|
||||
if (perPick.length === 0) return [] as string[];
|
||||
const [first, ...rest] = perPick;
|
||||
return first.filter((name) => rest.every((set) => set.includes(name)));
|
||||
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
|
||||
return pollVisible(load, 5000);
|
||||
});
|
||||
|
||||
const canSubmit = $derived(
|
||||
!openingBeta &&
|
||||
testPlan.requirements.length > 0 &&
|
||||
picks.length > 0 &&
|
||||
picks.every((p) => p.scriptId !== null && p.versionId !== null) &&
|
||||
candidateServers.length > 0 &&
|
||||
pickedServers.size > 0
|
||||
);
|
||||
|
||||
async function openNewBeta(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
openingBeta = true;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch('/api/betas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
|
||||
servers: [...pickedServers],
|
||||
testPlan: parseBetaPlan(testPlan),
|
||||
waitForHealth,
|
||||
waitForMatch
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; result?: { betaId: number } };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
if (body.result) await goto(`/betas/${body.result.betaId}`);
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
openingBeta = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
|
||||
let grouped = $derived(
|
||||
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
|
||||
);
|
||||
|
||||
function summarizeScripts(b: BetaListRow): string {
|
||||
if (b.scripts.length === 0) return '(no artifacts)';
|
||||
if (b.scripts.length === 1) {
|
||||
const s = b.scripts[0];
|
||||
return `[${s.kind}] ${s.name} · ${artifactVersionLabel(s.version)}`;
|
||||
}
|
||||
const byKind: Record<string, number> = {};
|
||||
for (const s of b.scripts) byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
|
||||
return Object.entries(byKind).map(([k, n]) => `${n} ${k}${n === 1 ? '' : 's'}`).join(', ');
|
||||
}
|
||||
function betaStateVariant(s: string): 'online' | 'warning' | 'info' | 'neutral' {
|
||||
return s === 'ready-promote' ? 'online' :
|
||||
s === 'running' ? 'warning' :
|
||||
s === 'promoted' ? 'info' :
|
||||
'neutral';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
title="Betas"
|
||||
subtitle="Test unpromoted versions on selected servers before rolling them out."
|
||||
/>
|
||||
|
||||
{#if flash}<p class="flash" role="alert">{flash}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else}
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Open a beta</h2>
|
||||
|
||||
{#if eligibleScripts.length === 0}
|
||||
<EmptyState
|
||||
title="No artifacts with untested versions"
|
||||
description="Upload a new version from scripts / configs / plugins to open a beta."
|
||||
/>
|
||||
{:else}
|
||||
<form onsubmit={openNewBeta} class="beta-form">
|
||||
<Field label="Artifacts">
|
||||
<div class="picks">
|
||||
{#each picks as row, idx (row.key)}
|
||||
<div class="pick-row">
|
||||
<div class="pick-select">
|
||||
<select
|
||||
class="native-select"
|
||||
bind:value={row.scriptId}
|
||||
onchange={() => onPickScript(idx)}
|
||||
>
|
||||
<option value={null}>— Pick an artifact —</option>
|
||||
{#each eligibleForRow(idx) as s (s.id)}
|
||||
<option value={s.id}>[{s.kind}] {s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="chev">▾</span>
|
||||
</div>
|
||||
<div class="pick-select">
|
||||
<select
|
||||
class="native-select"
|
||||
bind:value={row.versionId}
|
||||
disabled={row.scriptId === null}
|
||||
>
|
||||
{#if row.scriptId === null}
|
||||
<option value={null}>—</option>
|
||||
{:else}
|
||||
{#each versionsForRow(row) as v (v.id)}
|
||||
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<span class="chev">▾</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="row-x"
|
||||
onclick={() => removeRow(idx)}
|
||||
disabled={picks.length === 1}
|
||||
title={picks.length === 1 ? 'Need at least one script' : 'Remove this script'}
|
||||
aria-label="Remove artifact"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="add-row" onclick={addRow}>+ Add artifact</button>
|
||||
</Field>
|
||||
|
||||
<Field label="Test servers" hint="Intersection of scopes across all picked artifacts.">
|
||||
<div class="server-picker">
|
||||
{#if candidateServers.length === 0}
|
||||
<span class="picker-empty">No server satisfies all selected scopes — pick different scripts.</span>
|
||||
{:else}
|
||||
{#each candidateServers as name (name)}
|
||||
<label class="chk">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickedServers.has(name)}
|
||||
onchange={() => toggleServer(name)}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<BetaPlanBuilder bind:plan={testPlan} />
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canSubmit}
|
||||
loading={openingBeta}
|
||||
>
|
||||
Open beta
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if betas.length === 0}
|
||||
<EmptyState title="No betas yet" description="Open one above once you have unpromoted versions." />
|
||||
{:else}
|
||||
{#each grouped as g (g.state)}
|
||||
{#if g.rows.length > 0}
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2 class="capitalize">{g.state.replace('-', ' ')}</h2>
|
||||
<Badge variant="neutral" size="sm">{g.rows.length}</Badge>
|
||||
</div>
|
||||
<div class="beta-rows">
|
||||
{#each g.rows as b (b.id)}
|
||||
<a class="beta-row" href="/betas/{b.id}">
|
||||
<span class="beta-id">#{b.id}</span>
|
||||
<span class="beta-script">{summarizeScripts(b)}</span>
|
||||
<Badge variant={betaStateVariant(b.state)} size="sm" uppercase>{b.testOutcome && b.state === 'running' ? b.testOutcome : b.state}</Badge>
|
||||
<span class="beta-servers">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
|
||||
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<Tabs {tabs} bind:value={tab} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
|
||||
<div class="ui-toolbar"><input type="search" bind:value={search} aria-label="Search betas" placeholder="Search betas or files…" /></div>
|
||||
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
|
||||
{#if loading}<EmptyState title="Loading betas…" />
|
||||
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
|
||||
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
||||
<a href={'/betas/' + beta.id} class="row">
|
||||
<div><strong>Beta #{beta.id}</strong><span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span></div>
|
||||
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.length} servers</span></div>
|
||||
</a>
|
||||
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.beta-form { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
|
||||
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.pick-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.pick-select { position: relative; min-width: 0; }
|
||||
.native-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 9px 32px 9px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.native-select:hover { border-color: var(--border-strong); }
|
||||
.native-select:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15); }
|
||||
.native-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.chev {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.row-x {
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 14px;
|
||||
height: 38px;
|
||||
font-size: var(--text-lg);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.row-x:hover:not(:disabled) { color: var(--state-danger); border-color: rgba(255, 86, 112, 0.4); }
|
||||
.row-x:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.add-row {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
margin-top: var(--space-2);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.add-row:hover { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
|
||||
.server-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.picker-empty {
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.chk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
|
||||
|
||||
|
||||
.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.section-head { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.capitalize { text-transform: capitalize; }
|
||||
|
||||
.beta-rows { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.beta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto auto;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.beta-row:hover { background: var(--surface-raised); }
|
||||
.beta-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.beta-id { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||
.beta-script { font-size: var(--text-sm); color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.beta-servers { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.beta-row { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||
.pick-row { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
.pick-select:first-child { grid-column: 1 / -1; }
|
||||
.chk { min-height: 44px; min-width: 0; }
|
||||
.chk input { flex-shrink: 0; }
|
||||
}
|
||||
.page { display:grid; gap:24px; }
|
||||
.rows { display:grid; gap:8px; }
|
||||
.row { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); text-decoration:none; }
|
||||
.row:hover { border-color:var(--border-strong); }
|
||||
.files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
|
||||
.meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
|
||||
import type { BetaTestView } from "$lib/beta-plan.js";
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
@@ -63,11 +67,7 @@
|
||||
|
||||
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
poll = setInterval(load, 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
onMount(() => pollVisible(load, 5000, false));
|
||||
|
||||
async function promote(): Promise<void> {
|
||||
acting = 'promote'; flash = null;
|
||||
@@ -76,7 +76,7 @@
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
}
|
||||
async function rollback(): Promise<void> {
|
||||
const isReject = beta?.state === 'ready-promote';
|
||||
@@ -84,14 +84,14 @@
|
||||
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
|
||||
: `Rollback beta #${id}? This restores original files on every beta server.`;
|
||||
const restart = test ? test.plan.rollbackRestart === 'when-empty' ? ' Restarts will roll out when servers are empty.' : ' Restart servers manually to load the restored files.' : '';
|
||||
if (!confirm(prompt + restart)) return;
|
||||
if (!await confirmAction({title:isReject ? 'Reject beta?' : 'Roll back beta?',message:prompt + restart,confirmLabel:isReject ? 'Reject beta' : 'Roll back',danger:true})) return;
|
||||
acting = 'rollback'; flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
}
|
||||
async function retry(server: string): Promise<void> {
|
||||
acting = `retry:${server}`; flash = null;
|
||||
@@ -104,21 +104,7 @@
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
|
||||
function betaStateVariant(s: string): 'online' | 'warning' | 'info' | 'neutral' {
|
||||
return s === 'ready-promote' ? 'online' :
|
||||
s === 'running' ? 'warning' :
|
||||
s === 'promoted' ? 'info' :
|
||||
'neutral';
|
||||
}
|
||||
function stageVariant(s: string): 'online' | 'warning' | 'danger' | 'neutral' {
|
||||
return s === 'ready' ? 'online' :
|
||||
s === 'failed' ? 'danger' :
|
||||
s === 'awaiting-empty' || s === 'restarting' || s === 'awaiting-health' || s === 'awaiting-match' || s === 'deploying'
|
||||
? 'warning' :
|
||||
'neutral';
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -137,7 +123,7 @@
|
||||
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Badge variant={betaStateVariant(b.state)} size="md" uppercase>{b.state}</Badge>
|
||||
<StatusBadge state={b.state} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
@@ -163,7 +149,7 @@
|
||||
{#each scripts as s (s.id)}
|
||||
<div class="script-row">
|
||||
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
|
||||
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
|
||||
<a class="s-name" href={"/" + (s.kind === "baseline" ? "configs" : s.kind + "s") + "/" + s.id}>{s.name}</a>
|
||||
<span class="s-target">{s.target_rel_path}</span>
|
||||
<span class="s-ver">{artifactVersionLabel(s.version)}</span>
|
||||
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
|
||||
@@ -202,8 +188,8 @@
|
||||
<div class="srv-list">
|
||||
{#each servers as bs (bs.server)}
|
||||
<div class="srv-row">
|
||||
<a class="srv-name" href="/servers/{bs.server}">{bs.server}</a>
|
||||
<Badge variant={stageVariant(bs.stage)} size="sm" uppercase>{bs.stage}</Badge>
|
||||
<a class="srv-name" href="/servers/{bs.server}"><ServerIdentity name={bs.server} /></a>
|
||||
<StatusBadge state={bs.stage} />
|
||||
<div class="ticks" aria-label="pipeline progress">
|
||||
<span class:on={bs.deployed_at}>Deploy</span>
|
||||
<span class:on={bs.restarted_at || bs.stage === 'restarting'}>Empty</span>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -47,6 +53,10 @@
|
||||
interface DriftServer { server: string; files: DriftFile[] }
|
||||
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
|
||||
|
||||
let loadError = $state('');
|
||||
let limit = $state(25);
|
||||
let driftLimit = $state(25);
|
||||
$effect(() => { driftFilter; driftLimit = 25; });
|
||||
let deployments = $state<Deployment[]>([]);
|
||||
let loading = $state(true);
|
||||
let drift = $state<DriftPreview | null>(null);
|
||||
@@ -54,20 +64,28 @@
|
||||
let running = $state(false);
|
||||
let rollingBack = $state<string | null>(null);
|
||||
let flash = $state<string | null>(null);
|
||||
let driftError = $state('');
|
||||
// Drift panel opens automatically when there's a problem; collapses when clean.
|
||||
let driftOpen = $state(false);
|
||||
let driftFilter = $state('');
|
||||
|
||||
async function loadCampaigns(): Promise<void> {
|
||||
const res = await fetch('/api/campaigns');
|
||||
const body = (await res.json()) as { deployments: Deployment[] };
|
||||
deployments = body.deployments;
|
||||
try {
|
||||
const res = await fetch('/api/campaigns');
|
||||
if (!res.ok) throw new Error('Could not refresh rollouts.');
|
||||
deployments = (await res.json()).deployments; loadError = '';
|
||||
} catch (error) { loadError = (error as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function scanDrift(): Promise<void> {
|
||||
if (scanning) return;
|
||||
scanning = true;
|
||||
try {
|
||||
const res = await fetch('/api/drift');
|
||||
if (!res.ok) throw new Error('Could not scan drift.');
|
||||
drift = (await res.json()) as DriftPreview;
|
||||
driftError = '';
|
||||
} catch (error) { driftError = (error as Error).message;
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
@@ -97,7 +115,7 @@
|
||||
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
|
||||
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
|
||||
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
|
||||
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
|
||||
if (!await confirmAction({ title:'Reconcile drift?', message:`This will ${parts.join(' and ')} across ${drift.servers.length} servers.`, confirmLabel:'Reconcile', danger:true })) return;
|
||||
running = true; flash = null;
|
||||
try {
|
||||
const res = await fetch('/api/drift', {
|
||||
@@ -117,20 +135,12 @@
|
||||
if (restored > 0) parts2.push(`installed ${restored}`);
|
||||
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
|
||||
await Promise.all([loadCampaigns(), scanDrift()]);
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(async () => {
|
||||
await Promise.all([loadCampaigns(), scanDrift()]);
|
||||
loading = false;
|
||||
poll = setInterval(loadCampaigns, 5000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (poll) clearInterval(poll);
|
||||
});
|
||||
onMount(() => { void scanDrift(); return pollVisible(loadCampaigns, 5000); });
|
||||
|
||||
function isActive(d: Deployment): boolean {
|
||||
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
|
||||
@@ -142,13 +152,6 @@
|
||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||
}
|
||||
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
|
||||
return s === 'done' ? 'online' :
|
||||
s === 'failed' ? 'danger' :
|
||||
s === 'pending' ? 'warning' :
|
||||
s === 'held' ? 'info' :
|
||||
'neutral';
|
||||
}
|
||||
|
||||
let active = $derived(deployments.filter(isActive));
|
||||
let history = $derived(deployments.filter((d) => !isActive(d)));
|
||||
@@ -169,16 +172,16 @@
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const summary = scriptSummary(d) || 'this promote';
|
||||
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
|
||||
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
|
||||
rollingBack = d.id;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
|
||||
flash = 'Rollback campaign opened';
|
||||
flash = 'Rollback started';
|
||||
await loadCampaigns();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
rollingBack = null;
|
||||
}
|
||||
}
|
||||
@@ -186,7 +189,7 @@
|
||||
// History grouped by day for a nice timeline read.
|
||||
let historyByDay = $derived.by(() => {
|
||||
const groups = new Map<string, Deployment[]>();
|
||||
for (const d of history) {
|
||||
for (const d of history.slice(0,limit)) {
|
||||
const key = new Date(d.started_at).toDateString();
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(d);
|
||||
@@ -197,29 +200,27 @@
|
||||
// Drift filtering — spare the operator's eyes on huge fleets.
|
||||
let filteredDriftServers = $derived(
|
||||
drift?.servers.filter((s) =>
|
||||
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase())
|
||||
!driftFilter || (s.server + ' ' + serverTitle(s.server)).toLowerCase().includes(driftFilter.toLowerCase())
|
||||
) ?? []
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
title="Campaigns"
|
||||
subtitle="Fleet campaigns, drift reconciliation, and rollbacks. Servers restart in stages as earlier servers recover."
|
||||
title="Rollouts"
|
||||
subtitle="Track file rollouts, reconcile drift, and review rollbacks."
|
||||
/>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else}
|
||||
<!-- Drift summary — collapsed by default when there's no problem. -->
|
||||
<Card padding="lg">
|
||||
<button
|
||||
type="button"
|
||||
class="drift-summary"
|
||||
aria-expanded={driftOpen}
|
||||
onclick={() => (driftOpen = !driftOpen)}
|
||||
>
|
||||
<div class="drift-summary-l">
|
||||
{#if driftError}<div class="ui-error" role="alert">{driftError}<Button size="sm" onclick={scanDrift} disabled={scanning}>Retry scan</Button></div>{/if}
|
||||
<div class="drift-summary">
|
||||
<button class="drift-summary-l" aria-expanded={driftOpen} aria-controls="drift-body" onclick={() => driftOpen = !driftOpen}>
|
||||
|
||||
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
|
||||
<span class="drift-title">Drift</span>
|
||||
{#if drift === null}
|
||||
@@ -240,8 +241,8 @@
|
||||
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
|
||||
</button>
|
||||
<div class="drift-summary-r">
|
||||
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
|
||||
Rescan
|
||||
</Button>
|
||||
@@ -255,21 +256,21 @@
|
||||
Reconcile
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if driftOpen}
|
||||
<div class="drift-body">
|
||||
<div class="drift-body" id="drift-body">
|
||||
{#if drift && drift.totalDrift > 0}
|
||||
<input
|
||||
class="drift-filter"
|
||||
class="drift-filter" aria-label="Filter drift by server"
|
||||
type="search"
|
||||
placeholder="Filter by server name…"
|
||||
bind:value={driftFilter}
|
||||
/>
|
||||
{#each filteredDriftServers as s (s.server)}
|
||||
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
|
||||
<div class="drift-server">
|
||||
<div class="drift-server-head">
|
||||
<span class="server-name">{s.server}</span>
|
||||
<span class="server-name"><ServerIdentity name={s.server} /></span>
|
||||
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="drift-files">
|
||||
@@ -286,6 +287,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<ListFooter total={filteredDriftServers.length} bind:limit={driftLimit} label="servers" />
|
||||
{:else if drift?.totalDrift === 0}
|
||||
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
|
||||
{:else}
|
||||
@@ -308,7 +310,7 @@
|
||||
{#if active.length === 0}
|
||||
<EmptyState
|
||||
title="Nothing in flight"
|
||||
description="Push scripts or open a beta to see campaigns here."
|
||||
description="Promote a beta to roll out tested files to the fleet."
|
||||
/>
|
||||
{:else}
|
||||
<div class="deploy-list">
|
||||
@@ -327,7 +329,7 @@
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<EmptyState
|
||||
title="No completed campaigns in the retention window"
|
||||
title="No completed rollouts in the retention window"
|
||||
description="Retention is 7 days by default."
|
||||
/>
|
||||
{:else}
|
||||
@@ -343,14 +345,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={history.length} bind:limit label="rollouts" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet deployRow(d: Deployment)}
|
||||
<a class="deploy-row" href="/campaigns/{d.id}">
|
||||
<article class="deploy-row">
|
||||
<div class="row-head">
|
||||
<div class="row-head-l">
|
||||
<span class="action">{d.action}</span>
|
||||
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
|
||||
{#if d.action === 'promote' || d.action === 'rollback'}
|
||||
<span class="target">{scriptSummary(d)}</span>
|
||||
{/if}
|
||||
@@ -381,7 +384,7 @@
|
||||
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
|
||||
<span class="node-label">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -391,12 +394,12 @@
|
||||
{#each ['done','pending','failed','held','manual'] as state}
|
||||
{@const n = d.servers.filter((s) => s.restart_state === state).length}
|
||||
{#if n > 0}
|
||||
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
|
||||
<span class="state-count">{n} <StatusBadge {state} size="sm" /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
@@ -664,4 +667,7 @@
|
||||
.drift-summary { flex-direction: column; align-items: flex-start; }
|
||||
.drift-summary-r { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
.drift-summary-l { padding:0; background:transparent; border:0; color:inherit; text-align:left; cursor:pointer; }
|
||||
.state-count { display:inline-flex; align-items:center; gap:5px; }
|
||||
.action { color:var(--text-primary); text-decoration:underline; text-underline-offset:4px; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import DiffViewer from '$ui/components/DiffViewer.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
@@ -35,6 +39,10 @@
|
||||
let detail = $state<Detail | null>(null);
|
||||
let loadError = $state('');
|
||||
let request = 0;
|
||||
let diffOpen = $state(false);
|
||||
let diffError = $state('');
|
||||
let diffLoading = $state(false);
|
||||
let diffRequest = 0;
|
||||
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
@@ -42,7 +50,7 @@
|
||||
const current = ++request;
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.');
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Rollout not found.' : 'Could not refresh rollout.');
|
||||
const body = await res.json() as Detail;
|
||||
if (current !== request || id !== $page.params.id) return;
|
||||
detail = body; loadError = '';
|
||||
@@ -51,20 +59,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { $page.params.id; detail = null; openFile = null; loadError = ''; void loadDetail(); });
|
||||
$effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; void loadDetail(); });
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
poll = setInterval(loadDetail, 5000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (poll) clearInterval(poll);
|
||||
});
|
||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||
|
||||
async function loadDiff(f: FileRow) {
|
||||
const res = await fetch(`/api/campaigns/${$page.params.id}/file/${f.id}`);
|
||||
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string };
|
||||
openFile = { id: f.id, ...body };
|
||||
const id = $page.params.id;
|
||||
const current = ++diffRequest;
|
||||
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
|
||||
try {
|
||||
const response = await fetch('/api/campaigns/' + id + '/file/' + f.id);
|
||||
if (!response.ok) throw new Error('Could not load this file comparison. Close and select it again to retry.');
|
||||
const body = await response.json();
|
||||
if (id === $page.params.id && current === diffRequest) openFile = {id:f.id, ...body};
|
||||
} catch (error) { if (id === $page.params.id && current === diffRequest) diffError = (error as Error).message; }
|
||||
finally { if (current === diffRequest) diffLoading = false; }
|
||||
}
|
||||
|
||||
let byServer = $derived.by(() => {
|
||||
@@ -76,14 +85,6 @@
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
|
||||
return s === 'done' ? 'online' :
|
||||
s === 'failed' ? 'danger' :
|
||||
s === 'pending' ? 'warning' :
|
||||
s === 'held' ? 'info' :
|
||||
'neutral';
|
||||
}
|
||||
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
|
||||
return v === 'added' ? 'online' :
|
||||
v === 'updated' ? 'info' :
|
||||
@@ -100,7 +101,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Campaign | Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Rollout | Electric Cherry</title></svelte:head>
|
||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
||||
|
||||
{#if detail}
|
||||
@@ -109,7 +110,7 @@
|
||||
<SectionHeader title={action} subtitle={detail.deployment.id}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Campaigns', href: '/campaigns' },
|
||||
{ label: 'Rollouts', href: '/campaigns' },
|
||||
{ label: action }
|
||||
]} />
|
||||
{/snippet}
|
||||
@@ -139,13 +140,13 @@
|
||||
{#each detail.servers as s (s.server)}
|
||||
<div class="srv-row">
|
||||
<div class="srv-l">
|
||||
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
|
||||
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
|
||||
{#if s.error}
|
||||
<span class="err">{s.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="srv-r">
|
||||
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
|
||||
<StatusBadge state={s.restart_state} />
|
||||
{#if s.players !== null && s.players > 0}
|
||||
<span class="players">{s.players}p</span>
|
||||
{/if}
|
||||
@@ -165,11 +166,11 @@
|
||||
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
|
||||
</div>
|
||||
{#if detail.files.length === 0}
|
||||
<EmptyState title="No file changes recorded" description="This campaign didn't touch any tracked artifacts." />
|
||||
<EmptyState title="No file changes recorded" description="This rollout didn't change any tracked files." />
|
||||
{:else}
|
||||
{#each [...byServer.entries()] as [srv, files] (srv)}
|
||||
<div class="file-group">
|
||||
<h3>{srv}</h3>
|
||||
<h3><ServerIdentity name={srv} /></h3>
|
||||
<div class="file-rows">
|
||||
{#each files as f (f.id)}
|
||||
<button class="file-row" onclick={() => loadDiff(f)}>
|
||||
@@ -188,29 +189,11 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if openFile}
|
||||
<div
|
||||
class="diff-scrim"
|
||||
onclick={() => (openFile = null)}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close diff"
|
||||
>
|
||||
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
|
||||
<div class="diff-head">
|
||||
<span class="diff-title">{openFile.relPath}</span>
|
||||
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
before={openFile.before}
|
||||
after={openFile.after}
|
||||
verb={openFile.verb}
|
||||
relPath={openFile.relPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p>
|
||||
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
||||
{:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
|
||||
</Dialog>
|
||||
</div>
|
||||
{:else if !loadError}
|
||||
<div class="loading">Loading…</div>
|
||||
@@ -341,44 +324,10 @@
|
||||
}
|
||||
|
||||
/* Diff modal */
|
||||
.diff-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.diff-modal {
|
||||
width: min(1000px, 92vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
.diff-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.diff-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.srv-row { align-items: flex-start; flex-direction: column; }
|
||||
.srv-l, .file-row { flex-wrap: wrap; }
|
||||
.diff-modal { padding: var(--space-4); max-height: 92dvh; }
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -31,13 +38,23 @@
|
||||
let blueprintsLoading = $state(true);
|
||||
let blueprintError = $state('');
|
||||
let removing = $state('');
|
||||
let confirmRemove = $state('');
|
||||
let tab = $state('active');
|
||||
let limit = $state(25);
|
||||
let collectionLimit = $state(10);
|
||||
let historyState = $state('all');
|
||||
$effect(() => { action; search; tab; historyState; limit = 25; collectionLimit = 10; });
|
||||
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
|
||||
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]);
|
||||
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
|
||||
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
|
||||
const attention = $derived(deployments.filter(needsAttention));
|
||||
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
|
||||
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
|
||||
(historyState === 'all' || needsAttention(d)) &&
|
||||
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
@@ -54,12 +71,7 @@
|
||||
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
void loadBlueprints();
|
||||
const timer = setInterval(load, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
onMount(() => pollVisible(load, 5000));
|
||||
async function loadBlueprints() {
|
||||
try {
|
||||
const res = await fetch('/api/blueprints');
|
||||
@@ -70,11 +82,12 @@
|
||||
finally { blueprintsLoading = false; }
|
||||
}
|
||||
async function removeBlueprint(id: string) {
|
||||
if (!await confirmAction({title:'Remove blueprint?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove blueprint', danger:true})) return;
|
||||
removing = id; blueprintError = '';
|
||||
try {
|
||||
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Could not remove blueprint.');
|
||||
blueprints = blueprints.filter(b => b.id !== id); confirmRemove = '';
|
||||
blueprints = blueprints.filter(b => b.id !== id);
|
||||
} catch (err) { blueprintError = (err as Error).message; }
|
||||
finally { removing = ''; }
|
||||
}
|
||||
@@ -87,20 +100,18 @@
|
||||
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." />
|
||||
<div class="launch-banner">
|
||||
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps and target lobby counts. Existing and queued servers count toward the blueprint; preview and create only the shortfall.</p></div>
|
||||
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
|
||||
</div>
|
||||
<SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
|
||||
</SectionHeader>
|
||||
<Tabs {tabs} bind:value={tab} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
||||
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
<div class="fleet-shutdown">
|
||||
<div><strong>Call it a night.</strong><p>Queue the whole fleet for removal once each server is empty.</p></div>
|
||||
<Button variant="danger" onclick={() => housePartyOpen = true}>House Party Protocol</Button>
|
||||
</div>
|
||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in In flight below.`; void load(); }} />
|
||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
|
||||
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
|
||||
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
|
||||
{#if tab === 'blueprints'}
|
||||
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
|
||||
<section>
|
||||
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||
@@ -108,7 +119,7 @@
|
||||
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
|
||||
{:else}
|
||||
<div class="blueprints">
|
||||
{#each blueprints as blueprint (blueprint.id)}
|
||||
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
|
||||
<article class="blueprint">
|
||||
<div class="blueprint-top">
|
||||
<span class="eyebrow">Server collection</span>
|
||||
@@ -120,57 +131,61 @@
|
||||
<div class="blueprint-footer">
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
|
||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
||||
</div>
|
||||
{#if confirmRemove === blueprint.id}
|
||||
<div class="remove-confirm">
|
||||
<p>Remove this blueprint? Existing servers and launch history are kept.</p>
|
||||
<button disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>{removing === blueprint.id ? 'Removing…' : 'Remove blueprint'}</button>
|
||||
<button disabled={!!removing} onclick={() => confirmRemove = ''}>Keep it</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={blueprints.length} bind:limit label="blueprints" />
|
||||
{:else}
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if collections.length}
|
||||
{#if visibleCollections.length}
|
||||
<section>
|
||||
<h2>Collection launches</h2>
|
||||
<div class="rows">{#each collections as collection (collection.id)}
|
||||
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
|
||||
<a class="deployment-row" href="/deployments/collections/{collection.id}">
|
||||
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.failed ? 'danger' : collection.active ? 'info' : 'online'}>{collection.active ? 'In progress' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
|
||||
<p>{collection.done} of {collection.total} servers verified{collection.failed ? ` · ${collection.failed} need attention` : ''}{collection.active ? ` · ${collection.active} in progress` : ''}</p>
|
||||
<span class="meta">Started {fmt(collection.created_at)}</span>
|
||||
</a>
|
||||
{/each}</div>
|
||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} step={10} label="collections" />
|
||||
</section>
|
||||
{/if}
|
||||
{#if tab === 'active'}
|
||||
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; }}>Review history</Button></div>{/if}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
{#if loading}<p>Loading deployments...</p>
|
||||
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
|
||||
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section>
|
||||
<h2>History</h2>
|
||||
<div class="filters">
|
||||
<label>Operation <select bind:value={action}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Destructions</option></select></label>
|
||||
<label>Status <select bind:value={historyState}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
|
||||
<label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
|
||||
</div>
|
||||
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="History is recorded for server operations performed after this update, including failures." />
|
||||
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
|
||||
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
<ListFooter total={history.length} bind:limit label="operations" />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet row(d: Deployment)}
|
||||
<a class="deployment-row" href="/deployments/{d.id}">
|
||||
<div class="row-head">
|
||||
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
||||
<strong class="server">{d.server}</strong>
|
||||
<strong class="server"><ServerIdentity name={d.server} /></strong>
|
||||
</div>
|
||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||
<StatusBadge state={d.state} />
|
||||
</div>
|
||||
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server creation completed.' : 'Server destroyed.') : d.stage}</p>
|
||||
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
||||
@@ -180,22 +195,21 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.fleet-shutdown { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
|
||||
.fleet-shutdown p { margin: 5px 0 0; font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.row-head { justify-content: space-between; }
|
||||
.launch-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; border: 1px solid var(--border-strong); border-radius: 16px; padding: 28px; background: linear-gradient(120deg, color-mix(in oklab, var(--accent-primary) 10%, var(--surface)), var(--surface)); }
|
||||
.launch-banner > div { max-width: 650px; }.launch-banner h2 { font-size: 25px; letter-spacing: -.03em; }.launch-banner p, .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.banner-actions { display: flex; gap: 8px; flex-wrap: wrap; }.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
|
||||
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: 10px; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: 11px; color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
|
||||
.remove, .blueprint-empty button, .remove-confirm button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.remove-confirm { border-top: 1px solid var(--border-subtle); margin-top: 14px; }.remove-confirm button { margin-right: 12px; }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: 12px; padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
|
||||
|
||||
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
|
||||
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
|
||||
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
|
||||
section { display: grid; gap: 16px; }
|
||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||
p { margin: 8px 0; } a { color: var(--accent); }
|
||||
.rows { display: grid; gap: 12px; }
|
||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||
@@ -204,4 +218,8 @@
|
||||
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
|
||||
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 0; } }
|
||||
.panels { display:grid; gap:24px; }
|
||||
.fleet-actions { position:relative; font-size:13px; }
|
||||
.fleet-actions summary { padding:10px; cursor:pointer; }
|
||||
.fleet-actions[open] :global(.btn) { position:absolute; right:0; top:100%; z-index:10; white-space:nowrap; box-shadow:var(--shadow-raised); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||
import type { OperationStep } from '$lib/operation-flow.js';
|
||||
import { page } from '$app/stores';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
@@ -75,9 +80,6 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
function stateLabel(state: string) {
|
||||
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
|
||||
}
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
@@ -91,23 +93,20 @@
|
||||
finally { refreshing = false; }
|
||||
}
|
||||
$effect(() => { $page.params.id; detail = null; void load(); });
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
|
||||
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment?.server}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
|
||||
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment ? serverTitle(deployment.server) : undefined}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if detail?.collection}<a href="/deployments/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
||||
{#if deployment}
|
||||
<section class="summary">
|
||||
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
|
||||
<div class="top"><StatusBadge state={deployment.state} />
|
||||
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
|
||||
</div>
|
||||
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2>
|
||||
@@ -120,38 +119,21 @@
|
||||
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
||||
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Operation flow</h2>
|
||||
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} class:failed={!active && ['failed', 'interrupted'].includes(deployment.state) && index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index < currentStep ? '✓' : index + 1}</span>{step}</li>{/each}</ol>
|
||||
</section>
|
||||
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
|
||||
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if detail && detail.events.length > 0}
|
||||
<ol class="timeline">{#each detail.events as event (event.id)}<li><time datetime={event.created_at}>{fmt(event.created_at)}</time><p>{event.message}</p></li>{/each}</ol>
|
||||
{:else}<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown above.</p>{/if}
|
||||
</section>
|
||||
|
||||
{:else if !error}<p>Loading deployment...</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
||||
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
|
||||
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
|
||||
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
|
||||
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; }
|
||||
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
|
||||
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow li.reached { color: var(--text-primary); }
|
||||
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.flow li.failed { border-color: var(--state-danger); }
|
||||
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
|
||||
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
|
||||
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
|
||||
.timeline p { margin-top: 6px; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
|
||||
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
|
||||
|
||||
.error { color: var(--state-danger); } .warning { color: var(--state-warning); }
|
||||
@media (max-width: 640px) {
|
||||
.timing { flex-direction: column; gap: var(--space-2); }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
@@ -22,7 +25,7 @@
|
||||
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
|
||||
}
|
||||
$effect(() => { $page.params.id; detail = null; void load(); });
|
||||
onMount(() => { const timer = setInterval(load, 2000); return () => { clearInterval(timer); requestNumber++; }; });
|
||||
onMount(() => { const stop = pollVisible(load, 5000, false); return () => { stop(); requestNumber++; }; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
|
||||
@@ -41,7 +44,7 @@
|
||||
<section class="servers" aria-label="Servers in this collection">
|
||||
{#each detail.servers as server (server.id)}
|
||||
<a class="server" href="/deployments/{server.id}">
|
||||
<div class="top"><strong>{server.server}</strong><Badge variant={server.state === 'done' ? 'online' : ['failed', 'interrupted'].includes(server.state) ? 'danger' : 'info'}>{labels[server.state] ?? server.state}</Badge></div>
|
||||
<div class="top"><strong><ServerIdentity name={server.server} /></strong><StatusBadge state={server.state} /></div>
|
||||
<p>{server.stage}</p>
|
||||
{#if server.error}<p class="error">{server.error}</p>{/if}
|
||||
<div class="top muted"><span>Port {server.port} · {server.server.startsWith('t6_') ? 'Black Ops II' : 'Black Ops I'}</span><span>View operation →</span></div>
|
||||
@@ -52,5 +55,5 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: 28px; }.summary { display: grid; gap: 20px; padding: 24px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, p { margin: 0; }h2 { font-size: 21px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }dl { display: flex; flex-wrap: wrap; gap: 24px 50px; margin: 0; }dt { color: var(--text-secondary); font-size: 12px; }dd { font-family: var(--font-mono); font-size: 28px; margin: 8px 0 0; }progress { width: 100%; height: 8px; accent-color: var(--accent-primary); }.servers { display: grid; gap: 14px; }.server { display: grid; gap: 14px; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 10px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }.server:hover { border-color: var(--border-strong); }.server strong { font-family: var(--font-mono); font-size: 14px; }.server p { font-size: 13px; line-height: 1.6; }.error { color: var(--state-danger); }
|
||||
.page { display: grid; gap: 28px; }.summary { display: grid; gap: 20px; padding: 24px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, p { margin: 0; }h2 { font-size: 21px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }dl { display: flex; flex-wrap: wrap; gap: 24px 50px; margin: 0; }dt { color: var(--text-secondary); font-size: 12px; }dd { font-family: var(--font-mono); font-size: 28px; margin: 8px 0 0; }progress { width: 100%; height: 8px; accent-color: var(--accent-primary); }.servers { display: grid; gap: 14px; }.server { display: grid; gap: 14px; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: inherit; text-decoration: none; overflow-wrap: anywhere; }.server:hover { border-color: var(--border-strong); }.server strong { font-family: var(--font-mono); font-size: 14px; }.server p { font-size: 13px; line-height: 1.6; }.error { color: var(--state-danger); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { mapLabel } from '$lib/script-library.js';
|
||||
import PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
|
||||
import PluginUpdates from '$ui/components/PluginUpdates.svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
@@ -20,6 +25,7 @@
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
currentVersion: { id: number; version: number; release_version: string | null } | null;
|
||||
kind: ArtifactKind;
|
||||
latestVersion: {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
@@ -32,6 +38,18 @@
|
||||
}
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||
|
||||
let addOpen = $state(false);
|
||||
let method = $state('upload');
|
||||
let updatesOpen = $state(false);
|
||||
let search = $state('');
|
||||
let game = $state('');
|
||||
let status = $state('');
|
||||
let sort = $state('name');
|
||||
let limit = $state(25);
|
||||
$effect(() => { search; game; status; sort; limit = 25; });
|
||||
const visible = $derived.by(() => plugins.filter(a => (a.name + ' ' + a.target_rel_path).toLowerCase().includes(search.toLowerCase())
|
||||
&& (!game || !a.scope_type || (a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? '')))
|
||||
&& (!status || stateLabel(a) === status)).sort((a,b) => sort === 'recent' ? (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') : a.name.localeCompare(b.name)));
|
||||
let plugins = $state<ArtifactRow[]>([]);
|
||||
let importCount = $state(0);
|
||||
let loading = $state(true);
|
||||
@@ -61,11 +79,11 @@
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!pluginRes.ok) {
|
||||
flash = `Plugins API returned ${pluginRes.status}. If this is a dev environment, restart the server so the DB migration runs.`;
|
||||
plugins = [];
|
||||
throw new Error('Could not refresh plugins. Your last loaded results are still shown.');
|
||||
} else {
|
||||
plugins = ((await pluginRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
|
||||
}
|
||||
flash = null;
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} };
|
||||
}
|
||||
@@ -105,33 +123,22 @@
|
||||
: null;
|
||||
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
|
||||
newReleaseVersion = '';
|
||||
addOpen = false;
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(a: ArtifactRow): string {
|
||||
if (!a.scope_type) return 'unscoped';
|
||||
return `${a.scope_type} · ${a.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateVariantFor(a: ArtifactRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
|
||||
if (!a.latestVersion) return null;
|
||||
if (a.current_version_id === null) return 'neutral';
|
||||
if (a.latestVersion.id !== a.current_version_id) {
|
||||
if (a.latestVersion.state === 'untested') return 'neutral';
|
||||
if (a.latestVersion.state === 'tested') return 'online';
|
||||
if (a.latestVersion.state === 'test-failed') return 'danger';
|
||||
}
|
||||
return null;
|
||||
return a.scope_type === 'map' ? mapLabel(a.scope_value ?? '') : a.scope_value === 't5' ? 'Black Ops I' : a.scope_value === 't6' ? 'Black Ops II' : a.scope_value ?? 'Unscoped';
|
||||
}
|
||||
function stateLabel(a: ArtifactRow): string {
|
||||
if (!a.latestVersion) return '';
|
||||
if (a.current_version_id === null) return 'untested';
|
||||
if (a.latestVersion.id !== a.current_version_id) {
|
||||
return `${artifactVersionLabel(a.latestVersion)} ${a.latestVersion.state.replace('-', ' ')}`;
|
||||
}
|
||||
return '';
|
||||
if (a.runningBetaId) return 'testing';
|
||||
if (!a.latestVersion) return 'empty';
|
||||
if (a.latestVersion.id === a.current_version_id) return 'promoted';
|
||||
return a.latestVersion.state;
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
@@ -150,20 +157,68 @@
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Plugins" subtitle="Native game plugins deployed alongside scripts." />
|
||||
|
||||
<PluginGithubImport onimport={async () => { importCount++; await load(); }} />
|
||||
{#key importCount}<PluginUpdates />{/key}
|
||||
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Upload a plugin</h2>
|
||||
{#if successBanner}
|
||||
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
{#if successBanner}
|
||||
<div class="banner">
|
||||
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
|
||||
<span>Open a beta on <a href="/betas">the betas page</a> to test it before promotion.</span>
|
||||
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
|
||||
</div>
|
||||
{/if}
|
||||
<form onsubmit={createPlugin} class="upload-form">
|
||||
|
||||
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
<div class="ui-toolbar">
|
||||
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
|
||||
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
|
||||
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
|
||||
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
|
||||
</div>
|
||||
<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>All plugins</h2>
|
||||
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if visible.length === 0 && !flash}
|
||||
<EmptyState title={plugins.length ? "No matching plugins" : "No plugins yet"} description={plugins.length ? "Try different filters." : "Add a plugin to upload a file or import a GitHub release."} />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each visible.slice(0,limit) as a (a.id)}
|
||||
<a class="row" href="/plugins/{a.id}">
|
||||
<div class="row-top">
|
||||
<span class="name">{a.name}</span>
|
||||
<Badge
|
||||
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
|
||||
size="sm"
|
||||
>
|
||||
{scopeLabel(a)}
|
||||
</Badge>
|
||||
<StatusBadge state={stateLabel(a)} />
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="path">{a.target_rel_path}</span>
|
||||
<span>{a.currentVersion ? `Promoted ${artifactVersionLabel(a.currentVersion)}` : 'Never promoted'}</span>
|
||||
<span>Latest {artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
|
||||
{#if a.servers.length}
|
||||
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<ListFooter total={visible.length} bind:limit label="plugins" />
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading} wide>
|
||||
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading},{id:'github',label:'GitHub release',disabled:uploading}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
|
||||
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
|
||||
{#if method === 'github'}<PluginGithubImport onimport={async () => { importCount++; addOpen = false; await load(); }} />
|
||||
{:else} <form onsubmit={createPlugin} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||
</Field>
|
||||
@@ -175,19 +230,19 @@
|
||||
</Field>
|
||||
<Field label="Scope" hint="Where this plugin deploys.">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
||||
<Select ariaLabel="Scope game" bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Plugin file (.dll)">
|
||||
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
|
||||
</Field>
|
||||
<div class="form-actions">
|
||||
<div class="form-actions ui-sticky-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -199,56 +254,15 @@
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>All plugins</h2>
|
||||
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if plugins.length === 0}
|
||||
<EmptyState title="No plugins yet" description="Upload one above to get started." />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each plugins as a (a.id)}
|
||||
<a class="row" href="/plugins/{a.id}">
|
||||
<div class="row-top">
|
||||
<span class="name">{a.name}</span>
|
||||
<Badge
|
||||
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
|
||||
size="sm"
|
||||
>
|
||||
{scopeLabel(a)}
|
||||
</Badge>
|
||||
{#if stateVariantFor(a)}
|
||||
{@const v = stateVariantFor(a)!}
|
||||
<Badge variant={v} size="sm">{stateLabel(a)}</Badge>
|
||||
{/if}
|
||||
{#if a.runningBetaId}
|
||||
<Badge variant="warning" size="sm">β{a.runningBetaId} running</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="path">{a.target_rel_path}</span>
|
||||
<span>{artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
|
||||
{#if a.servers.length}
|
||||
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.panel-title { margin: 0 0 var(--space-4); font-family: var(--font-body); font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); }
|
||||
.upload-form { margin-top: 20px; }
|
||||
|
||||
.banner {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -8,6 +10,8 @@
|
||||
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
||||
import { zombieMaps } from '$lib/map-aliases.js';
|
||||
|
||||
let limit = $state(25);
|
||||
$effect(() => { search; status; game; map; sort; limit = 25; });
|
||||
let scripts = $state<LibraryScript[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
@@ -38,7 +42,7 @@
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||
const selectable = $derived(visible.filter(canTest));
|
||||
const selectable = $derived(visible.slice(0,limit).filter(canTest));
|
||||
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
||||
async function load() {
|
||||
error = '';
|
||||
@@ -71,7 +75,7 @@
|
||||
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="status-grid" aria-label="Filter by script status">
|
||||
@@ -102,12 +106,12 @@
|
||||
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each visible as s (s.id)}
|
||||
{#each visible.slice(0,limit) as s (s.id)}
|
||||
{@const state = scriptStatus(s)}
|
||||
<article class="script-row" class:selected={selected.includes(s.id)}>
|
||||
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
|
||||
<div class="script-info">
|
||||
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></div>
|
||||
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><StatusBadge state={state} /></div>
|
||||
<div class="path">{s.target_rel_path}</div>
|
||||
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
|
||||
</div>
|
||||
@@ -125,6 +129,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListFooter total={visible.length} bind:limit label="scripts" />
|
||||
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
|
||||
</section>
|
||||
{#if selectedScripts.length}
|
||||
@@ -164,4 +169,7 @@
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
|
||||
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
|
||||
.status-grid { display:flex; gap:8px; overflow-x:auto; }
|
||||
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
|
||||
.status-grid strong { font-size:13px; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/ui/components/primitives/Button.svelte';
|
||||
import Field from '$lib/ui/components/primitives/Field.svelte';
|
||||
import Input from '$lib/ui/components/primitives/Input.svelte';
|
||||
|
||||
interface SavedKey { server_id: string; deployed: number; updated_at: string }
|
||||
let open = $state(false);
|
||||
let search = $state('');
|
||||
let limit = $state(25);
|
||||
$effect(() => { search; limit = 25; });
|
||||
let keys = $state<SavedKey[]>([]);
|
||||
let serverId = $state('');
|
||||
let plutoKey = $state('');
|
||||
@@ -13,13 +24,15 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
const filtered = $derived(keys.filter(key => (key.server_id + ' ' + serverTitle(key.server_id)).toLowerCase().includes(search.toLowerCase())));
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/api/server-keys');
|
||||
if (!res.ok) throw new Error('Could not load server keys');
|
||||
keys = (await res.json()).keys;
|
||||
}
|
||||
onMount(() => { load().catch(e => error = e.message).finally(() => loading = false); });
|
||||
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
||||
onMount(() => { void reload(); });
|
||||
function reset() { serverId = ''; plutoKey = ''; editing = false; }
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
@@ -31,12 +44,12 @@
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
||||
reset(); notice = 'Server key saved.'; await load();
|
||||
reset(); open = false; notice = 'Server key saved.'; await load();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function remove(id: string) {
|
||||
if (!confirm(`Remove the saved key for ${id}? Running servers are unaffected.`)) return;
|
||||
if (!await confirmAction({title:'Remove saved key?',message:`Remove the key for ${serverTitle(id)} (${id})? Running servers are unaffected.`,confirmLabel:'Remove key',danger:true})) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const res = await fetch('/api/server-keys', {
|
||||
@@ -52,10 +65,39 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
||||
<h1>Keys</h1>
|
||||
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
|
||||
<form onsubmit={save}>
|
||||
<h2>{editing ? 'Update key' : 'Add key'}</h2>
|
||||
<div class="page">
|
||||
<SectionHeader title="Server keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => { reset(); error = ''; open = true; }}>Add key</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} placeholder="Search servers…" /></div>
|
||||
|
||||
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
<h2>Saved keys</h2>
|
||||
{#if loading}<p>Loading keys…</p>
|
||||
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
|
||||
{:else}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
|
||||
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
|
||||
<tr>
|
||||
<td><ServerIdentity name={key.server_id} /><code>{key.server_id}</code></td>
|
||||
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
|
||||
<td aria-label="Key saved">••••••••</td>
|
||||
<td><div class="actions">
|
||||
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
|
||||
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
|
||||
</div></td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{/if}
|
||||
|
||||
<ListFooter total={filtered.length} bind:limit label="keys" />
|
||||
</div>
|
||||
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<form onsubmit={save}>
|
||||
<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1.">
|
||||
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
||||
</Field>
|
||||
@@ -64,37 +106,16 @@
|
||||
</Field>
|
||||
<div class="actions">
|
||||
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
||||
{#if editing}<Button onclick={reset} disabled={busy}>Cancel</Button>{/if}
|
||||
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
<h2>Saved keys</h2>
|
||||
{#if loading}<p>Loading keys…</p>
|
||||
{:else if keys.length === 0}<p>No saved keys yet. Add one above or save one when deploying a server.</p>
|
||||
{:else}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
|
||||
<tbody>{#each keys as key (key.server_id)}
|
||||
<tr>
|
||||
<td><code>{key.server_id}</code></td>
|
||||
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
|
||||
<td aria-label="Key saved">••••••••</td>
|
||||
<td><div class="actions">
|
||||
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; }}>Update</Button>
|
||||
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
|
||||
</div></td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{/if}
|
||||
|
||||
</Dialog>
|
||||
<style>
|
||||
h1 { margin-top: 0; }
|
||||
|
||||
h2 { font-size: var(--text-md); }
|
||||
p { color: var(--text-secondary); }
|
||||
form { display: grid; gap: var(--space-4); max-width: 620px; padding: var(--space-6); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); margin: var(--space-6) 0; }
|
||||
form h2 { margin: 0; }
|
||||
|
||||
.actions { display: flex; gap: var(--space-2); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
@@ -102,4 +123,7 @@
|
||||
th { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
code { font-family: var(--font-mono); }
|
||||
.error { color: var(--state-danger); }
|
||||
.page { display:grid; gap:24px; }
|
||||
form { margin:0; padding:0; border:0; }
|
||||
code { display:block; font-size:12px; color:var(--text-secondary); margin-top:6px; }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
|
||||
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import ConfigInspector from '$ui/components/ConfigInspector.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||
import { statusByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
||||
@@ -68,7 +76,7 @@
|
||||
game_started_at: string | null; game_ended_at: string | null;
|
||||
}
|
||||
interface InventoryEntry {
|
||||
relPath: string; scriptVersionId: number | null;
|
||||
relPath: string; kind?: string; scriptVersionId: number | null;
|
||||
sha256: string; size: number; updatedAt: string; source: string | null;
|
||||
}
|
||||
interface Integrations {
|
||||
@@ -79,11 +87,11 @@
|
||||
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
|
||||
}
|
||||
|
||||
let server = $state<Server | null>(null);
|
||||
let integrations = $state<Integrations | null>(null);
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
|
||||
let integrations = $derived($fleetIntegrations);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let inventory = $state<InventoryEntry[]>([]);
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
||||
let probing = $state(false);
|
||||
let netDiagnosis = $state<NetDiagnosis | null>(null);
|
||||
@@ -91,54 +99,53 @@
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroyOpen = $state(false);
|
||||
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
|
||||
let activeTab = $state('roster');
|
||||
let tabLoading = $state(false);
|
||||
let tabError = $state('');
|
||||
let historyLimit = $state(25);
|
||||
let inventoryLimit = $state(25);
|
||||
const operations = $derived($fleet.operations.filter(op => op.server === name));
|
||||
|
||||
async function loadServer(): Promise<void> {
|
||||
const [srvRes, depRes, invRes] = await Promise.all([
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/campaigns'),
|
||||
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
|
||||
]);
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
||||
integrations = srvBody.integrations;
|
||||
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
|
||||
if (invRes.ok) {
|
||||
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
|
||||
inventory = invBody.scripts;
|
||||
}
|
||||
async function loadTab(currentName: string, tab: string) {
|
||||
if (tab !== 'history' && tab !== 'inventory') return;
|
||||
try {
|
||||
const response = await fetch(tab === 'history' ? '/api/campaigns?server=' + encodeURIComponent(currentName) : '/api/servers/' + encodeURIComponent(currentName) + '/scripts');
|
||||
if (!response.ok) throw new Error('Could not refresh ' + tab + '.');
|
||||
const body = await response.json();
|
||||
if (name !== currentName || activeTab !== tab) return;
|
||||
if (tab === 'history') history = body.deployments; else inventory = body.scripts;
|
||||
tabError = '';
|
||||
} catch (error) { if (name === currentName && activeTab === tab) tabError = (error as Error).message; }
|
||||
finally { if (name === currentName && activeTab === tab) tabLoading = false; }
|
||||
}
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
loadServer();
|
||||
poll = setInterval(loadServer, 5000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (poll) clearInterval(poll);
|
||||
$effect(() => {
|
||||
const currentName = name;
|
||||
const tab = activeTab;
|
||||
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
|
||||
tabLoading = tab === 'history' || tab === 'inventory';
|
||||
return pollVisible(() => loadTab(currentName, tab), 10000);
|
||||
});
|
||||
$effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; });
|
||||
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
|
||||
|
||||
let live = $derived($statusByServer.get(name));
|
||||
let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null);
|
||||
let effectiveMap = $derived(live?.map ?? server?.last_map ?? null);
|
||||
let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null);
|
||||
let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null);
|
||||
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
|
||||
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
|
||||
let effectiveErr = $derived(live ? live.lastError : server?.last_error ?? null);
|
||||
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
|
||||
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
|
||||
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
|
||||
let effectiveMap = $derived(server?.last_map ?? null);
|
||||
let effectiveGametype = $derived(server?.last_gametype ?? null);
|
||||
let effectiveRoster = $derived(server?.roster ?? null);
|
||||
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
|
||||
let effectiveSeen = $derived(server?.last_seen_at ?? null);
|
||||
let effectiveErr = $derived(server?.last_error ?? null);
|
||||
let effectiveEndpoint = $derived<RconEndpoint | null>(server?.endpoint ?? null);
|
||||
let decoded = $derived(decodeRconError(effectiveErr));
|
||||
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
|
||||
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
|
||||
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
||||
let effectiveSnippet = $derived<string | null>(
|
||||
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
|
||||
rconProbe?.rawReply ?? server?.replySnippet ?? null
|
||||
);
|
||||
let effectiveActiveSince = $derived<string | null>(live?.activeSince ?? server?.active_since ?? null);
|
||||
let effectiveInactiveSince = $derived<string | null>(live?.inactiveSince ?? server?.inactive_since ?? null);
|
||||
let effectiveGameStartedAt = $derived<string | null>(live?.gameStartedAt ?? server?.game_started_at ?? null);
|
||||
let effectiveGameEndedAt = $derived<string | null>(live?.gameEndedAt ?? server?.game_ended_at ?? null);
|
||||
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
|
||||
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
|
||||
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
|
||||
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
|
||||
let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
||||
let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
||||
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
||||
@@ -146,13 +153,6 @@
|
||||
|
||||
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
|
||||
|
||||
// Auto-open the Diagnostics tab if there's a problem the operator should see.
|
||||
$effect(() => {
|
||||
if (hasDiagnosticIssue && activeTab === 'roster' && effectiveRoster === null) {
|
||||
activeTab = 'diagnostics';
|
||||
}
|
||||
});
|
||||
|
||||
function ago(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
@@ -173,10 +173,7 @@
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' });
|
||||
komodoProbe = (await res.json()) as KomodoProbeResult;
|
||||
if (komodoProbe.ok && server) {
|
||||
const srvRes = await fetch('/api/servers');
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? server;
|
||||
integrations = srvBody.integrations;
|
||||
await refreshFleet();
|
||||
}
|
||||
} catch (err) {
|
||||
komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message };
|
||||
@@ -198,9 +195,7 @@
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' });
|
||||
rconProbe = (await res.json()) as RconProbeResult;
|
||||
const srvRes = await fetch('/api/servers');
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? server;
|
||||
await refreshFleet();
|
||||
} catch (err) {
|
||||
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
||||
} finally { probingRcon = false; }
|
||||
@@ -209,6 +204,7 @@
|
||||
let tabs = $derived([
|
||||
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
|
||||
{ id: 'logs', label: 'Logs' },
|
||||
{ id: 'config', label: 'Config' },
|
||||
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
|
||||
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
|
||||
{ id: 'history', label: 'History', badge: history.length || null }
|
||||
@@ -221,9 +217,9 @@
|
||||
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
|
||||
<!-- Hero: name + status + player count + primary actions -->
|
||||
<Card padding="lg" elevation="flat">
|
||||
<SectionHeader title={server.name} size="lg">
|
||||
<SectionHeader title={serverTitle(server.name)}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button variant="danger" onclick={() => destroyOpen = true}>
|
||||
@@ -232,6 +228,7 @@
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<CopyValue value={name} label="Server ID" />
|
||||
<div class="metrics">
|
||||
<div class="metric-cell name-cell">
|
||||
<StatusPip health={effectiveHealth} size="md" />
|
||||
@@ -244,14 +241,14 @@
|
||||
<div class="metric-cell">
|
||||
<Stat
|
||||
label="Players"
|
||||
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? 0}
|
||||
size="xl"
|
||||
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
|
||||
size="md"
|
||||
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
|
||||
tween
|
||||
/>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<Stat label="Map" value={effectiveMap ?? '—'} size="md" tween={false} />
|
||||
<Stat label="Map" value={fleetIdentity(server).map} size="md" tween={false} />
|
||||
{#if gametypeLabel(effectiveGametype)}
|
||||
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
|
||||
{/if}
|
||||
@@ -281,11 +278,14 @@
|
||||
|
||||
</Card>
|
||||
|
||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
||||
{#if operations.length}<section class="ui-panel"><h2>Current operations</h2><div class="server-operations">{#each operations as operation (operation.key)}<a href={operation.href}><strong>{operation.label}</strong><span>{operation.detail}</span><StatusBadge state={operation.state} /></a>{/each}</div></section>{/if}
|
||||
<div class="tabs-row">
|
||||
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" />
|
||||
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
|
||||
</div>
|
||||
|
||||
<div class="tab-panel">
|
||||
<div class="tab-panel" role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
|
||||
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
|
||||
{#if activeTab === 'roster'}
|
||||
<Card padding="lg">
|
||||
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
|
||||
@@ -300,22 +300,24 @@
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
{:else if activeTab === 'config'}
|
||||
{#key name}<ConfigInspector fixedServer={name} />{/key}
|
||||
{:else if activeTab === 'inventory'}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Scripts on this server</h2>
|
||||
<h2>Installed files</h2>
|
||||
<span class="panel-count">{inventory.length}</span>
|
||||
</div>
|
||||
{#if inventory.length === 0}
|
||||
{#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0}
|
||||
<EmptyState
|
||||
title="No scripts tracked yet"
|
||||
description="Upload a script from the Scripts page and open a beta to deploy it here."
|
||||
title="No files tracked yet"
|
||||
description="Files appear here after scripts, configs, or plugins are deployed to this server."
|
||||
/>
|
||||
{:else}
|
||||
<div class="inv-list">
|
||||
{#each inventory as f (f.relPath)}
|
||||
{#each inventory.slice(0,inventoryLimit) as f (f.relPath)}
|
||||
<div class="inv-row">
|
||||
<span class="rel">{f.relPath}</span>
|
||||
<span class="rel">{f.relPath}<span class="file-kind">{f.kind ?? (f.relPath.endsWith(".dll") ? "Plugin" : f.relPath.endsWith(".cfg") ? "Config" : "Script")}</span></span>
|
||||
<span class="size">{f.size} B</span>
|
||||
<span class="src">{f.source ?? '—'}</span>
|
||||
<span class="sha">{f.sha256.slice(0, 12)}</span>
|
||||
@@ -324,6 +326,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
|
||||
{:else if activeTab === 'diagnostics'}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
@@ -489,29 +492,22 @@
|
||||
{:else if activeTab === 'history'}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Campaign history</h2>
|
||||
<h2>Rollout history</h2>
|
||||
<span class="panel-count">{history.length}</span>
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
|
||||
<EmptyState
|
||||
title="No campaigns yet"
|
||||
title="No rollouts yet"
|
||||
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
|
||||
/>
|
||||
{:else}
|
||||
<div class="history">
|
||||
{#each history as d (d.id)}
|
||||
{#each history.slice(0,historyLimit) as d (d.id)}
|
||||
{@const mine = d.servers.find((s) => s.server === name)!}
|
||||
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
|
||||
<span class="hist-action">{d.action}</span>
|
||||
<span class="hist-state">
|
||||
<Badge
|
||||
variant={mine.restart_state === 'done' ? 'online' :
|
||||
mine.restart_state === 'failed' ? 'danger' :
|
||||
mine.restart_state === 'pending' ? 'warning' :
|
||||
'neutral'}
|
||||
size="sm"
|
||||
uppercase
|
||||
>{mine.restart_state}</Badge>
|
||||
<StatusBadge state={mine.restart_state} />
|
||||
{#if mine.players !== null && mine.players > 0}
|
||||
<span class="hist-players">{mine.players}p</span>
|
||||
{/if}
|
||||
@@ -524,9 +520,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if $fleetError}
|
||||
<div class="ui-error" role="alert">{$fleetError}<Button onclick={refreshFleet}>Retry</Button></div>
|
||||
{:else if $fleet.receivedAt}
|
||||
<EmptyState title="Server not found" description="This server may have been removed from the fleet." />
|
||||
{:else}
|
||||
<div class="loading">Loading…</div>
|
||||
{/if}
|
||||
@@ -823,4 +824,8 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
.server-operations { display:grid; gap:8px; }
|
||||
.server-operations a { display:flex; gap:12px; flex-wrap:wrap; align-items:center; text-decoration:none; color:var(--text-primary); padding:10px 0; }
|
||||
.server-operations a span { color:var(--text-secondary); font-size:13px; }
|
||||
.file-kind { display:block; color:var(--text-secondary); font:12px var(--font-body); margin-top:5px; }
|
||||
</style>
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user