Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Toaster from '$ui/components/Toaster.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
@@ -18,8 +21,17 @@
|
||||
interface Props { children: import('svelte').Snippet }
|
||||
let { children }: Props = $props();
|
||||
let menuOpen = $state(false);
|
||||
let notificationsOpen = $state(false);
|
||||
let helpOpen = $state(false);
|
||||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||||
let menuButton: HTMLButtonElement;
|
||||
|
||||
function openUtility(panel: 'notifications' | 'help') {
|
||||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||
if (panel === 'notifications') notificationsOpen = true;
|
||||
else helpOpen = true;
|
||||
}
|
||||
|
||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
@@ -30,6 +42,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stopNotifications = startNotificationHistory();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
const stopFleetMonitor = startFleetMonitor();
|
||||
@@ -38,7 +51,7 @@
|
||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||
const shortcuts: Record<string, string> = {
|
||||
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard'
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings'
|
||||
};
|
||||
let armed = false;
|
||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -47,6 +60,7 @@
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; }
|
||||
|
||||
if (!armed && e.key === 'g') {
|
||||
armed = true;
|
||||
@@ -66,6 +80,7 @@
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopNotifications();
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
stopServers?.();
|
||||
@@ -83,14 +98,14 @@
|
||||
{ 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') },
|
||||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') || p.startsWith('/settings') },
|
||||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }
|
||||
];
|
||||
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' }] : []);
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
@@ -119,7 +134,7 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="strut" aria-hidden="true"></div>
|
||||
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
|
||||
</nav>
|
||||
{#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">
|
||||
@@ -127,6 +142,10 @@
|
||||
</main>
|
||||
<Toaster />
|
||||
<Confirmation />
|
||||
<NotificationHistory bind:open={notificationsOpen} />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Rollouts'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -229,7 +248,13 @@
|
||||
view-transition-name: nav-active;
|
||||
}
|
||||
|
||||
.strut { margin-left: auto; }
|
||||
.nav-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.utility { background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer; font: inherit; font-size: var(--text-xs); white-space: nowrap; }
|
||||
.shortcuts { display: grid; gap: 12px; margin: 0; }
|
||||
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
|
||||
.shortcuts dt { min-width: 55px; }
|
||||
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
|
||||
.menu-toggle { display: none; }
|
||||
|
||||
main {
|
||||
@@ -242,7 +267,7 @@
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 1080px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
@@ -271,7 +296,8 @@
|
||||
.tabs { display: none; width: 100%; margin: 0; }
|
||||
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
||||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||||
.strut { display: none; }
|
||||
.nav-tools { display: none; }
|
||||
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
|
||||
@@ -282,6 +308,7 @@
|
||||
}
|
||||
|
||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||
:global(input), :global(select), :global(textarea), :global(button), :global(a) { scroll-margin-block: 90px 100px; }
|
||||
: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; }
|
||||
|
||||
+40
-9
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { afterNavigate, replaceState } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -7,7 +9,7 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
||||
import { fleet, fleetServers, fleetError, fleetRefreshing, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
||||
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
||||
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
||||
@@ -25,6 +27,7 @@
|
||||
let viewOptionsOpen = $state(false);
|
||||
let pinned = $state<string[]>([]);
|
||||
let preferencesReady = $state(false);
|
||||
let routerReady = $state(false);
|
||||
let deployOpen = $state(false);
|
||||
let initialGame = $state<ZombieGame>('t5');
|
||||
let initialMapSlug = $state('');
|
||||
@@ -33,6 +36,17 @@
|
||||
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||
];
|
||||
|
||||
function readViewUrl() {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
query = params.get('q') ?? '';
|
||||
if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view;
|
||||
if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping;
|
||||
if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort;
|
||||
if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter;
|
||||
if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!;
|
||||
}
|
||||
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
|
||||
@@ -43,6 +57,7 @@
|
||||
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
|
||||
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
|
||||
} catch { /* Preferences also work when storage is disabled. */ }
|
||||
readViewUrl();
|
||||
preferencesReady = true;
|
||||
void refreshFleet();
|
||||
});
|
||||
@@ -51,7 +66,15 @@
|
||||
if (!preferencesReady) return;
|
||||
try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); }
|
||||
catch { /* The current view still works without persistent storage. */ }
|
||||
const values = { q: query, view, group: grouping, sort, filter, game };
|
||||
if (!routerReady) return;
|
||||
untrack(() => {
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
||||
if (url.href !== window.location.href) replaceState(url, $page.state);
|
||||
});
|
||||
});
|
||||
const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - $fleet.receivedAt) / 1000)); });
|
||||
|
||||
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
|
||||
? { ...server, health: 'stale' as const } : server));
|
||||
@@ -127,12 +150,16 @@
|
||||
{#if !$fleet.receivedAt}
|
||||
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
||||
{:else}
|
||||
<div class="overview" aria-label="Fleet summary">
|
||||
{#if servers.length}<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>
|
||||
<a class="metric-link" class:no-rollouts={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
||||
</div>{/if}
|
||||
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
|
||||
{/if}
|
||||
|
||||
{#if problems.length || attentionOperations.length || starting}
|
||||
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
||||
@@ -174,7 +201,7 @@
|
||||
{/if}
|
||||
|
||||
{#if servers.length === 0}
|
||||
<EmptyState title="Your first lobby starts here" description="Deploy a server or choose a saved blueprint to bring your fleet online." />
|
||||
<EmptyState title="Your first server starts here" description="Check your connections and saved keys, then create a server or use a blueprint.">{#snippet actions()}<Button href="/settings">Check system setup</Button><Button href="/deployments?tab=blueprints" variant="ghost">Browse blueprints</Button>{/snippet}</EmptyState>
|
||||
{:else}
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
@@ -199,9 +226,9 @@
|
||||
{#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>{#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 grouping !== 'none'}<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>
|
||||
</div>{/if}
|
||||
{#if view === 'cards'}
|
||||
<div class="cards">{#each group.servers as server (server.name)}
|
||||
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
|
||||
@@ -252,6 +279,10 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.setup-note strong { color: var(--text-primary); }
|
||||
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-rollouts { display: none; } }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
@@ -283,7 +314,7 @@
|
||||
.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: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); 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; }
|
||||
|
||||
@@ -32,9 +32,13 @@ export async function GET({ url }: { url: URL }) {
|
||||
: null
|
||||
};
|
||||
});
|
||||
const test = storedPlan(b.id);
|
||||
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
|
||||
return {
|
||||
...b,
|
||||
testOutcome: storedPlan(b.id)?.outcome ?? null,
|
||||
testOutcome: test?.outcome ?? null,
|
||||
testReason: test?.reason ?? null,
|
||||
deadlineAt: plan ? new Date(Date.parse(b.opened_at) + plan.timeoutHours * 3600000).toISOString() : null,
|
||||
scripts,
|
||||
servers: listBetaServers(b.id)
|
||||
};
|
||||
|
||||
@@ -31,5 +31,9 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
|
||||
return json({ beta, scripts, servers, events, test: testView(id) });
|
||||
const rollout = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
|
||||
const promotion = rollout ? { ...rollout,
|
||||
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(rollout.id) as { n: number }).n,
|
||||
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(rollout.id) as { n: number }).n } : null;
|
||||
return json({ beta, scripts, servers, events, test: testView(id), promotion });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { promoteBeta, previewBetaPromotion, BetaError } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'Invalid beta ID.' }, { status: 400 });
|
||||
try { return json({ preview: previewBetaPromotion(id) }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (err) {
|
||||
if (err instanceof BetaError) return json({ error: err.message }, { status: 409 });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const body = await request.json().catch(() => null);
|
||||
if (typeof body?.previewToken !== 'string') return json({ ok: false, error: 'Review promotion targets before confirming.' }, { status: 400 });
|
||||
try {
|
||||
const result = await promoteBeta(id);
|
||||
const result = await promoteBeta(id, body.previewToken);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return json({ inspection: inspect(body.server, body) }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
return json({ inspection: inspect(body.server, body), promoted: inspect(body.server) }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { cancelRemoval } from '$lib/server/fleet/deployments.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
if (!cancelRemoval(params.id)) return json({ error: 'Removal has already begun or this request is no longer waiting. Refresh to see its current state.' }, { status: 409 });
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { runDriftDeployment, scanFleetDrift } from '$lib/server/fleet/drift.js';
|
||||
import { DriftPlanChanged, previewDrift, runDriftDeployment } from '$lib/server/fleet/drift.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
// GET returns the current drift plan for the whole fleet without touching disk.
|
||||
export async function GET() {
|
||||
const entries = await scanFleetDrift();
|
||||
const plan = await previewDrift();
|
||||
const entries = plan.files.map(file => ({ ...file, size: file.afterSize ?? file.beforeSize ?? 0, sha256: file.afterSha256 ?? file.beforeSha256 ?? '' }));
|
||||
const byServer = new Map<
|
||||
string,
|
||||
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
|
||||
@@ -21,21 +22,25 @@ export async function GET() {
|
||||
byServer.set(e.server, arr);
|
||||
}
|
||||
return json({
|
||||
totalDrift: entries.length,
|
||||
totalDrift: entries.length, scannedAt: plan.scannedAt,
|
||||
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
|
||||
});
|
||||
}
|
||||
|
||||
// POST removes drift and creates a drift deployment. Body: { servers?: string[], dryRun?: boolean }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request
|
||||
.json()
|
||||
.catch(() => ({}))) as { servers?: string[]; dryRun?: boolean };
|
||||
const result = await runDriftDeployment({
|
||||
servers: body.servers,
|
||||
dryRun: body.dryRun ?? false,
|
||||
triggeredBy: 'ui'
|
||||
});
|
||||
if (result.deploymentId) void kick();
|
||||
return json({ ok: true, result });
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.every((server: unknown) => typeof server === 'string')))) {
|
||||
return json({ ok: false, error: 'Provide the server IDs to review.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
if (body.dryRun === true) return json({ ok: true, plan: await previewDrift(body.servers) });
|
||||
if (typeof body.expectedFingerprint !== 'string' || !body.expectedFingerprint) return json({ ok: false, error: 'Review a reconciliation plan before applying changes.' }, { status: 400 });
|
||||
const result = await runDriftDeployment({ servers: body.servers, expectedFingerprint: body.expectedFingerprint, triggeredBy: 'ui' });
|
||||
if (result.deploymentId) void kick();
|
||||
return json({ ok: true, result });
|
||||
} catch (error) {
|
||||
if (error instanceof DriftPlanChanged) return json({ ok: false, error: error.message, plan: error.plan }, { status: 409 });
|
||||
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
return json({
|
||||
script,
|
||||
versions,
|
||||
installedVersions: script.kind === 'script' || script.kind === 'plugin' ? db().prepare(`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`).all(id, script.target_rel_path) : [],
|
||||
matchedServers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
});
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||
import { getScript, setScope, serversMatchingScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid file ID.' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script) return json({ error: 'File not found.' }, { status: 404 });
|
||||
const type = url.searchParams.get('type');
|
||||
const value = url.searchParams.get('value');
|
||||
if (type !== 'none' && type !== 'game' && type !== 'map' || type !== 'none' && !value) return json({ error: 'Choose a complete scope.' }, { status: 400 });
|
||||
const before = serversMatchingScope(script.scope_type, script.scope_value);
|
||||
const after = type === 'none' ? [] : serversMatchingScope(type as ScopeType, value);
|
||||
const composed = script.kind === 'config' || script.kind === 'baseline';
|
||||
const tracked = composed ? [] : (db().prepare(`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`).all(script.target_rel_path, script.id) as Array<{ server: string }>).map(row => row.server);
|
||||
return json({ before, after, added: after.filter(name => !before.includes(name)), removed: before.filter(name => !after.includes(name)),
|
||||
affected: composed ? [...new Set([...before, ...after])].sort() : type === 'none' ? tracked : [...new Set([...before, ...after])].sort(),
|
||||
composed, detached: type === 'none' ? tracked : [] }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||
import { listServers } from '$lib/server/fleet/servers.js';
|
||||
import { listServerKeys } from '$lib/server/fleet/server-keys.js';
|
||||
import { classifyHealth } from '$lib/server/fleet/health.js';
|
||||
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
|
||||
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const servers = listServers();
|
||||
const readyServers = servers.filter(server => classifyHealth(server.last_seen_at, server.last_error, server.container_state) === 'online').length;
|
||||
const savedKeys = listServerKeys().length;
|
||||
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
|
||||
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
|
||||
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
|
||||
try {
|
||||
const address = new URL(config.komodo.address);
|
||||
if (!['http:', 'https:'].includes(address.protocol)) throw new Error('invalid address');
|
||||
const result = await Promise.race([
|
||||
tcpProbe('Komodo', address.hostname, Number(address.port || (address.protocol === 'https:' ? 443 : 80)), 3000),
|
||||
new Promise<null>(resolve => setTimeout(() => resolve(null), 4000))
|
||||
]);
|
||||
const reachable = result?.outcome === 'connected';
|
||||
connection.state = reachable ? 'ready' : 'attention';
|
||||
connection.detail = reachable ? 'Host reachable. This network check does not validate API credentials; deployment previews check launch prerequisites.' : 'Host unreachable from Electric Cherry. Check the address, DNS, port, and network access.';
|
||||
} catch { connection.state = 'attention'; connection.detail = 'Komodo address is invalid or could not be reached. Check KOMODO_ADDRESS.'; }
|
||||
}
|
||||
const checks: SetupCheck[] = [
|
||||
connection,
|
||||
{ id: 'target', title: 'Deployment host', state: config.komodo.targetServer ? 'ready' : 'unconfigured', detail: config.komodo.targetServer ? `New servers target ${config.komodo.targetServer}.` : 'Set KOMODO_TARGET_SERVER to the exact host name shown in Komodo.', href: '#setup-guide', action: 'Setup instructions' },
|
||||
{ id: 'rcon', title: 'Game server connection', state: !rconConfigured() ? 'unconfigured' : readyServers ? 'ready' : 'unverified', detail: !rconConfigured() ? 'Set RCON_PASSWORD to the password used by your game servers.' : readyServers ? `${readyServers} servers have fresh successful RCON reports.` : 'RCON password configured. A running server is needed to verify a response.', href: servers.length ? '/' : '#setup-guide', action: servers.length ? 'Review fleet' : 'Setup instructions' },
|
||||
{ id: 'game-files', title: 'Game files on the host', state: 'unverified', detail: 'T5 and T6 game-file volumes must be populated on the Komodo host. Their contents cannot be verified from this app.', href: '#game-files', action: 'Game-file instructions' },
|
||||
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Key presence does not confirm validity with Plutonium.`, href: '/server-keys', action: savedKeys ? 'Manage keys' : 'Add a key' },
|
||||
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Servers are registered but none has a fresh healthy report. Open diagnostics to investigate.' : 'Create your first server after completing the setup checks.', href: servers.length ? '/' : '/deployments', action: servers.length ? 'Review fleet' : 'Create a server' }
|
||||
];
|
||||
const body: SystemStatus = { checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys };
|
||||
return json(body, { headers: { 'cache-control': 'no-store' } });
|
||||
}
|
||||
@@ -9,11 +9,16 @@
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { statusPresentation } from '$lib/ui/status.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
interface BetaListRow {
|
||||
id: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
testOutcome: string | null;
|
||||
testReason: string | null;
|
||||
deadlineAt: string | null;
|
||||
notes: string | null;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
opened_at: string;
|
||||
@@ -33,10 +38,22 @@
|
||||
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())));
|
||||
let now = $state(Date.now());
|
||||
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
||||
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
|
||||
function blocker(beta:BetaListRow) {
|
||||
if(beta.state==='promoted')return 'Version promoted; check its rollout for remaining activation.';
|
||||
if(beta.state==='rolled-back')return 'Rolled back. The files and plan can be reused for another attempt.';
|
||||
if(beta.state==='ready-promote')return 'Ready for promotion review.';
|
||||
if(beta.testReason)return beta.testReason;
|
||||
const problem=beta.servers.find(server=>server.error);
|
||||
if(problem)return `${serverTitle(problem.server)}: ${problem.error}`;
|
||||
const waiting=beta.servers.find(server=>server.stage!=='ready');
|
||||
return waiting?`${serverTitle(waiting.server)}: ${statusPresentation(waiting.stage).label.toLowerCase()}.`:'Waiting for test evaluation.';
|
||||
}
|
||||
$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 = ''; }
|
||||
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; }
|
||||
catch (e) { error = (e as Error).message; } finally { loading = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
@@ -57,8 +74,8 @@
|
||||
{: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>
|
||||
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={new Date(beta.opened_at).toLocaleString()}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {new Date(beta.deadlineAt).toLocaleString()}{:else if beta.closed_at} · Finished {new Date(beta.closed_at).toLocaleString()}{/if}</span></div>
|
||||
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
|
||||
</a>
|
||||
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
|
||||
</div>
|
||||
@@ -70,4 +87,5 @@
|
||||
.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); }
|
||||
.identity { flex:1 1 320px; min-width:0; } .blocker,.timing { display:block; margin-top:8px; line-height:1.5; font-size:13px; overflow-wrap:anywhere; } .timing,.beta-id { color:var(--text-secondary); font-size:12px; }
|
||||
</style>
|
||||
|
||||
+152
-340
@@ -1,375 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
|
||||
import type { BetaTestView } from "$lib/beta-plan.js";
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
|
||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||
import { betaFlow, type ActivityEvent } from '$lib/operation-flow.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import BetaTestProgress from '$ui/components/BetaTestProgress.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import OperationHistory from '$ui/components/OperationHistory.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';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||
import { betaFlow, type ActivityEvent } from '$lib/operation-flow.js';
|
||||
|
||||
interface BetaRow {
|
||||
id: number; script_id: number; script_version_id: number;
|
||||
trigger_kind: string; wait_for_health: number; wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string; closed_at: string | null; notes: string | null;
|
||||
}
|
||||
interface BetaServerRow {
|
||||
beta_id: number; server: string;
|
||||
had_original: number; prior_version_id: number | null;
|
||||
stage: string; error: string | null;
|
||||
deployed_at: string | null; restarted_at: string | null;
|
||||
healthy_at: string | null; match_seen_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface BetaScript {
|
||||
id: number; name: string; target_rel_path: string;
|
||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||||
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
||||
}
|
||||
|
||||
let test = $state<BetaTestView | null>(null);
|
||||
let beta = $state<BetaRow | null>(null);
|
||||
let scripts = $state<BetaScript[]>([]);
|
||||
let servers = $state<BetaServerRow[]>([]);
|
||||
interface BetaRow { id:number; state:string; opened_at:string; closed_at:string|null; wait_for_health:number; wait_for_match:number; notes?:string|null; }
|
||||
interface BetaServer { server:string; stage:string; error:string|null; deployed_at:string|null; restarted_at:string|null; healthy_at:string|null; match_seen_at:string|null; }
|
||||
interface BetaFile { id:number; name:string; kind:string; target_rel_path:string; version:{id:number; version:number; release_version:string|null; sha256:string}|null; }
|
||||
interface Promotion { id:string; pending:number; failed:number; }
|
||||
let test = $state<BetaTestView|null>(null);
|
||||
let beta = $state<BetaRow|null>(null);
|
||||
let scripts = $state<BetaFile[]>([]);
|
||||
let servers = $state<BetaServer[]>([]);
|
||||
let events = $state<ActivityEvent[]>([]);
|
||||
let loadError = $state('');
|
||||
let request = 0;
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let acting = $state<string | null>(null);
|
||||
let promotion = $state<Promotion|null>(null);
|
||||
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
|
||||
let acting = $state<string|null>(null), request = 0;
|
||||
let reviewOpen = $state(false), preview = $state<BetaPromotionPreview|null>(null), reviewError = $state('');
|
||||
let now = $state(Date.now());
|
||||
const id = $derived(Number($page.params.id));
|
||||
const closed = $derived(beta?.state === 'promoted' || beta?.state === 'rolled-back');
|
||||
const deadline = $derived(beta && test ? new Date(Date.parse(beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
|
||||
const remaining = $derived(deadline ? Math.max(0, Math.ceil((deadline.getTime() - now) / 60000)) : 0);
|
||||
const deadlineText = $derived(remaining > 0 ? `${Math.floor(remaining / 60)}h ${remaining % 60}m remaining` : 'Testing window ended');
|
||||
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length);
|
||||
const ranked = $derived([...servers].sort((a,b) => (a.stage === 'failed' ? 0 : a.stage === 'ready' ? 2 : 1) - (b.stage === 'failed' ? 0 : b.stage === 'ready' ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
|
||||
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the rollout to check activation on additional servers.' : 'Reuse these files and this plan when you are ready to test again.'
|
||||
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
|
||||
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
|
||||
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
|
||||
: beta?.state === 'ready-promote' ? 'All required coverage passed. Review the full promotion impact before deploying to additional servers.'
|
||||
: 'Review the outstanding conditions below. Promotion becomes available when the required coverage passes.');
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++request;
|
||||
const requestedId = id;
|
||||
async function load() {
|
||||
const current = ++request, requestedId = id;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${requestedId}`);
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta.');
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.');
|
||||
const body = await res.json();
|
||||
if (current !== request || requestedId !== id) return;
|
||||
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events;
|
||||
loadError = '';
|
||||
} catch (err) {
|
||||
if (current === request && requestedId === id) loadError = (err as Error).message;
|
||||
} finally { if (current === request && requestedId === id) loading = false; }
|
||||
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null;
|
||||
now = Date.now(); loadError = '';
|
||||
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
|
||||
finally { if (current === request && requestedId === id) loading = false; }
|
||||
}
|
||||
|
||||
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
|
||||
|
||||
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; void load(); });
|
||||
onMount(() => pollVisible(load, 5000, false));
|
||||
|
||||
async function promote(): Promise<void> {
|
||||
acting = 'promote'; flash = null;
|
||||
async function reviewPromotion() {
|
||||
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/promote`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
|
||||
await load();
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
const response = await fetch(`/api/betas/${id}/promote`), body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not prepare promotion review.');
|
||||
preview = body.preview;
|
||||
} catch (error) { reviewError = (error as Error).message; }
|
||||
finally { acting = null; }
|
||||
}
|
||||
async function rollback(): Promise<void> {
|
||||
const isReject = beta?.state === 'ready-promote';
|
||||
const prompt = isReject
|
||||
? `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 (!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;
|
||||
async function promote() {
|
||||
if (!preview) return;
|
||||
acting = 'promote'; reviewError = ''; flash = ''; success = '';
|
||||
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; }
|
||||
const response = await fetch(`/api/betas/${id}/promote`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({previewToken:preview.token})});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.ok) {
|
||||
if (body.kind === 'preview-changed' && body.details) preview = body.details;
|
||||
throw new Error(body.error ?? 'Promotion could not be started.');
|
||||
}
|
||||
reviewOpen = false;
|
||||
success = `Promotion created. ${body.result.awaitingActivation ?? 0} additional servers await activation${body.result.failedServers?.length ? `; ${body.result.failedServers.length} servers need attention` : ''}.`;
|
||||
await load();
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
} catch (error) { reviewError = (error as Error).message; }
|
||||
finally { acting = null; }
|
||||
}
|
||||
async function retry(server: string): Promise<void> {
|
||||
acting = `retry:${server}`; flash = null;
|
||||
async function rollback() {
|
||||
const reject = beta?.state === 'ready-promote';
|
||||
const restarts = test?.plan.rollbackRestart === 'manual' ? 'Restart servers manually to load the restored files.' : 'Restarts wait for empty servers.';
|
||||
if (!await confirmAction({title:reject?'Reject beta?':'Roll back beta?',message:`Restore the original files on ${servers.length} beta servers${reject ? ' and mark these versions as test-failed' : ''}. ${restarts}`,confirmLabel:reject?'Reject beta':'Roll back',danger:true})) return;
|
||||
acting = 'rollback'; flash = ''; success = '';
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server })
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
|
||||
const response = await fetch(`/api/betas/${id}/rollback`,{method:'POST'}), body = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Rollback failed.');
|
||||
success = `Original files restored. ${restarts}`; await load();
|
||||
} catch(error) { flash = (error as Error).message; }
|
||||
finally { acting = null; }
|
||||
}
|
||||
async function retry(server:string) {
|
||||
acting = `retry:${server}`; flash = '';
|
||||
try {
|
||||
const response = await fetch(`/api/betas/${id}/retry`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({server})}), body = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Retry failed.');
|
||||
await load();
|
||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
||||
} catch(error) { flash = (error as Error).message; }
|
||||
finally { acting = null; }
|
||||
}
|
||||
function outstanding(server:string) {
|
||||
const progress = test?.servers.find(row => row.server === server)?.progress.find(item => !item.passed);
|
||||
const requirement = test?.plan.requirements.find(item => item.id === progress?.id);
|
||||
return requirement ? requirementSummary(requirement) : 'Waiting for deployment and fresh observations.';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Beta #{id} | Electric Cherry</title></svelte:head>
|
||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={load}>Retry</button></p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if !beta}
|
||||
<EmptyState title="Beta not found" description="This beta may have been archived." />
|
||||
{:else}
|
||||
{@const b = beta}
|
||||
<div class="page">
|
||||
<SectionHeader title="Beta #{b.id}">
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<StatusBadge state={b.state} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="opts">
|
||||
<div class="opt"><span class="opt-k">Started</span><span class="opt-v">{new Date(b.opened_at).toLocaleString()}</span></div>
|
||||
<div class="opt"><span class="opt-k">Finished</span><span class="opt-v">{b.closed_at ? new Date(b.closed_at).toLocaleString() : 'In progress'}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OperationHistory steps={betaFlow({ ...b, testPlan: !!test }, servers)} {events} active={b.state === 'running' || b.state === 'ready-promote'} />
|
||||
|
||||
<!-- Artifacts -->
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Artifacts</h2>
|
||||
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
|
||||
</div>
|
||||
{#if scripts.length === 0}
|
||||
<EmptyState title="No artifacts bundled" description="This beta has no scripts, configs, or plugins." />
|
||||
{:else}
|
||||
<div class="script-list">
|
||||
{#each scripts as s (s.id)}
|
||||
<div class="script-row">
|
||||
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if test}
|
||||
<Card padding="lg"><BetaTestProgress {test} betaId={id} closed={b.state === "promoted" || b.state === "rolled-back"} onUpdate={load} /></Card>
|
||||
{:else}
|
||||
<!-- Options -->
|
||||
<Card padding="lg">
|
||||
<div class="panel-head"><h2>Options</h2></div>
|
||||
<div class="opts">
|
||||
<div class="opt">
|
||||
<span class="opt-k">Wait for health</span>
|
||||
<span class="opt-v">{b.wait_for_health ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
<div class="opt">
|
||||
<span class="opt-k">Wait for match</span>
|
||||
<span class="opt-v">{b.wait_for_match ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Server progress -->
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Server progress</h2>
|
||||
<Badge variant="neutral" size="sm">{servers.length}</Badge>
|
||||
</div>
|
||||
<div class="srv-list">
|
||||
{#each servers as bs (bs.server)}
|
||||
<div class="srv-row">
|
||||
<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>
|
||||
<span class:on={bs.restarted_at}>Restart</span>
|
||||
{#if b.wait_for_health}<span class:on={bs.healthy_at}>Healthy</span>{/if}
|
||||
{#if b.wait_for_match}
|
||||
<span class:on={bs.match_seen_at}>Match</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bs.error}
|
||||
<span class="err">{bs.error}</span>
|
||||
{/if}
|
||||
{#if bs.stage === 'failed' && !test}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => retry(bs.server)}
|
||||
disabled={acting === `retry:${bs.server}`}
|
||||
loading={acting === `retry:${bs.server}`}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Actions -->
|
||||
<Card padding="lg">
|
||||
<div class="panel-head"><h2>Actions</h2></div>
|
||||
<div class="action-row">
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={promote}
|
||||
disabled={b.state !== 'ready-promote' || acting !== null}
|
||||
loading={acting === 'promote'}
|
||||
title={b.state !== 'ready-promote' ? 'The test plan must pass before promotion' : ''}
|
||||
>
|
||||
Promote
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onclick={rollback}
|
||||
disabled={b.state === 'promoted' || b.state === 'rolled-back' || acting !== null}
|
||||
loading={acting === 'rollback'}
|
||||
>
|
||||
{b.state === 'ready-promote' ? 'Reject' : 'Rollback'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if flash}<div class="flash">{flash}</div>{/if}
|
||||
</Card>
|
||||
</div>
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
{#if loading}<EmptyState title="Loading beta…" />
|
||||
{:else if !beta && !loadError}<EmptyState title="Beta not found" description="Return to Betas to find another test." />
|
||||
{:else if beta}
|
||||
<div class="page">
|
||||
<SectionHeader title={beta.notes || `Beta #${beta.id}`} subtitle={beta.notes ? `Beta #${beta.id}` : undefined}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet}
|
||||
{#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet}
|
||||
</SectionHeader>
|
||||
<Card padding="lg">
|
||||
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
|
||||
<p>{nextAction}</p>
|
||||
{#if test?.reason}<p class="reason">{test.reason}</p>{/if}
|
||||
<div class="timing"><span>Started <time datetime={beta.opened_at}>{new Date(beta.opened_at).toLocaleString()}</time></span>
|
||||
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{deadline.toLocaleString()}</time> · {deadlineText}</strong>{/if}
|
||||
{#if beta.closed_at}<span>Finished {new Date(beta.closed_at).toLocaleString()}</span>{/if}
|
||||
</div>
|
||||
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
|
||||
<div class="actions">
|
||||
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
|
||||
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if}
|
||||
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View rollout</Button>{/if}
|
||||
</div>
|
||||
{#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if}
|
||||
{#if flash}<p role="alert" class="ui-error">{flash}</p>{/if}
|
||||
{#if success}<p role="status">{success}</p>{/if}
|
||||
</Card>
|
||||
{#if !closed && waiting.length}
|
||||
<section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list">
|
||||
{#each waiting as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server} /></a><StatusBadge state={server.stage} /><p>{server.error ?? outstanding(server.server)}</p><a href={server.error ? `/servers/${encodeURIComponent(server.server)}?tab=diagnostics` : '#test-results'}>{server.error ? 'Diagnose' : 'View checks'}</a></div>{/each}
|
||||
</div></section>
|
||||
{/if}
|
||||
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
|
||||
<details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details>
|
||||
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
|
||||
{#each ranked as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server}/></a><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'completed' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
|
||||
</div></details>
|
||||
<OperationHistory steps={betaFlow({...beta,testPlan:!!test},servers)} {events} active={!closed}/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog bind:open={reviewOpen} title="Review promotion" description="Review the exact files and server eligibility before installing the promoted versions." busy={acting === 'promote'} wide>
|
||||
{#if acting === 'preview'}<p role="status">Preparing promotion impact…</p>{/if}
|
||||
{#if reviewError}<p class="ui-error" role="alert">{reviewError}</p>{/if}
|
||||
{#if preview}
|
||||
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
|
||||
<p>Files are installed when you confirm. Tested servers have already restarted. Additional servers restart when empty, then their health is checked. Eligibility is determined by each file's current scope.</p>
|
||||
<p class="muted">Reviewed {new Date(preview.createdAt).toLocaleString()}. If targets, versions, or composed settings change, you must review again.</p>
|
||||
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
|
||||
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
|
||||
{#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if}
|
||||
{:else if acting !== 'preview'}<Button onclick={reviewPromotion}>Retry preview</Button>{/if}
|
||||
{#snippet actions()}<Button onclick={() => reviewOpen = false} disabled={acting === 'promote'}>Cancel</Button>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}>Refresh review</Button><Button variant="primary" onclick={promote} loading={acting === 'promote'} disabled={acting !== null || !!preview.blockers.length}>Promote to {preview.servers.length} servers</Button>{/if}{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Artifacts */
|
||||
.script-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.script-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(160px, 220px) 1fr 80px 100px;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.s-name { color: var(--text-primary); text-decoration: none; font-weight: var(--weight-semibold); }
|
||||
.s-name:hover { color: var(--accent-primary-hover); text-decoration: underline; }
|
||||
.s-target { color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--text-xs); word-break: break-all; }
|
||||
.s-ver, .s-sha { color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
/* Options */
|
||||
.opts { display: flex; gap: var(--space-6); flex-wrap: wrap; }
|
||||
.opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 200px;
|
||||
}
|
||||
.opt-k {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.opt-v { font-size: var(--text-md); color: var(--text-primary); font-weight: var(--weight-semibold); }
|
||||
|
||||
/* Server progress */
|
||||
.srv-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.srv-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) auto 1fr auto auto;
|
||||
gap: var(--space-3);
|
||||
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);
|
||||
}
|
||||
.srv-name { color: var(--text-primary); text-decoration: none; font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
.srv-name:hover { color: var(--accent-primary-hover); }
|
||||
|
||||
/* Pipeline ticks */
|
||||
.ticks {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.ticks span { position: relative; padding-left: 14px; }
|
||||
.ticks span::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-quaternary);
|
||||
background: transparent;
|
||||
transition: background-color var(--dur-base) var(--ease-standard), border-color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
.ticks span.on { color: var(--text-primary); }
|
||||
.ticks span.on::before {
|
||||
background: var(--state-online);
|
||||
border-color: var(--state-online);
|
||||
box-shadow: 0 0 5px rgba(124, 212, 255, 0.45);
|
||||
}
|
||||
.err {
|
||||
color: var(--state-danger);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.action-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.flash {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(255, 86, 112, 0.06);
|
||||
border: 1px solid rgba(255, 86, 112, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-danger);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.script-row, .srv-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
.page { display:grid; gap:24px; }
|
||||
h2 { margin:0; font-size:18px; } p { line-height:1.6; overflow-wrap:anywhere; }
|
||||
.summary-heading,.actions,.timing { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.summary-heading { justify-content:space-between; } .timing,.muted,.summary-heading span { color:var(--text-secondary); font-size:13px; }
|
||||
.timing strong { color:var(--text-primary); } .actions { margin-top:16px; } .reason { border-left:3px solid var(--state-warning); padding-left:12px; }
|
||||
.server-list,.file-list { display:grid; gap:12px; margin-top:16px; }
|
||||
.server-row { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:8px 16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
.server-row p { margin:0; font-size:13px; } .server-row a,.file-list a { color:var(--accent-primary-hover); }
|
||||
.file-list > div { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
||||
.file-list code { overflow-wrap:anywhere; } .file-list li { margin:10px 0; } .file-list li span { margin-left:12px; font-size:12px; color:var(--text-secondary); }
|
||||
details > summary { cursor:pointer; font-weight:600; } #test-results { scroll-margin-top:80px; }
|
||||
@media(max-width:600px) { .server-row { grid-template-columns:1fr; } .timing { align-items:flex-start; flex-direction:column; } }
|
||||
</style>
|
||||
|
||||
+203
-400
@@ -1,422 +1,225 @@
|
||||
<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 { beforeNavigate, goto } from '$app/navigation';
|
||||
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
|
||||
import { betaDraftKey, readBetaDraft, compatibleBetaServers, type BetaDraft } from '$lib/beta-draft.js';
|
||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
|
||||
import { fleetIdentity, freshServer } from '$lib/fleet-overview.js';
|
||||
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 { serverTimeNow } from '$ui/stores/servers.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import BetaPlanBuilder from '$ui/components/BetaPlanBuilder.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.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[]>([]);
|
||||
interface FileRow { id:number; name:string; kind:string; target_rel_path:string; scope_type:'game'|'map'|null; scope_value:string|null; current_version_id:number|null; latestVersion:{id:number}|null; runningBetaId:number|null; }
|
||||
interface Version { id:number; version:number; release_version:string|null; sha256:string; state:string; }
|
||||
interface FileDetail { script:FileRow; versions:Version[]; matchedServers:string[]; runningBetaId?:number|null; }
|
||||
interface Pick { key:number; scriptId:number|null; versionId:number|null; }
|
||||
let scripts = $state<FileRow[]>([]), detailById = $state<Record<number,FileDetail>>({}), nextKey=0;
|
||||
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
|
||||
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
|
||||
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
|
||||
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state('');
|
||||
let availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
|
||||
let hasLinkedSetup = $state(false);
|
||||
let loadingVersions = $state<Set<number>>(new Set());
|
||||
const rowRequests = new Map<number, number>();
|
||||
let versionRequest = 0;
|
||||
let betaLabel = $state('');
|
||||
let search = $state(''), serverGame = $state(''), availability = $state(''), now = $state(Date.now());
|
||||
const steps = ['Files','Servers','Test plan','Review'];
|
||||
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 eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null));
|
||||
const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId)));
|
||||
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
|
||||
const candidateServers = $derived.by(()=>{
|
||||
const scopes=picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file);
|
||||
return scopes.length ? fleetServers.filter(server=>scopes.every(file=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value)).map(server=>server.name) : [];
|
||||
});
|
||||
const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{
|
||||
const identity=fleetIdentity(server), fresh=freshServer(server,now);
|
||||
return (!serverGame || identity.game===serverGame) && (!availability || (availability==='empty' ? fresh&&server.last_players===0 : availability==='occupied' ? fresh&&(server.last_players??0)>0 : !fresh))
|
||||
&& `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase());
|
||||
}));
|
||||
const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)})));
|
||||
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? !pickedServers.size ? 'Select at least one compatible test server.' : '' : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady || !pickedServers.size || [...pickedServers].some(name=>!candidateServers.includes(name)) ? 'Recheck the selected files and servers before starting.' : '');
|
||||
|
||||
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;
|
||||
}
|
||||
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
|
||||
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
|
||||
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
|
||||
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
|
||||
function reconcileSelection() {
|
||||
// Failed refreshes can leave an empty or retained snapshot; neither is
|
||||
// authoritative enough to remove selections from the saved draft.
|
||||
if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return;
|
||||
const result=compatibleBetaServers(pickedServers,candidateServers);
|
||||
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match every file: ${result.removed.join(', ')}.`; }
|
||||
}
|
||||
async function loadSelections(selections:BetaDraft['picks']) {
|
||||
const warnings:string[]=[];
|
||||
picks=await Promise.all(selections.map(async selection=>{
|
||||
const pick={key:nextKey++,...selection};
|
||||
if(pick.scriptId===null)return pick;
|
||||
const listed=scripts.find(file=>file.id===pick.scriptId);
|
||||
if(!listed || listed.runningBetaId!==null) { warnings.push(`File #${pick.scriptId} is unavailable or in another beta. Choose a replacement.`); return {...pick,scriptId:null,versionId:null}; }
|
||||
try {
|
||||
const response=await fetch(`/api/scripts/${pick.scriptId}`);
|
||||
if(!response.ok)throw Error();
|
||||
const detail=await response.json() as FileDetail;
|
||||
detailById[pick.scriptId]=detail;
|
||||
if(detail.runningBetaId!=null) { warnings.push(`${listed.name} is in beta #${detail.runningBetaId}. Choose a replacement.`); return {...pick,scriptId:null,versionId:null}; }
|
||||
if(!detail.versions.some(v=>v.id===pick.versionId) || detail.script.current_version_id===pick.versionId) { warnings.push(`${listed.name}: saved version ${pick.versionId??''} is unavailable or already promoted. Select an unpromoted version; no newer version was substituted.`); pick.versionId=null; }
|
||||
return pick;
|
||||
} catch { warnings.push(`${listed.name}: versions could not be loaded. Choose the file again to retry.`); return {...pick,versionId:null}; }
|
||||
}));
|
||||
if(warnings.length) { flash=warnings.join(' '); step=0; }
|
||||
}
|
||||
async function initialize(ignoreDraft = false) {
|
||||
loading=true; flash=''; draftReady=false;
|
||||
try {
|
||||
const response=await fetch('/api/scripts');
|
||||
if(!response.ok)throw new Error('Could not load files for beta setup. Retry to continue.');
|
||||
scripts=(await response.json()).scripts;
|
||||
await refreshFleet();
|
||||
const query=new URLSearchParams(window.location.search), selections=parseBetaSelection(query), reuse=Number(query.get('reuse'));
|
||||
hasLinkedSetup=selections.length>0 || reuse>0;
|
||||
// Returning from a file detail retains the original selection URL. A
|
||||
// stored draft must be offered before that URL can seed a fresh setup.
|
||||
if(!ignoreDraft) {
|
||||
try { availableDraft=readBetaDraft(localStorage.getItem(betaDraftKey)); } catch { storageUnavailable=true; }
|
||||
if(availableDraft) {
|
||||
if(hasLinkedSetup)notice='An unfinished draft is saved. Resume it, or discard it to start from the linked files and exact versions.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(reuse>0) {
|
||||
const response=await fetch(`/api/betas/${reuse}`);
|
||||
if(!response.ok)throw new Error('The beta to reuse is unavailable. Choose files below.');
|
||||
const source=await response.json();
|
||||
if(!['promoted','rolled-back'].includes(source.beta.state))throw new Error('Roll back or promote the original beta before reusing its files.');
|
||||
if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan);
|
||||
betaLabel=source.beta.notes ?? '';
|
||||
pickedServers=new Set(source.servers.map((s:{server:string})=>s.server));
|
||||
await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null})));
|
||||
reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true;
|
||||
} else if(selections.length) { await loadSelections(selections); draftReady=true; }
|
||||
else draftReady=true;
|
||||
} catch(error) { flash=(error as Error).message; draftReady=true; }
|
||||
finally { loading=false; }
|
||||
}
|
||||
async function resumeDraft() {
|
||||
if(!availableDraft)return;
|
||||
const draft=availableDraft; loading=true; flash=''; notice='';
|
||||
try { testPlan=draft.testPlan; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
|
||||
finally { availableDraft=null; loading=false; draftReady=true; }
|
||||
}
|
||||
function discardDraft() {
|
||||
const useLinked=!!availableDraft && hasLinkedSetup;
|
||||
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
|
||||
try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
|
||||
draftReady=true;
|
||||
if(useLinked)void initialize(true);
|
||||
}
|
||||
$effect(()=>{
|
||||
if(!draftReady || loading || openingBeta)return;
|
||||
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],testPlan:JSON.parse(JSON.stringify(testPlan))};
|
||||
if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
|
||||
});
|
||||
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
|
||||
beforeNavigate(navigation=>{
|
||||
if(!dirty || !storageUnavailable || allowLeave || openingBeta)return;
|
||||
if(navigation.willUnload){navigation.cancel();return;}
|
||||
if(navigation.to){navigation.cancel();void confirmAction({title:'Leave unfinished beta?',message:'This browser cannot save the draft. Leaving will discard your unfinished setup.',confirmLabel:'Leave setup',danger:true}).then(confirmed=>{if(confirmed){allowLeave=true;void goto(navigation.to!.url);}});}
|
||||
});
|
||||
onMount(()=>{void initialize();const timer=setInterval(()=>now=serverTimeNow(),10000);const unload=(event:BeforeUnloadEvent)=>{if(dirty&&storageUnavailable&&!allowLeave&&!openingBeta){event.preventDefault();event.returnValue='';}};window.addEventListener('beforeunload',unload);return()=>{clearInterval(timer);window.removeEventListener('beforeunload',unload);};});
|
||||
async function onPickFile(index:number) {
|
||||
const pick=picks[index], scriptId=pick.scriptId, current=++versionRequest; rowRequests.set(pick.key,current); pick.versionId=null; flash='';
|
||||
if(scriptId===null){loadingVersions=new Set([...loadingVersions].filter(key=>key!==pick.key));reconcileSelection();return;}
|
||||
loadingVersions=new Set([...loadingVersions,pick.key]);
|
||||
try {
|
||||
const response=await fetch(`/api/scripts/${scriptId}`);
|
||||
if(!response.ok)throw new Error('Could not load versions. Choose the file again to retry.');
|
||||
const detail=await response.json() as FileDetail;
|
||||
if(rowRequests.get(pick.key)!==current || pick.scriptId!==scriptId || !picks.some(p=>p.key===pick.key))return;
|
||||
detailById[scriptId]=detail;
|
||||
pick.versionId=versionsFor(pick)[0]?.id??null; picks=[...picks]; reconcileSelection();
|
||||
}catch(error){if(rowRequests.get(pick.key)===current)flash=(error as Error).message;}
|
||||
finally{if(rowRequests.get(pick.key)===current)loadingVersions=new Set([...loadingVersions].filter(key=>key!==pick.key));}
|
||||
}
|
||||
function toggleServer(name:string) { const next=new Set(pickedServers);next.has(name)?next.delete(name):next.add(name);pickedServers=next; }
|
||||
function selectVisible(select:boolean) { const next=new Set(pickedServers);for(const server of visibleServers)select?next.add(server.name):next.delete(server.name);pickedServers=next; }
|
||||
async function move(next:number) {
|
||||
flash='';
|
||||
if(next>step && step===2) { if(!await planBuilder?.validate())return;try{parseBetaPlan(testPlan);}catch(error){flash=(error as Error).message;return;} }
|
||||
step=next;await tick();stepHeading?.focus();
|
||||
}
|
||||
async function openBeta(event:Event) {
|
||||
event.preventDefault();if(step!==3||forwardReason||openingBeta)return;
|
||||
if(betaPlanIssues(testPlan,pickedServers.size).length){await move(2);await planBuilder?.validate();return;}
|
||||
openingBeta=true;flash='';
|
||||
try {
|
||||
const response=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),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
|
||||
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
|
||||
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
|
||||
await goto(`/betas/${body.result.betaId}`);
|
||||
}catch(error){flash=(error as Error).message;}finally{openingBeta=false;}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
|
||||
<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}
|
||||
<SectionHeader title="New beta" subtitle="Choose exact file versions 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 !scripts.length}<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." />
|
||||
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||
{#if loading}<EmptyState title="Loading beta setup…"/>
|
||||
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {new Date(availableDraft.savedAt).toLocaleString()} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
||||
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
|
||||
{: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>
|
||||
<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>
|
||||
<div class="draft-status">{#if storageUnavailable}<span role="alert">Draft saving is unavailable in this browser. Keep this page open to preserve your work.</span>{:else}<span>Unfinished changes save in this browser.</span>{/if}{#if dirty}<Button variant="ghost" size="sm" onclick={async()=>{if(await confirmAction({title:'Discard beta draft?',message:'Clear the selected files, servers, and custom test plan?',confirmLabel:'Discard draft',danger:true}))discardDraft();}}>Discard draft</Button>{/if}</div>
|
||||
<form onsubmit={openBeta} class="beta-form">
|
||||
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
||||
{#if step===0}
|
||||
<label>Beta label (optional)<input bind:value={betaLabel} maxlength="120" placeholder="For example, Town balance update"/><span class="muted">Shown in the beta list so others can identify this test.</span></label>
|
||||
<div class="picks">{#each picks as pick,index(pick.key)}<section class="pick-row">
|
||||
<label>File {index+1}<select bind:value={pick.scriptId} onchange={()=>onPickFile(index)}><option value={null}>Choose a file</option>{#each eligibleFor(index) as file}<option value={file.id}>[{file.kind}] {file.name}</option>{/each}</select></label>
|
||||
<label>Exact version<select bind:value={pick.versionId} disabled={pick.scriptId===null||loadingVersions.has(pick.key)}><option value={null}>{loadingVersions.has(pick.key)?'Loading versions…':'Choose a version'}</option>{#each versionsFor(pick) as version}<option value={version.id}>{artifactVersionLabel(version)} · {version.sha256.slice(0,8)} · {version.state.replaceAll('-',' ')}</option>{/each}</select></label>
|
||||
<Button ariaLabel={`Remove file ${index+1}`} disabled={picks.length===1} onclick={()=>{picks=picks.filter(p=>p.key!==pick.key);reconcileSelection();}}>Remove</Button>
|
||||
{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<p class="scope">{scopeText(file)} · {scopeCount(file)} eligible servers · <a href={`/${file.kind==='baseline'?'configs':file.kind+'s'}/${file.id}`}>Inspect file and versions</a>{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}<br/>No unpromoted versions. Upload a new version from the file page.{/if}</p>{/if}{/if}
|
||||
</section>{/each}</div>
|
||||
<Button onclick={()=>picks=[...picks,{key:nextKey++,scriptId:null,versionId:null}]} disabled={picks.length>=eligibleFiles.length}>Add file</Button>
|
||||
<p class="muted">Test servers must match every file's scope. Changing a file retains compatible server selections.</p>
|
||||
{:else if step===1}
|
||||
<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>
|
||||
<details><summary>Selected file scopes</summary><ul>{#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<li>{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers</li>{/if}{/if}{/each}</ul></details>
|
||||
{#if !candidateServers.length}<div class="ui-error">The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.</div>
|
||||
{:else}
|
||||
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
|
||||
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
|
||||
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
||||
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
||||
{/if}
|
||||
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
|
||||
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</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 automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p></section>{/if}
|
||||
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {pickedServers.size} servers</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; }
|
||||
}
|
||||
.beta-form input:not([type='checkbox']), .beta-form select { border-color:var(--border-control); }
|
||||
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
|
||||
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
|
||||
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
|
||||
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
|
||||
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
|
||||
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
|
||||
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }
|
||||
@media(max-width:700px) { .pick-row { grid-template-columns:minmax(0,1fr) auto; } .pick-row label:first-child { grid-column:1/-1; } .steps { gap:10px; } .steps li { font-size:12px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import type { DriftPlan, DriftResult } from '$lib/drift-plan.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
@@ -51,7 +55,7 @@
|
||||
driftKind: 'untracked' | 'missing' | 'replace';
|
||||
}
|
||||
interface DriftServer { server: string; files: DriftFile[] }
|
||||
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
|
||||
interface DriftPreview { totalDrift: number; servers: DriftServer[]; scannedAt: string }
|
||||
|
||||
let loadError = $state('');
|
||||
let limit = $state(25);
|
||||
@@ -64,10 +68,27 @@
|
||||
let running = $state(false);
|
||||
let rollingBack = $state<string | null>(null);
|
||||
let flash = $state<string | null>(null);
|
||||
let flashError = $state(false);
|
||||
let flashId = $state('');
|
||||
let selected = $state<string[]>([]);
|
||||
let reviewOpen = $state(false);
|
||||
let plan = $state<DriftPlan | null>(null);
|
||||
let planError = $state('');
|
||||
let reviewing = $state(false);
|
||||
let reviewAll = $state(false);
|
||||
let result = $state<DriftResult | null>(null);
|
||||
const skipped = $derived(result?.perServer.filter(server => server.skipped.length) ?? []);
|
||||
let driftError = $state('');
|
||||
// Drift panel opens automatically when there's a problem; collapses when clean.
|
||||
let driftOpen = $state(false);
|
||||
let driftFilter = $state('');
|
||||
$effect(() => { driftFilter = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||
function saveContext() {
|
||||
const url = new URL($page.url);
|
||||
if (driftFilter) url.searchParams.set('server', driftFilter); else url.searchParams.delete('server');
|
||||
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
async function loadCampaigns(): Promise<void> {
|
||||
try {
|
||||
@@ -84,6 +105,7 @@
|
||||
const res = await fetch('/api/drift');
|
||||
if (!res.ok) throw new Error('Could not scan drift.');
|
||||
drift = (await res.json()) as DriftPreview;
|
||||
selected = selected.filter(name => drift!.servers.some(server => server.server === name));
|
||||
driftError = '';
|
||||
} catch (error) { driftError = (error as Error).message;
|
||||
} finally {
|
||||
@@ -109,33 +131,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function reviewDrift(all: boolean, targets = selected): Promise<void> {
|
||||
reviewOpen = true; reviewing = true; plan = null; planError = ''; reviewAll = all;
|
||||
try {
|
||||
const response = await fetch('/api/drift', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dryRun: true, servers: all ? undefined : targets }) });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not prepare the reconciliation plan.');
|
||||
plan = body.plan;
|
||||
} catch (error) { planError = (error as Error).message; }
|
||||
finally { reviewing = false; }
|
||||
}
|
||||
|
||||
async function runDrift(): Promise<void> {
|
||||
if (!drift || drift.totalDrift === 0) return;
|
||||
const parts: string[] = [];
|
||||
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 (!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;
|
||||
if (!plan || running) return;
|
||||
running = true; planError = ''; result = null;
|
||||
try {
|
||||
const res = await fetch('/api/drift', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify({ servers: reviewAll ? undefined : plan.servers, expectedFingerprint: plan.fingerprint })
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean; error?: string;
|
||||
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
|
||||
};
|
||||
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
|
||||
const removed = body.result?.totalRemoved ?? 0;
|
||||
const restored = body.result?.totalRestored ?? 0;
|
||||
const parts2: string[] = [];
|
||||
if (removed > 0) parts2.push(`removed ${removed}`);
|
||||
if (restored > 0) parts2.push(`installed ${restored}`);
|
||||
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok) { if (body.plan) plan = body.plan; throw new Error(body.error ?? 'Reconciliation failed.'); }
|
||||
result = body.result; reviewOpen = false;
|
||||
await Promise.all([loadCampaigns(), scanDrift()]);
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
} catch (error) { planError = (error as Error).message; } finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
@@ -174,14 +194,14 @@
|
||||
const summary = scriptSummary(d) || 'this promote';
|
||||
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;
|
||||
flash = null; flashId = d.id; flashError = false;
|
||||
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; }
|
||||
if (!res.ok || !body.ok) { flashError = true; flash = body.error ?? 'Rollback failed'; return; }
|
||||
flash = 'Rollback started';
|
||||
await loadCampaigns();
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
} catch (error) { flashError = true; flash = (error as Error).message; } finally {
|
||||
rollingBack = null;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +225,8 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Rollouts | Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
title="Rollouts"
|
||||
@@ -249,28 +271,32 @@
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={runDrift}
|
||||
disabled={running || scanning || !drift || drift.totalDrift === 0}
|
||||
onclick={() => reviewDrift(false)}
|
||||
disabled={running || scanning || reviewing || !selected.length}
|
||||
loading={running}
|
||||
>
|
||||
Reconcile
|
||||
Review {selected.length} selected
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onclick={() => reviewDrift(true)} disabled={running || scanning || reviewing || !drift?.totalDrift}>Review entire fleet</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if driftOpen}
|
||||
<div class="drift-body" id="drift-body">
|
||||
{#if drift}<p class="scan-context">Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.</p>{/if}
|
||||
{#if drift && drift.totalDrift > 0}
|
||||
<input
|
||||
class="drift-filter" aria-label="Filter drift by server"
|
||||
type="search"
|
||||
placeholder="Filter by server name…"
|
||||
bind:value={driftFilter}
|
||||
oninput={event => { driftFilter = event.currentTarget.value; saveContext(); }}
|
||||
/>
|
||||
<div class="selection-actions"><Button size="sm" onclick={() => selected = [...new Set([...selected, ...filteredDriftServers.map(server => server.server)])]}>Select visible</Button><Button size="sm" variant="ghost" disabled={!selected.length} onclick={() => selected = []}>Clear selection</Button><span>{selected.length} selected</span></div>
|
||||
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
|
||||
<div class="drift-server">
|
||||
<div class="drift-server-head">
|
||||
<span class="server-name"><ServerIdentity name={s.server} /></span>
|
||||
<label class="server-name"><input type="checkbox" checked={selected.includes(s.server)} onchange={event => selected = event.currentTarget.checked ? [...selected, s.server] : selected.filter(name => name !== s.server)} /> <ServerIdentity name={s.server} /></label>
|
||||
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="drift-files">
|
||||
@@ -296,9 +322,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flash}
|
||||
<div class="flash">{flash}</div>
|
||||
{/if}
|
||||
{#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}>
|
||||
<strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong>
|
||||
<p>{result.totalRemoved} files removed; {result.totalRestored} installed. {result.totalServers ? 'Affected servers restart when empty, then health is verified.' : skipped.length ? 'No changes could be applied. Review the reasons below.' : 'The reviewed servers already match the intended files.'}</p>
|
||||
{#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View rollout →</a>{/if}
|
||||
{#each skipped as server}<div><a href="/servers/{encodeURIComponent(server.server)}?tab=diagnostics"><ServerIdentity name={server.server} /></a><ul>{#each server.skipped as reason}<li>{reason}</li>{/each}</ul></div>{/each}
|
||||
{#if skipped.length}<Button onclick={() => reviewDrift(false, skipped.map(server => server.server))}>Review unresolved servers</Button>{/if}
|
||||
</div>{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Active pipelines -->
|
||||
@@ -345,12 +375,25 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={history.length} bind:limit label="rollouts" />
|
||||
<ListFooter total={history.length} bind:limit onchange={saveContext} label="rollouts" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog bind:open={reviewOpen} title="Review reconciliation" busy={running || reviewing} wide>
|
||||
{#if planError}<p class="ui-error" role="alert">{planError}</p>{/if}
|
||||
{#if reviewing}<p role="status">Comparing current files with their intended versions…</p>
|
||||
{:else if plan}
|
||||
<p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p>
|
||||
<p>Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.</p>
|
||||
<p class="scan-context">Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.</p>
|
||||
<div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div>
|
||||
{/if}
|
||||
{#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet}
|
||||
</Dialog>
|
||||
|
||||
{#snippet deployRow(d: Deployment)}
|
||||
<article class="deploy-row">
|
||||
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
|
||||
<div class="row-head">
|
||||
<div class="row-head-l">
|
||||
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
|
||||
@@ -384,7 +427,7 @@
|
||||
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="node-label">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
|
||||
<a class="node-label" href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</a><StatusBadge state={s.restart_state} />
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -403,6 +446,16 @@
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.scan-context { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.selection-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 12px; }
|
||||
.result { padding: 16px; margin-top: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); line-height: 1.6; }
|
||||
.result-error { border-color: var(--state-warning); }
|
||||
.result p { margin-block: 8px; }
|
||||
.result a { color: var(--accent-primary-hover); }
|
||||
.plan-files { display: grid; gap: 20px; }
|
||||
.plan-file { display: grid; gap: 8px; padding-block: 12px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
.plan-file span, .plan-file details { font-size: 13px; color: var(--text-secondary); }
|
||||
.server-name { display: flex; align-items: center; gap: 10px; }
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -505,15 +558,6 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-online);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
let diffLoading = $state(false);
|
||||
let diffRequest = 0;
|
||||
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||
let retryFile = $state<FileRow | null>(null);
|
||||
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
|
||||
const unresolved = $derived(detail?.servers.filter(server => server.restart_state !== 'done').length ?? 0);
|
||||
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
|
||||
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
|
||||
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
|
||||
}));
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
const id = $page.params.id;
|
||||
@@ -64,6 +71,7 @@
|
||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||
|
||||
async function loadDiff(f: FileRow) {
|
||||
retryFile = f;
|
||||
const id = $page.params.id;
|
||||
const current = ++diffRequest;
|
||||
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
|
||||
@@ -101,13 +109,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Rollout | Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>{detail ? `${detail.deployment.action} rollout · ${detail.deployment.id}` : 'Rollout'} | Electric Cherry</title></svelte:head>
|
||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
||||
|
||||
{#if detail}
|
||||
{@const action = detail.deployment.action}
|
||||
<div class="page">
|
||||
<SectionHeader title={action} subtitle={detail.deployment.id}>
|
||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} rollout`} subtitle={detail.deployment.id}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Rollouts', href: '/campaigns' },
|
||||
@@ -116,6 +124,8 @@
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<section class="outcome" aria-label="Rollout outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>Files are staged before activation. Servers restart when empty, then their health is verified.</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="meta-grid">
|
||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
||||
@@ -125,19 +135,13 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OperationHistory
|
||||
steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))}
|
||||
events={detail.events}
|
||||
active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')}
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Servers</h2>
|
||||
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
||||
</div>
|
||||
<div class="server-list">
|
||||
{#each detail.servers as s (s.server)}
|
||||
{#each rankedServers as s (s.server)}
|
||||
<div class="srv-row">
|
||||
<div class="srv-l">
|
||||
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
|
||||
@@ -148,12 +152,13 @@
|
||||
<div class="srv-r">
|
||||
<StatusBadge state={s.restart_state} />
|
||||
{#if s.players !== null && s.players > 0}
|
||||
<span class="players">{s.players}p</span>
|
||||
<span class="players">{s.players} players at last check</span>
|
||||
{/if}
|
||||
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
||||
{#if s.restarted_at}
|
||||
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
||||
{/if}
|
||||
{#if s.restart_state !== 'done'}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -189,8 +194,9 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')} />
|
||||
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p>
|
||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
|
||||
{: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>
|
||||
@@ -200,6 +206,7 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.outcome { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }.outcome h2 { font-size: 20px; margin: 0; }.outcome p { color: var(--text-secondary); line-height: 1.6; margin: 10px 0 0; }.srv-r a { color: var(--accent-primary-hover); font-size: 13px; }
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
@@ -19,9 +21,10 @@
|
||||
|
||||
interface Deployment {
|
||||
id: string; server: string; action: 'create' | 'destroy';
|
||||
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
||||
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
canCancelRemoval: boolean;
|
||||
}
|
||||
let deployments = $state<Deployment[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -42,7 +45,34 @@
|
||||
let limit = $state(25);
|
||||
let collectionLimit = $state(10);
|
||||
let historyState = $state('all');
|
||||
$effect(() => { action; search; tab; historyState; limit = 25; collectionLimit = 10; });
|
||||
let cancelling = $state(false);
|
||||
let cancellationNotice = $state('');
|
||||
$effect(() => {
|
||||
const params = $page.url.searchParams;
|
||||
tab = ['active', 'blueprints', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
|
||||
action = ['create', 'destroy'].includes(params.get('action') ?? '') ? params.get('action')! : 'all';
|
||||
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||
search = params.get('server') ?? '';
|
||||
limit = Math.max(25, Number(params.get('limit')) || 25);
|
||||
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
|
||||
});
|
||||
function saveContext(reset = false) {
|
||||
if (reset) { limit = 25; collectionLimit = 10; }
|
||||
const url = new URL($page.url);
|
||||
for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '', collections: collectionLimit > 10 ? String(collectionLimit) : '' })) {
|
||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||
}
|
||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
async function cancelRequests(ids: string[]) {
|
||||
cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
|
||||
for (const id of ids) {
|
||||
try { const response = await fetch(`/api/deployments/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
|
||||
catch { unavailable.push(id); }
|
||||
}
|
||||
cancellationNotice = `${cancelled} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`;
|
||||
cancelling = false; await load();
|
||||
}
|
||||
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(); });
|
||||
@@ -93,9 +123,6 @@
|
||||
}
|
||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
function status(d: Deployment) {
|
||||
return { queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
@@ -103,12 +130,13 @@
|
||||
<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" />
|
||||
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} 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} />
|
||||
<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}
|
||||
{#if cancellationNotice}<p role="status">{cancellationNotice}</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>
|
||||
@@ -129,7 +157,7 @@
|
||||
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
|
||||
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
|
||||
<div class="blueprint-footer">
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview missing servers →</Button>
|
||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
||||
</div>
|
||||
@@ -138,7 +166,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={blueprints.length} bind:limit label="blueprints" />
|
||||
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="blueprints" />
|
||||
{:else}
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if visibleCollections.length}
|
||||
@@ -152,11 +180,12 @@
|
||||
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow launch' : 'View results'} →</span></div>
|
||||
</a>
|
||||
{/each}</div>
|
||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} step={10} label="collections" />
|
||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} 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}
|
||||
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if}
|
||||
{#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
{#if loading}<p>Loading deployments...</p>
|
||||
@@ -167,13 +196,13 @@
|
||||
<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>
|
||||
<label>Operation <select bind:value={action} onchange={event => { action = event.currentTarget.value; saveContext(true); }}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Removals</option></select></label>
|
||||
<label>Status <select bind:value={historyState} onchange={event => { historyState = event.currentTarget.value; saveContext(true); }}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
|
||||
<label>Server <input bind:value={search} oninput={event => { search = event.currentTarget.value; saveContext(true); }} placeholder="Filter by server name" /></label>
|
||||
</div>
|
||||
{#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" />
|
||||
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -181,7 +210,7 @@
|
||||
</div>
|
||||
|
||||
{#snippet row(d: Deployment)}
|
||||
<a class="deployment-row" href="/deployments/{d.id}">
|
||||
<article class="deployment-row"><a class="row-link" 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"><ServerIdentity name={d.server} /></strong>
|
||||
@@ -192,10 +221,11 @@
|
||||
<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>
|
||||
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||
</a>
|
||||
</a>{#if d.canCancelRemoval}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.row-link { color: inherit; text-decoration: none; display: block; }.row-actions { margin-top: 12px; }
|
||||
|
||||
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
}
|
||||
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; collection: { id: string; name: string } | null }
|
||||
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; canCancelRemoval: boolean; collection: { id: string; name: string } | null }
|
||||
let detail = $state<Detail | null>(null);
|
||||
let error = $state('');
|
||||
let refreshing = false;
|
||||
@@ -46,6 +46,7 @@
|
||||
});
|
||||
let explanation = $derived.by(() => {
|
||||
if (!deployment) return '';
|
||||
if (deployment.state === 'cancelled') return 'The queued removal was cancelled before removal began. The server and its files were kept.';
|
||||
if (detail?.canResumeCleanup) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.';
|
||||
if (deployment.state === 'waiting' && deployment.stage === 'Removing server files and registry') return 'Local cleanup is queued. Komodo deletion has already succeeded.';
|
||||
if (deployment.state === 'done') return deployment.action === 'create'
|
||||
@@ -80,6 +81,17 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
async function cancelRemoval() {
|
||||
if (!deployment || resuming) return;
|
||||
resuming = true; error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/cancel`, { method: 'POST' });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not cancel this request.');
|
||||
await load();
|
||||
} catch (err) { error = (err as Error).message; await load(); error = (err as Error).message; }
|
||||
finally { resuming = false; }
|
||||
}
|
||||
async function load() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
@@ -99,7 +111,7 @@
|
||||
|
||||
<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 ? serverTitle(deployment.server) : undefined}>
|
||||
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Remove server' : '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}
|
||||
@@ -114,18 +126,21 @@
|
||||
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
|
||||
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
|
||||
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
|
||||
{#if detail?.canCancelRemoval}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
|
||||
{#if deployment.server_exists && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
|
||||
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||
{#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>
|
||||
<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 files'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Launch this map with the latest promoted scripts, plugins, and configuration using a fresh lobby and port.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
||||
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch files'}</h2><Button variant="ghost" onclick={() => open = true}>Launch another server →</Button></div><p class="muted">Launch this map with the latest promoted files using a fresh server ID and port. This does not repair the existing server.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
||||
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:deployment.state === 'cancelled' ? 'pending' : index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
|
||||
|
||||
{:else if !error}<p>Loading deployment...</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recovery-links { display: flex; gap: 16px; flex-wrap: wrap; font-size: 14px; }
|
||||
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
||||
.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; }
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
let search = $state('');
|
||||
let mounted = $state(false);
|
||||
let checkedAt = $state<Date | null>(null);
|
||||
let totalsOpen = $state(true);
|
||||
let fullTable = $state(false);
|
||||
let controller: AbortController | undefined;
|
||||
let requestId = 0;
|
||||
let loadedUrl = '';
|
||||
@@ -63,6 +65,7 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
totalsOpen = !window.matchMedia('(max-width: 640px)').matches;
|
||||
mounted = true;
|
||||
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
|
||||
const interval = setInterval(refreshVisible, 15_000);
|
||||
@@ -170,6 +173,7 @@
|
||||
{/if}
|
||||
|
||||
{#if data}
|
||||
<details class="totals" bind:open={totalsOpen}><summary>Fleet totals · {number.format(data.summary.players)} players · Round {number.format(data.summary.highestRound)} record</summary>
|
||||
<div class="overview" aria-label="Totals for the selected game and map">
|
||||
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
|
||||
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
|
||||
@@ -177,13 +181,15 @@
|
||||
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
|
||||
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading}>
|
||||
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
||||
<div class="rankings-header">
|
||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||
</div>
|
||||
{#if data.entries.length}
|
||||
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player rankings" tabindex="0">
|
||||
<table>
|
||||
@@ -239,12 +245,14 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.totals summary { cursor: pointer; padding: 10px 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.mobile-view { display: none; }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); padding: var(--space-5); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||
label { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
input, select, button { font: inherit; }
|
||||
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
|
||||
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
|
||||
input::placeholder { color: var(--text-tertiary); }
|
||||
.search > div { display: flex; gap: var(--space-2); }
|
||||
.search input { flex: 1; min-width: 0; width: 0; }
|
||||
@@ -298,6 +306,14 @@
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview > div { padding: var(--space-4); } .overview small { display: none; } }
|
||||
@media (max-width: 640px) {
|
||||
.mobile-view { display: block; padding: 0 16px 12px; }
|
||||
.compact .stat:not(.selected) { display: none; }
|
||||
.compact .player { min-width: 110px; max-width: 160px; position: static; }
|
||||
.compact .identity :global(.copy) { display: none; }
|
||||
.compact .stat { white-space: normal; }
|
||||
.compact .rank { width: 44px; }
|
||||
.compact th, .compact td { padding: 10px 8px; }
|
||||
.compact thead button { white-space: normal; text-align: right; }
|
||||
.page { gap: var(--space-4); }
|
||||
.filters { padding: var(--space-4); gap: var(--space-3); }
|
||||
.search { grid-column: 1 / -1; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
@@ -16,6 +17,7 @@
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
interface ArtifactRow {
|
||||
@@ -39,6 +41,8 @@
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||
|
||||
let addOpen = $state(false);
|
||||
let githubBusy = $state(false);
|
||||
let importComplete = $state<{ pluginId: number; betaUrl: string; alreadyImported: boolean } | null>(null);
|
||||
let method = $state('upload');
|
||||
let updatesOpen = $state(false);
|
||||
let search = $state('');
|
||||
@@ -46,7 +50,11 @@
|
||||
let status = $state('');
|
||||
let sort = $state('name');
|
||||
let limit = $state(25);
|
||||
$effect(() => { search; game; status; sort; limit = 25; });
|
||||
let mounted = $state(false);
|
||||
afterNavigate(() => { mounted = true; });
|
||||
let lastFilters = '';
|
||||
$effect(() => { const key = JSON.stringify([search, game, status, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||
$effect(() => { if (mounted) saveLibraryContext({ q: search, game, status, sort, limit }, { sort: 'name', 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)));
|
||||
@@ -93,7 +101,7 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
onMount(() => { const params = new URLSearchParams(window.location.search); search = params.get('q') ?? ''; game = params.get('game') ?? ''; status = params.get('status') ?? ''; sort = params.get('sort') === 'recent' ? 'recent' : 'name'; limit = libraryLimit(params); void load(); });
|
||||
|
||||
function pickFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
@@ -156,25 +164,29 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Plugins · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<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 importComplete}<div class="banner" role="status"><strong>{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'}</strong><span><a href={'/plugins/' + importComplete.pluginId}>View version</a> · <a href={importComplete.betaUrl}>Start beta</a></span></div>{/if}
|
||||
{#if successBanner}
|
||||
<div class="banner">
|
||||
<div class="banner" role="status">
|
||||
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
|
||||
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
<div class="ui-toolbar">
|
||||
{#if loading || plugins.length || flash}<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>
|
||||
</div>{/if}
|
||||
{#if game}<p class="filter-hint">Game results include unscoped plugins that can use explicitly selected beta servers.</p>{/if}
|
||||
{#if plugins.length}<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>{/if}
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>All plugins</h2>
|
||||
@@ -214,10 +226,10 @@
|
||||
</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" />
|
||||
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
|
||||
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading || githubBusy},{id:'github',label:'GitHub release',disabled:uploading || githubBusy}]} 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(); }} />
|
||||
{#if method === 'github'}<PluginGithubImport bind:busy={githubBusy} onimport={async result => { importComplete = result; importCount++; addOpen = false; await load(); }} />
|
||||
{:else} <form onsubmit={createPlugin} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||
@@ -232,7 +244,7 @@
|
||||
<div class="scope-row">
|
||||
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
<Select ariaLabel="Scope game" bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
||||
<Select ariaLabel="Scope game" bind:value={newScopeGame} onchange={() => newScopeMap = ''} options={gameOptions} disabled={uploading} />
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||
@@ -251,7 +263,7 @@
|
||||
>
|
||||
Upload plugin
|
||||
</Button>
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -259,6 +271,7 @@
|
||||
</Dialog>
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.filter-hint { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.upload-form { margin-top: 20px; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
@@ -9,9 +10,14 @@
|
||||
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
||||
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
||||
import { zombieMaps } from '$lib/map-aliases.js';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
|
||||
let limit = $state(25);
|
||||
$effect(() => { search; status; game; map; sort; limit = 25; });
|
||||
let mounted = $state(false);
|
||||
afterNavigate(() => { mounted = true; });
|
||||
let lastFilters = '';
|
||||
$effect(() => { const key = JSON.stringify([search, status, game, map, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||
$effect(() => { if (mounted) saveLibraryContext({ q: search, status, game, map, sort, limit, selected: selected.join(',') }, { status: 'all', sort: 'attention', limit: 25 }); });
|
||||
let scripts = $state<LibraryScript[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
@@ -34,7 +40,7 @@
|
||||
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
||||
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
||||
&& (status === 'all' || scriptStatus(s) === status)
|
||||
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||
&& (!game || !s.scope_type || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
||||
}).sort((a, b) => {
|
||||
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
||||
@@ -42,6 +48,8 @@
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||
const hiddenSelected = $derived(selectedScripts.filter(s => !visible.slice(0, limit).some(row => row.id === s.id)).length);
|
||||
let reviewSelection = $state(false);
|
||||
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() {
|
||||
@@ -54,7 +62,14 @@
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
onMount(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? '';
|
||||
status = tabs.some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all';
|
||||
sort = ['attention', 'recent', 'name'].includes(params.get('sort') ?? '') ? params.get('sort')! : 'attention';
|
||||
limit = libraryLimit(params); selected = (params.get('selected') ?? '').split(',').map(Number).filter(id => Number.isSafeInteger(id) && id > 0).slice(0, 500);
|
||||
void load();
|
||||
});
|
||||
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
||||
function selectVisible() {
|
||||
const ids = selectable.map(s => s.id);
|
||||
@@ -70,6 +85,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Scripts · Electric Cherry</title></svelte:head>
|
||||
|
||||
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
||||
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
||||
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||
@@ -78,15 +95,15 @@
|
||||
<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">
|
||||
{#if loading || scripts.length || error}<div class="status-grid" aria-label="Filter by script status">
|
||||
{#each tabs as tab}
|
||||
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
|
||||
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>{/if}
|
||||
<section class="library" aria-label="Script library">
|
||||
<div class="toolbar">
|
||||
{#if loading || scripts.length || error}<div class="toolbar">
|
||||
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||
@@ -96,7 +113,8 @@
|
||||
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
|
||||
<span>{visible.length} of {scripts.length} scripts</span>
|
||||
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||
</div>
|
||||
</div>{/if}
|
||||
{#if game}<p class="hint">Game results include unscoped scripts that can use explicitly selected beta servers.</p>{/if}
|
||||
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
||||
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
||||
{:else if !scripts.length && !error}
|
||||
@@ -134,9 +152,11 @@
|
||||
</section>
|
||||
{#if selectedScripts.length}
|
||||
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||
<strong>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
|
||||
<strong title={hiddenSelected ? 'Some selected scripts are outside the current filters or loaded rows.' : undefined}>{selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}</strong><span>Latest versions will be carried into beta setup.</span>
|
||||
<Button size="sm" variant="ghost" ariaLabel="Review selected scripts" onclick={() => reviewSelection = !reviewSelection}>Review</Button>
|
||||
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
||||
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||
<Button size="sm" variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||
{#if reviewSelection}<ul class="selection-review">{#each selectedScripts as script}<li>{script.name} · v{script.latestVersion?.version}<Button size="sm" variant="ghost" onclick={() => toggle(script.id)} ariaLabel={`Remove ${script.name} from selection`}>Remove</Button></li>{/each}</ul>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -164,6 +184,9 @@
|
||||
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
||||
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
|
||||
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.selection-review { flex-basis: 100%; margin: 0; padding: 0; list-style: none; max-height: 240px; overflow: auto; }
|
||||
.selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; }
|
||||
@media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; font-size: var(--text-sm); } }
|
||||
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
|
||||
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@@ -172,4 +195,5 @@
|
||||
.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; }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { zombieMaps, zombieMapBySlug, type ZombieGame } from '$lib/map-aliases.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
@@ -15,11 +18,21 @@
|
||||
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('');
|
||||
let editing = $state(false);
|
||||
let rawMode = $state(false);
|
||||
let game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('theater');
|
||||
let lobby = $state(1);
|
||||
const chosenId = $derived(editing || rawMode ? serverId.trim() : `${game}_zombie_${mapSlug}_${lobby}`);
|
||||
const validId = $derived.by(() => {
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(chosenId);
|
||||
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||
});
|
||||
$effect(() => { search = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||
function saveSearch() { const url = new URL($page.url); if (search) url.searchParams.set('server', search); else url.searchParams.delete('server'); if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); void goto(url, { replaceState: true, keepFocus: true, noScroll: true }); }
|
||||
let plutoKey = $state('');
|
||||
let busy = $state(false);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
@@ -33,14 +46,22 @@
|
||||
}
|
||||
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
||||
onMount(() => { void reload(); });
|
||||
function reset() { serverId = ''; plutoKey = ''; editing = false; }
|
||||
function reset() { serverId = ''; plutoKey = ''; editing = false; rawMode = false; }
|
||||
function addKey() {
|
||||
reset(); error = '';
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec($page.url.searchParams.get('server') ?? '');
|
||||
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
|
||||
open = true;
|
||||
}
|
||||
function savedAt(value: string) { return new Date(value.includes('T') ? value : value.replace(' ', 'T') + 'Z').toLocaleString(); }
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!validId || !plutoKey.trim() || busy) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const res = await fetch('/api/server-keys', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverId: serverId.trim(), plutoKey })
|
||||
body: JSON.stringify({ serverId: chosenId, plutoKey })
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
||||
@@ -67,9 +88,9 @@
|
||||
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
||||
<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}
|
||||
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} placeholder="Search servers…" /></div>
|
||||
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} 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}
|
||||
@@ -81,36 +102,42 @@
|
||||
<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>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td>
|
||||
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
|
||||
<td>Stored, not validated<small>Saved {savedAt(key.updated_at)}<br />Used on next deployment</small></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>
|
||||
<Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
|
||||
<Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} 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" />
|
||||
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} 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.">
|
||||
{#if !editing}<label class="raw-toggle"><input type="checkbox" bind:checked={rawMode} disabled={busy} onchange={() => { if (!rawMode) serverId = `${game}_zombie_${mapSlug}_${lobby}`; }} /> Enter a raw server ID</label>{/if}
|
||||
{#if editing || rawMode}<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." error={!validId && serverId ? 'Choose a recognized map and lobby number 1–20.' : null}>
|
||||
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
||||
</Field>
|
||||
</Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if}
|
||||
{#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if}
|
||||
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
|
||||
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
||||
</Field>
|
||||
<div class="actions">
|
||||
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || loading || !validId || !plutoKey.trim()} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
||||
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
|
||||
</Dialog>
|
||||
<style>
|
||||
a { color: var(--accent-primary-hover); }small { display: block; color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 5px; }
|
||||
.id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; }
|
||||
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
|
||||
|
||||
h2 { font-size: var(--text-md); }
|
||||
p { color: var(--text-secondary); }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||
@@ -100,7 +101,13 @@
|
||||
let rconProbe = $state<RconProbeResult | null>(null);
|
||||
let probingRcon = $state(false);
|
||||
let destroyOpen = $state(false);
|
||||
let activeTab = $state('scoreboard');
|
||||
const sectionIds = ['scoreboard', 'logs', 'config', 'inventory', 'diagnostics', 'history'];
|
||||
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard');
|
||||
function changeTab(tab: string) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('tab', tab);
|
||||
void goto(url, { noScroll: true, keepFocus: true });
|
||||
}
|
||||
let tabLoading = $state(false);
|
||||
let tabError = $state('');
|
||||
let historyLimit = $state(25);
|
||||
@@ -147,10 +154,10 @@
|
||||
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 activeElapsed = $derived(fresh && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
||||
let inactiveElapsed = $derived(fresh && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
||||
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
||||
let gameElapsed = $derived(effectiveHealth !== 'starting' && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
|
||||
let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
|
||||
|
||||
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
|
||||
|
||||
@@ -212,6 +219,7 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry</title></svelte:head>
|
||||
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
||||
|
||||
{#if server}
|
||||
@@ -223,9 +231,7 @@
|
||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button variant="danger" onclick={() => destroyOpen = true}>
|
||||
Destroy
|
||||
</Button>
|
||||
<details class="server-actions"><summary>Server actions</summary><Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details>
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
@@ -280,9 +286,15 @@
|
||||
</Card>
|
||||
|
||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
||||
{#if hasDiagnosticIssue}
|
||||
<section class="connection-issue" aria-label="Server connection issue">
|
||||
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
|
||||
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
|
||||
</section>
|
||||
{/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" idPrefix="server-tabs" panelId="server-panel" />
|
||||
<Tabs {tabs} value={activeTab} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
|
||||
@@ -329,9 +341,6 @@
|
||||
</Card>
|
||||
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
|
||||
{:else if activeTab === 'diagnostics'}
|
||||
{#key name}
|
||||
<TelemetryDiagnostics server={name} />
|
||||
{/key}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>RCON diagnostic</h2>
|
||||
@@ -493,6 +502,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
|
||||
{:else if activeTab === 'history'}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
@@ -537,6 +547,12 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.server-actions { text-align: right; }
|
||||
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.server-actions[open] summary { margin-bottom: 10px; }
|
||||
.connection-issue { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.connection-issue p { margin: 5px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.issue-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.server-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { SystemStatus } from '$lib/system-status.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
let data = $state<SystemStatus | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
async function refresh(probe = false) {
|
||||
if (loading) return;
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const response = await fetch('/api/system-status' + (probe ? '?probe=1' : ''));
|
||||
if (!response.ok) throw new Error('Could not load system status. Try again.');
|
||||
data = await response.json();
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void refresh(); });
|
||||
const labels = { ready: 'Ready', attention: 'Needs attention', unconfigured: 'Not configured', unverified: 'Not verified' };
|
||||
</script>
|
||||
|
||||
<svelte:head><title>System status · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="System status" subtitle="Check your connections and the prerequisites for a playable server.">
|
||||
{#snippet actions()}<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}<Button size="sm" onclick={() => refresh()}>Retry</Button></p>{/if}
|
||||
{#if data}
|
||||
<p class="checked">Checked {new Date(data.checkedAt).toLocaleString()} · {data.readyServers} / {data.totalServers} servers ready</p>
|
||||
<ol class="checks">{#each data.checks as check}<li><div class="heading"><h2>{check.title}</h2><Badge variant={check.state === 'ready' ? 'online' : check.state === 'unverified' ? 'neutral' : 'warning'}>{labels[check.state]}</Badge></div><p>{check.detail}</p>{#if check.href}<a href={check.href}>{check.action} →</a>{/if}</li>{/each}</ol>
|
||||
{:else if loading}<p role="status">Checking system setup…</p>{/if}
|
||||
<section id="setup-guide" class="guide"><h2>Connection setup</h2><p>Set these variables on the Electric Cherry container in Komodo, then restart the app to load the changes. Credentials are kept on the server and are never returned by this page.</p><dl><dt>Komodo</dt><dd><code>KOMODO_ADDRESS</code>, <code>KOMODO_API_KEY</code>, <code>KOMODO_API_SECRET</code>, and <code>KOMODO_TARGET_SERVER</code>. The target must match the host name in Komodo.</dd><dt>Game servers</dt><dd><code>RCON_PASSWORD</code> must match the game-server password. Set <code>RCON_HOST</code> if the default <code>host.docker.internal</code> does not reach the game host. Forward the allocated UDP game ports.</dd></dl></section>
|
||||
<section id="game-files" class="guide"><h2>Game files and keys</h2><p>Populate the Komodo host's <code>t5_game_files</code> or <code>t6_game_files</code> Docker volume with the corresponding game files and required DLC. Override those names using <code>PLUTAINER_GAMEFILES_VOLUME</code> or <code>PLUTAINER_T6_GAMEFILES_VOLUME</code>. Electric Cherry and the host must see each server directory at the same absolute path.</p><p>Create a Plutonium server key for the correct game and save it for the exact game, map, and lobby in <a href="/server-keys">Server keys</a>. Use a launch preview to review the assigned ID and port before creating a server.</p></section>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: 20px; }
|
||||
.checked { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; }
|
||||
.checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; list-style: none; margin: 0; padding: 0; }
|
||||
.checks li, .guide { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.heading { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
h2 { font-size: var(--text-md); margin: 0; }
|
||||
p, dd { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; }
|
||||
a { font-size: var(--text-sm); }
|
||||
dt { font-weight: var(--weight-semibold); margin-top: 12px; }
|
||||
dd { margin: 5px 0 0; }
|
||||
code { font-size: var(--text-xs); }
|
||||
.guide { scroll-margin-top: 90px; }
|
||||
@media (max-width: 700px) { .checks { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user