Files
electric-cherry/src/routes/collections/+page.svelte
T

177 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
let blueprintId = $state('');
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let launches = $state<CollectionSummary[]>([]);
let launchesLoading = $state(true);
let launchError = $state('');
let launchesRefreshing = false;
let tab = $state('collections');
let limit = $state(25);
let launchLimit = $state(10);
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const activeLaunches = $derived(launches.filter(launch => launch.active > 0));
const launchHistory = $derived(launches.filter(launch => launch.active === 0));
const visibleLaunches = $derived(tab === 'active' ? activeLaunches : launchHistory);
const tabs = $derived([
{ id: 'collections', label: 'Your collections' },
{ id: 'active', label: 'Active launches', badge: launchesLoading ? undefined : activeLaunches.length },
{ id: 'history', label: 'Launch history' }
]);
$effect(() => {
const params = $page.url.searchParams;
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'collections';
limit = Math.max(25, Number(params.get('limit')) || 25);
launchLimit = Math.max(10, Number(params.get('launches')) || 10);
});
function saveContext() {
const url = new URL($page.url);
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
if (tab !== 'collections') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (launchLimit > 10) url.searchParams.set('launches', String(launchLimit)); else url.searchParams.delete('launches');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
onMount(() => {
void loadBlueprints();
return pollVisible(loadLaunches, 5000);
});
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
if (!res.ok) throw new Error('Could not load your collections.');
blueprints = (await res.json()).blueprints;
blueprintError = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; }
}
async function loadLaunches() {
if (launchesRefreshing) return;
launchesRefreshing = true;
try {
const res = await fetch('/api/deployment-collections');
if (!res.ok) throw new Error('Could not load collection launches.');
launches = (await res.json()).collections;
launchError = '';
} catch (err) { launchError = (err as Error).message; }
finally { launchesLoading = false; launchesRefreshing = false; }
}
async function removeBlueprint(id: string) {
if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove collection.');
blueprints = blueprints.filter(b => b.id !== id);
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
</script>
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
{#snippet actions()}<Button href="/autoscaling">Autoscaling</Button>{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
</SectionHeader>
{#if canOperate}<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />{/if}
<Tabs {tabs} bind:value={tab} onchange={() => { launchLimit = 10; saveContext(); }} ariaLabel="Collection views" idPrefix="collection-tabs" panelId="collection-panel" />
<div id="collection-panel" role="tabpanel" aria-labelledby={"collection-tabs-" + tab} class="panel">
{#if tab === 'collections'}
<section>
<h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading collections…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>No collections yet.</strong><p>Collections define the maps and lobby counts for a fleet. Operators can create and launch them.</p>{#if canOperate}<button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button>{/if}</div>
{:else}
<div class="blueprints">
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Fill missing lobbies or match your fleet to this lineup exactly.'}</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>
{#if canOperate}<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>{/if}
</article>
{/each}
</div>
{/if}
</section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" />
{:else}
<section>
<h2>{tab === 'active' ? 'Collection launches' : 'Launch history'} <span>{visibleLaunches.length}</span></h2>
{#if launchError}<p role="alert" class="error">{launchError} <button onclick={loadLaunches}>Retry</button></p>{/if}
{#if launchesLoading}<p>Loading collection launches…</p>
{:else if visibleLaunches.length}
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
<a class="collection-row" href={contextHref(`/collections/launches/${collection.id}`, $page.url)}>
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
<div class="collection-footer"><span class="meta">Queued {$timeFormat.dateTime(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
</a>
{/each}</div>
{:else if !launchError}
<EmptyState title={tab === 'active' ? 'No collection launches in flight' : 'No collection launch history'} description={tab === 'active' ? 'Preview fleet changes from Your collections to start a launch.' : 'Completed launches and launches needing attention appear here.'} />
{/if}
<ListFooter total={visibleLaunches.length} bind:limit={launchLimit} onchange={() => saveContext()} step={10} label="launches" />
</section>
{/if}
</div>
</div>
<style>
.page { display: flex; flex-direction: column; gap: 28px; }
.panel { display: grid; gap: 16px; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; }
.error { color: var(--state-danger); }
.launches { display: grid; gap: 12px; }
.collection-row { display: grid; gap: 16px; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.collection-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.collection-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }
.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }
.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }
.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
.meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
</style>