Move collection launch progress and history into Collections

Fixes #52
This commit is contained in:
2026-09-28 10:10:09 -04:00
parent 2fa3792482
commit 905d1aa3f6
12 changed files with 166 additions and 48 deletions
+7 -2
View File
@@ -171,7 +171,7 @@ game-status, and selected-map verification.
No token, no session, no auth freeze. If Komodo or RCON aren't configured, No token, no session, no auth freeze. If Komodo or RCON aren't configured,
the sweep marks pending rows `manual` so nothing hangs forever. the sweep marks pending rows `manual` so nothing hangs forever.
## Provisioning collections and launch previews ## Collections, provisioning, and launch previews
Provisioning also offers **House Party Protocol**: review the entire fleet and queue Provisioning also offers **House Party Protocol**: review the entire fleet and queue
destruction in one action. This includes queued creations and reuses pending destruction in one action. This includes queued creations and reuses pending
@@ -189,7 +189,12 @@ repeating Komodo deletion. Requesting destruction again also resumes that cleanu
Recovery is blocked if a newer operation exists for the same server name. Recovery is blocked if a newer operation exists for the same server name.
**Operations → Collections** includes a builder for whole server lineups: choose **Operations → Collections** includes a builder for whole server lineups: choose
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet** multiple T5/T6 maps and set the desired total lobbies for each. Collections also
has **Active launches** and **Launch history** tabs, with progress at
`/collections/launches/:id`.
Each server operation remains in Provisioning and links back to its collection
launch. Existing collection launch bookmarks redirect to the new location.
**Conform fleet**
counts existing and queued servers and creates only the shortfall. The preview counts existing and queued servers and creates only the shortfall. The preview
shows target/existing/missing counts and IDs, ports, and key requirements for new shows target/existing/missing counts and IDs, ports, and key requirements for new
servers only. Already-satisfied targets create nothing; excess servers are kept. servers only. Already-satisfied targets create nothing; excess servers are kept.
+11 -4
View File
@@ -1,6 +1,8 @@
# Deployment collections # Deployment collections
Open **Operations → Collections** to manage reusable server lineups. A Open **Operations → Collections** to manage reusable server lineups. Use
**Active launches** to follow progress and **Launch history** to review completed
launches and failures. Both views refresh automatically. A
collection describes a whole lineup: for example, two Kino lobbies, one Moon lobby, collection describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
and two Town lobbies. Counts are desired fleet totals: existing and queued and two Town lobbies. Counts are desired fleet totals: existing and queued
servers count toward each target, and only missing servers are created. servers count toward each target, and only missing servers are created.
@@ -105,11 +107,13 @@ A lost startup response also keeps the slot because the container may already be
running. A confirmed creation failure is recorded for review, allowing later running. A confirmed creation failure is recorded for review, allowing later
queued servers to proceed on subsequent passes without undoing successful launches. queued servers to proceed on subsequent passes without undoing successful launches.
The collection page separates ready, launching, queued, and failed servers. It The collection launch page at `/collections/launches/:id` separates ready,
launching, queued, and failed servers. Its breadcrumb returns to Collections. It
highlights the current startup, shows each waiting server's queue position, and highlights the current startup, shows each waiting server's queue position, and
links to earlier launches when another collection occupies the available capacity. links to earlier launches when another collection occupies the available capacity.
Every server links to its operation timeline and recovery options, and each child Every server links to its Provisioning operation timeline and recovery options,
operation links back to its collection. and each child operation links back to its collection launch. Individual operations
remain visible in Provisioning activity and history.
## Create and review a launch ## Create and review a launch
@@ -204,6 +208,9 @@ repeats container creation or deployment as part of this recovery.
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` / Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
`blueprintName` fields for compatibility. Saved data needs no migration. Old `blueprintName` fields for compatibility. Saved data needs no migration. Old
`/provisioning?view=blueprints` links redirect to `/collections`. `/provisioning?view=blueprints` links redirect to `/collections`.
Collection launch details live at `/collections/launches/:id`; old
`/provisioning/collections/:id` and `/deployments/collections/:id` links redirect
there while preserving query parameters.
- `GET /api/blueprints`: list lineup summaries with game/map selections and counts. - `GET /api/blueprints`: list lineup summaries with game/map selections and counts.
- `POST /api/blueprints`: save `{ name, description?, previewId }` or - `POST /api/blueprints`: save `{ name, description?, previewId }` or
+9 -4
View File
@@ -1,7 +1,9 @@
# Provisioning # Provisioning
Open **Operations → Provisioning** for instance lifecycle work, blueprints, and Open **Operations → Provisioning** for instance lifecycle work and persistent
persistent activity. Provisioning owns the queues, safety checks, execution, and activity. **Operations → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing recovery for all six actions. Create a server using **Create server**. For an existing
server, choose **Manage server** or open its **Server actions** menu. server, choose **Manage server** or open its **Server actions** menu.
@@ -75,9 +77,10 @@ requester closes. Upgrades adopt existing restart intent for observation only.
## Routes and compatibility ## Routes and compatibility
- `/provisioning`: active operations, blueprints, and history filtered by action. - `/provisioning`: active operations and history filtered by action.
- `/provisioning/:id`: operation stages, errors, timestamps, and activity. - `/provisioning/:id`: operation stages, errors, timestamps, and activity.
- `/provisioning/collections/:id`: collection progress. - `/collections`: saved lineups, active launches, and launch history.
- `/collections/launches/:id`: collection progress, with links to each operation.
- `GET /api/provisioning`: lifecycle history (`deployments` response property). - `GET /api/provisioning`: lifecycle history (`deployments` response property).
- `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`. - `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`.
Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202 Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202
@@ -88,6 +91,8 @@ requester closes. Upgrades adopt existing restart intent for observation only.
launch preview, and fleet destruction APIs. launch preview, and fleet destruction APIs.
Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving
query parameters. Collection launch links at `/provisioning/collections/:id` or
`/deployments/collections/:id` redirect to `/collections/launches/:id` and preserve
query parameters. `/api/deployments` remains compatible, including old campaign query parameters. `/api/deployments` remains compatible, including old campaign
detail endpoints. No history or IDs are renamed or discarded. detail endpoints. No history or IDs are renamed or discarded.
@@ -60,7 +60,7 @@
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.'); if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
open = false; open = false;
if (body.collectionId) await goto(`/provisioning/collections/${encodeURIComponent(body.collectionId)}`); if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
+82 -5
View File
@@ -3,12 +3,17 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js'; 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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.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 BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.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 blueprintId = $state('');
let builderOpen = $state(false); let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null); let editingBlueprint = $state<BlueprintSummary | null>(null);
@@ -17,14 +22,38 @@
let blueprintsLoading = $state(true); let blueprintsLoading = $state(true);
let blueprintError = $state(''); let blueprintError = $state('');
let removing = $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 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() { function saveContext() {
const url = new URL($page.url); const url = new URL($page.url);
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); 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 }); 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) { function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name)); blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; } if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
@@ -38,6 +67,17 @@
} catch (err) { blueprintError = (err as Error).message; } } catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; } 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) { 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; if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
removing = id; blueprintError = ''; removing = id; blueprintError = '';
@@ -51,10 +91,14 @@
</script> </script>
<svelte:head><title>Collections | Electric Cherry</title></svelte:head> <svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page"> <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} /> <BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} /> <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> <section>
<h2>Your collections <span>{blueprints.length}</span></h2> <h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if} {#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
@@ -82,13 +126,46 @@
{/if} {/if}
</section> </section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" /> <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> </div>
<style> <style>
.page { display: flex; flex-direction: column; gap: 28px; } .page { display: flex; flex-direction: column; gap: 28px; }
.panel { display: grid; gap: 16px; }
section { display: grid; gap: 16px; } section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } p { margin: 8px 0; }
.error { color: var(--state-danger); } .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 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; } .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; } .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 {};
}
@@ -54,7 +54,7 @@
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head> <svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}> <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> </SectionHeader>
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail} {#if collection && detail}
@@ -2,5 +2,8 @@ import { redirect } from '@sveltejs/kit';
/** Preserve bookmarked operation links and list filters. */ /** Preserve bookmarked operation links and list filters. */
export function load({ url }: { url: URL }) { 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); redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search);
} }
+4 -25
View File
@@ -15,7 +15,6 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import type { CollectionSummary } from '$lib/deployment-plan.js';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js'; import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
@@ -34,10 +33,8 @@
let open = $state(false); let open = $state(false);
let housePartyOpen = $state(false); let housePartyOpen = $state(false);
let housePartyNotice = $state(''); let housePartyNotice = $state('');
let collections = $state<CollectionSummary[]>([]);
let tab = $state('active'); let tab = $state('active');
let limit = $state(25); let limit = $state(25);
let collectionLimit = $state(10);
let historyState = $state('all'); let historyState = $state('all');
let cancelling = $state(false); let cancelling = $state(false);
let cancellationNotice = $state(''); let cancellationNotice = $state('');
@@ -48,12 +45,11 @@
historyState = params.get('status') === 'attention' ? 'attention' : 'all'; historyState = params.get('status') === 'attention' ? 'attention' : 'all';
search = params.get('server') ?? ''; search = params.get('server') ?? '';
limit = Math.max(25, Number(params.get('limit')) || 25); limit = Math.max(25, Number(params.get('limit')) || 25);
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
}); });
function saveContext(reset = false) { function saveContext(reset = false) {
if (reset) { limit = 25; collectionLimit = 10; } if (reset) limit = 25;
const url = new URL($page.url); 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); if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
} }
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); 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.` : ''}`; 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(); 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'}]); const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]);
let action = $state('all'); let action = $state('all');
let search = $state(''); let search = $state('');
@@ -82,10 +77,9 @@
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
try { try {
const [res, collectionRes] = await Promise.all([fetch('/api/provisioning'), fetch('/api/deployment-collections')]); const res = await fetch('/api/provisioning');
if (!res.ok || !collectionRes.ok) throw new Error('Could not load provisioning activity.'); if (!res.ok) throw new Error('Could not load provisioning activity.');
deployments = (await res.json()).deployments; deployments = (await res.json()).deployments;
collections = (await collectionRes.json()).collections;
error = ''; error = '';
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; } finally { loading = false; refreshing = false; }
@@ -108,20 +102,6 @@
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels"> <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 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 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 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} {#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 { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); } .deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; } .deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.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; } .server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
.meta { font-size: 13px; color: var(--text-secondary); } .meta { font-size: 13px; color: var(--text-secondary); }
.error { color: var(--state-danger); } .error { color: var(--state-danger); }
+1 -1
View File
@@ -135,7 +135,7 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
</SectionHeader> </SectionHeader>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if detail?.collection}<a href="/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} {#if deployment}
<section class="summary"> <section class="summary">
<div class="top"><StatusBadge state={deployment.state} /> <div class="top"><StatusBadge state={deployment.state} />
@@ -1,7 +1,6 @@
import { error } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { getCollection } from '$lib/server/fleet/collections.js';
export function load({ params }: { params: { id: string } }) { /** Keep bookmarked collection launches under their current Collections home. */
if (!getCollection(params.id)) error(404, 'Collection not found.'); export function load({ params, url }: { params: { id: string }; url: URL }) {
return {}; redirect(308, `/collections/launches/${encodeURIComponent(params.id)}${url.search}`);
} }
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getCollection } from '../../src/lib/server/fleet/collections.js';
import { load as collectionLoad } from '../../src/routes/collections/launches/[id]/+page.server.js';
import { load as provisioningCollectionLoad } from '../../src/routes/provisioning/collections/[id]/+page.server.js';
import { load as deploymentsLoad } from '../../src/routes/deployments/[...path]/+page.server.js';
vi.mock('../../src/lib/server/fleet/collections.js', () => ({ getCollection: vi.fn() }));
beforeEach(() => vi.resetAllMocks());
describe('collection launch routes', () => {
it('loads an existing launch at its Collections location and preserves missing-launch errors', () => {
vi.mocked(getCollection).mockReturnValue({ collection: { id: 'launch-123' } } as ReturnType<typeof getCollection>);
expect(collectionLoad({ params: { id: 'launch-123' } })).toEqual({});
expect(getCollection).toHaveBeenCalledWith('launch-123');
vi.mocked(getCollection).mockReturnValue(null);
expect(() => collectionLoad({ params: { id: 'missing' } })).toThrow(expect.objectContaining({ status: 404, body: { message: 'Collection not found.' } }));
});
it('redirects Provisioning collection bookmarks with the same ID and query parameters', () => {
expect(() => provisioningCollectionLoad({
params: { id: 'launch-123' },
url: new URL('http://localhost/provisioning/collections/launch-123?view=history&limit=50')
})).toThrow(expect.objectContaining({ status: 308, location: '/collections/launches/launch-123?view=history&limit=50' }));
expect(getCollection).not.toHaveBeenCalled();
});
it.each([
['/deployments/collections/launch-123?limit=50&from=old%20link', '/collections/launches/launch-123?limit=50&from=old%20link'],
['/deployments/operation-123?from=collection', '/provisioning/operation-123?from=collection'],
['/deployments?action=create&limit=50', '/provisioning?action=create&limit=50']
])('keeps legacy %s bookmarks compatible', (path, location) => {
expect(() => deploymentsLoad({ url: new URL(path, 'http://localhost') })).toThrow(expect.objectContaining({ status: 308, location }));
});
});