@@ -3,12 +3,17 @@
|
||||
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 } from '$lib/deployment-plan.js';
|
||||
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
|
||||
let blueprintId = $state('');
|
||||
let builderOpen = $state(false);
|
||||
let editingBlueprint = $state<BlueprintSummary | null>(null);
|
||||
@@ -17,14 +22,38 @@
|
||||
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);
|
||||
$effect(() => { limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||
let launchLimit = $state(10);
|
||||
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(); });
|
||||
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; }
|
||||
@@ -38,6 +67,17 @@
|
||||
} 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 = '';
|
||||
@@ -51,10 +91,14 @@
|
||||
</script>
|
||||
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Collections" subtitle="Save your lineup of maps and lobby counts, then preview changes to your fleet." />
|
||||
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button></div>
|
||||
<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}
|
||||
@@ -82,13 +126,46 @@
|
||||
{/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="/collections/launches/{collection.id}">
|
||||
<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; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getCollection } from '$lib/server/fleet/collections.js';
|
||||
|
||||
export function load({ params }: { params: { id: string } }) {
|
||||
if (!getCollection(params.id)) error(404, 'Collection not found.');
|
||||
return {};
|
||||
}
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title={collection?.name ?? 'Collection launch'}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Collection launch' }]} />{/snippet}
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if collection && detail}
|
||||
@@ -2,5 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
||||
|
||||
/** Preserve bookmarked operation links and list filters. */
|
||||
export function load({ url }: { url: URL }) {
|
||||
if (url.pathname.startsWith('/deployments/collections/')) {
|
||||
redirect(308, url.pathname.replace('/deployments/collections/', '/collections/launches/') + url.search);
|
||||
}
|
||||
redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
|
||||
import type { CollectionSummary } from '$lib/deployment-plan.js';
|
||||
|
||||
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
||||
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
|
||||
@@ -34,10 +33,8 @@
|
||||
let open = $state(false);
|
||||
let housePartyOpen = $state(false);
|
||||
let housePartyNotice = $state('');
|
||||
let collections = $state<CollectionSummary[]>([]);
|
||||
let tab = $state('active');
|
||||
let limit = $state(25);
|
||||
let collectionLimit = $state(10);
|
||||
let historyState = $state('all');
|
||||
let cancelling = $state(false);
|
||||
let cancellationNotice = $state('');
|
||||
@@ -48,12 +45,11 @@
|
||||
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; }
|
||||
if (reset) limit = 25;
|
||||
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) : '' })) {
|
||||
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) : '' })) {
|
||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||
}
|
||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
@@ -67,7 +63,6 @@
|
||||
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:'history',label:'History'}]);
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
@@ -82,10 +77,9 @@
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
const [res, collectionRes] = await Promise.all([fetch('/api/provisioning'), fetch('/api/deployment-collections')]);
|
||||
if (!res.ok || !collectionRes.ok) throw new Error('Could not load provisioning activity.');
|
||||
const res = await fetch('/api/provisioning');
|
||||
if (!res.ok) throw new Error('Could not load provisioning activity.');
|
||||
deployments = (await res.json()).deployments;
|
||||
collections = (await collectionRes.json()).collections;
|
||||
error = '';
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; refreshing = false; }
|
||||
@@ -108,20 +102,6 @@
|
||||
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
|
||||
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if visibleCollections.length}
|
||||
<section>
|
||||
<h2>Collection launches</h2>
|
||||
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
|
||||
<a class="deployment-row collection-row" href="/provisioning/collections/{collection.id}">
|
||||
<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 {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
|
||||
</a>
|
||||
{/each}</div>
|
||||
<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'; 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}
|
||||
@@ -176,7 +156,6 @@
|
||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
|
||||
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
.collection-row { display: grid; gap: 16px; }.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; }.collection-footer .meta, .collection-link { font-size: 12px; }.collection-link { color: var(--accent-primary-hover); }
|
||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||
.meta { font-size: 13px; color: var(--text-secondary); }
|
||||
.error { color: var(--state-danger); }
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||
{#if detail?.collection}<a href="/provisioning/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
||||
{#if detail?.collection}<a href="/collections/launches/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
||||
{#if deployment}
|
||||
<section class="summary">
|
||||
<div class="top"><StatusBadge state={deployment.state} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getCollection } from '$lib/server/fleet/collections.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load({ params }: { params: { id: string } }) {
|
||||
if (!getCollection(params.id)) error(404, 'Collection not found.');
|
||||
return {};
|
||||
/** Keep bookmarked collection launches under their current Collections home. */
|
||||
export function load({ params, url }: { params: { id: string }; url: URL }) {
|
||||
redirect(308, `/collections/launches/${encodeURIComponent(params.id)}${url.search}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user