Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+473 -100
View File
@@ -31,39 +31,61 @@
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 canOperate = $derived(
$page.data.user?.role === 'admin' || $page.data.user?.role === 'operator'
);
const inProgress = (launch: CollectionSummary) => launch.active > 0 || launch.cleanupActive > 0;
const activeLaunches = $derived(launches.filter(inProgress));
const launchHistory = $derived(launches.filter(launch => !inProgress(launch)));
const launchHistory = $derived(launches.filter((launch) => !inProgress(launch)));
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: 'active',
label: 'Active launches',
badge: launchesLoading ? undefined : activeLaunches.length
},
{ id: 'history', label: 'Launch history' }
]);
function launchLabel(collection: CollectionSummary) {
if (collection.cancelled_at) return inProgress(collection) ? 'Cancellation requested' : collection.failed || collection.cleanupFailed ? 'Cancelled · needs attention' : 'Cancelled';
if (collection.cancelled_at)
return inProgress(collection)
? 'Cancellation requested'
: collection.failed || collection.cleanupFailed
? 'Cancelled · needs attention'
: 'Cancelled';
if (collection.cleanupActive) return 'Cleaning up';
if (collection.active) return collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued';
if (collection.active)
return collection.provisioning + collection.verifying
? collection.mode === 'exact'
? 'Applying collection'
: 'Launching'
: 'Queued';
if (collection.failed || collection.cleanupFailed) return 'Needs attention';
return collection.cancelled ? 'Finished with cancellations' : 'Completed';
}
function launchVariant(collection: CollectionSummary) {
if (collection.cleanupFailed || (!inProgress(collection) && collection.failed)) return 'danger';
if (collection.cleanupFailed || (!inProgress(collection) && collection.failed))
return 'danger';
if (inProgress(collection)) return 'info';
return collection.cancelled_at || collection.cancelled ? 'neutral' : 'online';
}
$effect(() => {
const params = $page.url.searchParams;
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'collections';
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');
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(() => {
@@ -71,8 +93,13 @@
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; }
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 {
@@ -80,8 +107,11 @@
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; }
} catch (err) {
blueprintError = (err as Error).message;
} finally {
blueprintsLoading = false;
}
}
async function loadLaunches() {
if (launchesRefreshing) return;
@@ -91,104 +121,447 @@
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; }
} 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 = '';
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' });
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 = ''; }
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()}{#if canOperate && (blueprintsLoading || blueprints.length || blueprintError)}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
<SectionHeader
title="Collections"
subtitle="Save your lineup, preview fleet changes, and follow launch progress and history."
>
{#snippet actions()}{#if canOperate && (blueprintsLoading || blueprints.length || blueprintError)}<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 && !blueprintError}<EmptyState headingLevel={3} title="No collections yet" description={canOperate ? 'Collections save maps and lobby counts for your fleet. Create one to preview and launch that lineup.' : 'Collections save maps and lobby counts for your fleet. An operator can create and launch them.'}>
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
</EmptyState>
{#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 && !blueprintError}<EmptyState
headingLevel={3}
title="No collections yet"
description={canOperate
? 'Collections save maps and lobby counts for your fleet. Create one to preview and launch that lineup.'
: 'Collections save maps and lobby counts for your fleet. An operator can create and launch them.'}
>
{#snippet actions()}{#if canOperate}<Button
variant="primary"
onclick={() => {
editingBlueprint = null;
builderOpen = true;
}}>Create collection</Button
>{/if}{/snippet}
</EmptyState>
{: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}
<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>
<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={launchVariant(collection)}
>{launchLabel(collection)}</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}
{plural(collection.failed, 'needs', 'need')} attention</span
>{/if}{#if collection.cancelled}<span
>{collection.cancelled} cancelled</span
>{/if}{#if collection.cleanupActive}<span
>{collection.cleanupActive} cleaning up</span
>{/if}{#if collection.cleanupFailed}<span class="error"
>{collection.cleanupFailed}
{plural(collection.cleanupFailed, 'cleanup')}
{plural(collection.cleanupFailed, 'needs', '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><span
class="cancelled"
style:width={`${(collection.cancelled / (collection.total || 1)) * 100}%`}
></span>
</div>
<div class="collection-footer">
<span class="meta"
>Queued <RelativeTime value={collection.created_at} /></span
><span class="collection-link"
>{inProgress(collection)
? 'Follow progress'
: 'View results'} →</span
>
</div>
</a>
{/each}
</div>
{:else if !launchError}
<EmptyState
headingLevel={3}
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 and cancelled launches, including those needing attention, appear here.'}
>
{#snippet actions()}<Button
onclick={() => {
tab = 'collections';
saveContext();
}}>View collections</Button
>{/snippet}
</EmptyState>
{/if}
<ListFooter
total={visibleLaunches.length}
bind:limit={launchLimit}
onchange={() => saveContext()}
step={10}
label="launches"
/>
</section>
{/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={launchVariant(collection)}>{launchLabel(collection)}</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} {plural(collection.failed, 'needs', 'need')} attention</span>{/if}{#if collection.cancelled}<span>{collection.cancelled} cancelled</span>{/if}{#if collection.cleanupActive}<span>{collection.cleanupActive} cleaning up</span>{/if}{#if collection.cleanupFailed}<span class="error">{collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', '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><span class="cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span></div>
<div class="collection-footer"><span class="meta">Queued <RelativeTime value={collection.created_at} /></span><span class="collection-link">{inProgress(collection) ? 'Follow progress' : 'View results'} →</span></div>
</a>
{/each}</div>
{:else if !launchError}
<EmptyState headingLevel={3} 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 and cancelled launches, including those needing attention, appear here.'}>
{#snippet actions()}<Button onclick={() => { tab = 'collections'; saveContext(); }}>View collections</Button>{/snippet}
</EmptyState>
{/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: var(--text-md); 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: var(--text-sm); 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-meter .cancelled { background: var(--text-tertiary); }
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: var(--text-xs); }
.meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); }
.blueprint p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: var(--text-sm); }.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: var(--text-xs); 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: var(--text-lg); 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 { background: none; color: var(--text-secondary); border: 0; font: inherit; font-size: var(--text-xs); cursor: pointer; }
.page {
display: flex;
flex-direction: column;
gap: 28px;
}
.panel {
display: grid;
gap: 16px;
}
section {
display: grid;
gap: 16px;
}
h2 {
font-size: var(--text-md);
margin: 0;
}
h2 span {
opacity: 0.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: var(--text-sm);
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-meter .cancelled {
background: var(--text-tertiary);
}
.collection-footer {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 16px;
font-size: var(--text-xs);
}
.meta {
color: var(--text-secondary);
}
.collection-link {
color: var(--accent-primary-hover);
}
.blueprint p {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.blueprint-lineup {
list-style: none;
padding: 0;
margin: 8px 0;
display: grid;
gap: 9px;
font-size: var(--text-sm);
}
.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: var(--text-xs);
cursor: pointer;
}
.blueprint-footer {
flex-wrap: wrap;
}
.eyebrow {
display: block;
text-transform: uppercase;
letter-spacing: 0.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: var(--text-lg);
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 {
background: none;
color: var(--text-secondary);
border: 0;
font: inherit;
font-size: var(--text-xs);
cursor: pointer;
}
</style>
+721 -72
View File
@@ -21,27 +21,64 @@
let cancellationAccepted = $state(false);
let requestNumber = 0;
let collection = $derived(detail?.collection);
const launching = (server: { state: string; server_deleted_at?: string | null }) => !server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning'].includes(server.state);
const cleanupNeedsAttention = (cancellation: NonNullable<CollectionServer['cancellation']>) => needsAttention(cancellation) || cancellation.state === 'cancelled';
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const launching = (server: { state: string; server_deleted_at?: string | null }) =>
!server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) =>
['failed', 'interrupted', 'warning'].includes(server.state);
const cleanupNeedsAttention = (cancellation: NonNullable<CollectionServer['cancellation']>) =>
needsAttention(cancellation) || cancellation.state === 'cancelled';
const canOperate = $derived(
$page.data.user?.role === 'admin' || $page.data.user?.role === 'operator'
);
const cancellationRequested = $derived(!!collection?.cancelled_at || cancellationAccepted);
const inProgress = $derived(!!collection && (collection.active > 0 || collection.cleanupActive > 0));
const inProgress = $derived(
!!collection && (collection.active > 0 || collection.cleanupActive > 0)
);
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const heading = $derived.by(() => {
if (!collection) return 'Collection launch';
if (cancellationRequested) return inProgress ? 'Cancellation requested' : collection.failed || collection.cleanupFailed ? 'Cancellation needs attention' : 'Collection launch cancelled';
if (cancellationRequested)
return inProgress
? 'Cancellation requested'
: collection.failed || collection.cleanupFailed
? 'Cancellation needs attention'
: 'Collection launch cancelled';
if (collection.cleanupActive) return 'Cleaning up cancelled launches';
if (collection.active) return exact ? 'Applying your collection' : currentServers.length ? 'Bringing your lineup online' : 'Your lineup is queued';
if (collection.failed || collection.cleanupFailed) return exact ? 'Collection finished with issues' : 'Launch complete with issues';
if (collection.active)
return exact
? 'Applying your collection'
: currentServers.length
? 'Bringing your lineup online'
: 'Your lineup is queued';
if (collection.failed || collection.cleanupFailed)
return exact ? 'Collection finished with issues' : 'Launch complete with issues';
if (collection.cancelled) return 'Collection finished with cancellations';
return exact ? 'Collection applied' : 'Your lineup is ready';
});
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
const otherLaunches = $derived(
detail?.queue.active.filter(
(active) => !detail?.servers.some((server) => server.id === active.id)
) ?? []
);
const firstQueued = $derived(
detail?.servers.find((server) => !server.server_deleted_at && server.state === 'queued')
);
const labels: Record<string, string> = {
queued: 'Queued',
running: 'Launching',
'awaiting-health': 'Checking readiness',
done: 'Ready',
warning: 'Needs review',
failed: 'Failed',
interrupted: 'Interrupted',
waiting: 'Waiting',
cancelled: 'Cancelled'
};
const waitingForPort = (server: CollectionServer) =>
server.state === 'queued' &&
(server.stage.startsWith('Waiting for removal') ||
server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
if (waitingForPort(server)) return 'Waiting for removal';
if (server.action === 'destroy' && server.state === 'done') return 'Removed';
@@ -52,7 +89,8 @@
function queueHint(server: CollectionServer) {
const reason = server.stage || 'Waiting for a startup slot and available host capacity.';
return server.queuePosition && server.queuePosition > 1
? `${reason} ${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead.` : reason;
? `${reason} ${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead.`
: reason;
}
async function load() {
@@ -62,15 +100,22 @@
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('Could not refresh this collection.');
const body = await res.json();
if (current === requestNumber && id === $page.params.id) { detail = body; error = ''; }
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
if (current === requestNumber && id === $page.params.id) {
detail = body;
error = '';
}
} catch (err) {
if (current === requestNumber) error = (err as Error).message;
}
}
async function cancelLaunch() {
if (!collection || !detail?.canCancel || cancellationRequested || cancelling || !canOperate) return;
if (!collection || !detail?.canCancel || cancellationRequested || cancelling || !canOperate)
return;
const id = collection.id;
const accepted = await confirmAction({
title: 'Cancel collection launch?',
message: 'Stops unfinished server creations, including eligible failed or interrupted launches, and deletes their containers and instance files. This can disconnect active players, even when player status is unknown. Shared game files are kept. Keys and ports are released only after removal is confirmed.\n\nCompleted launches and retained servers are kept. Pending removals are cancelled where safe; removals that have already started or are shared with another collection may still finish.',
message:
'Stops unfinished server creations, including eligible failed or interrupted launches, and deletes their containers and instance files. This can disconnect active players, even when player status is unknown. Shared game files are kept. Keys and ports are released only after removal is confirmed.\n\nCompleted launches and retained servers are kept. Pending removals are cancelled where safe; removals that have already started or are shared with another collection may still finish.',
confirmLabel: 'Cancel launch and clean up',
danger: true
});
@@ -78,7 +123,10 @@
cancelling = true;
cancelError = '';
try {
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
const res = await fetch(
`/api/deployment-collections/${encodeURIComponent(id)}/cancel`,
{ method: 'POST' }
);
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Could not cancel this collection launch.');
if (id !== $page.params.id) return;
@@ -89,48 +137,139 @@
cancelError = (err as Error).message;
await load();
}
} finally { if (id === $page.params.id) cancelling = false; }
} finally {
if (id === $page.params.id) cancelling = false;
}
}
$effect(() => { $page.params.id; detail = null; cancelError = ''; cancelling = false; cancellationAccepted = false; void load(); });
onMount(() => { const stop = pollVisible(load, 5000, false); return () => { stop(); requestNumber++; }; });
$effect(() => {
void $page.params.id;
detail = null;
cancelError = '';
cancelling = false;
cancellationAccepted = false;
void load();
});
onMount(() => {
const stop = pollVisible(load, 5000, false);
return () => {
stop();
requestNumber++;
};
});
</script>
<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">
<SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs fallback="/collections?view=active" items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
{#snippet crumbs()}<Breadcrumbs
fallback="/collections?view=active"
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}
<section class="summary" aria-label="Collection progress">
<div class="summary-heading">
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div>
<span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
<div>
<span class="eyebrow">Collection progress</span>
<h2>{heading}</h2>
</div>
<span class="completion"
><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total}
finished</span
>
</div>
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
<span class="progress-cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span>
<div
class="progress"
role="progressbar"
aria-label="Finished fleet operations"
aria-valuemin={0}
aria-valuemax={collection.total || 1}
aria-valuenow={collection.done + collection.failed + collection.cancelled}
aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}
>
<span
class="progress-ready"
style:width={`${(collection.done / (collection.total || 1)) * 100}%`}
></span>
<span
class="progress-failed"
style:width={`${(collection.failed / (collection.total || 1)) * 100}%`}
></span>
<span
class="progress-cancelled"
style:width={`${(collection.cancelled / (collection.total || 1)) * 100}%`}
></span>
</div>
<dl class="counts" class:has-cancellations={cancellationRequested || collection.cancelled > 0}>
<div class="ready-count"><dt>{exact ? 'Completed' : 'Ready'}</dt><dd>{collection.done}</dd></div>
<div><dt>{exact ? 'In progress' : 'Launching'}</dt><dd>{collection.provisioning + collection.verifying}</dd></div>
<div><dt>Queued</dt><dd>{collection.queued}</dd></div>
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
{#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
<dl
class="counts"
class:has-cancellations={cancellationRequested || collection.cancelled > 0}
>
<div class="ready-count">
<dt>{exact ? 'Completed' : 'Ready'}</dt>
<dd>{collection.done}</dd>
</div>
<div>
<dt>{exact ? 'In progress' : 'Launching'}</dt>
<dd>{collection.provisioning + collection.verifying}</dd>
</div>
<div>
<dt>Queued</dt>
<dd>{collection.queued}</dd>
</div>
<div class:attention-count={collection.failed > 0}>
<dt>Need attention</dt>
<dd>{collection.failed}</dd>
</div>
{#if cancellationRequested || collection.cancelled > 0}<div>
<dt>Cancelled</dt>
<dd>{collection.cancelled}</dd>
</div>{/if}
</dl>
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} {plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention.</p>
<p class="screen-reader" role="status">
{collection.done} completed, {collection.provisioning + collection.verifying} in progress,
{collection.queued} queued, {collection.failed}
{plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled,
{collection.cleanupActive} cleaning up, {collection.cleanupFailed}
{plural(collection.cleanupFailed, 'cleanup')}
{plural(collection.cleanupFailed, 'needs', 'need')} attention.
</p>
{#if cancellationRequested}
<p class="cancellation-note">Completed launches and retained servers are kept. Removals that already started or are shared with another collection may still finish. Follow each cancelled server's cleanup below until removal is confirmed.</p>
<p class="cancellation-note">
Completed launches and retained servers are kept. Removals that already started
or are shared with another collection may still finish. Follow each cancelled
server's cleanup below until removal is confirmed.
</p>
{/if}
{#if collection.cleanupActive || collection.cleanupFailed}
<div class="cleanup-summary" role="status">
{#if collection.cleanupActive}<p>{collection.cleanupActive} server cleanup{collection.cleanupActive === 1 ? '' : 's'} in progress. Keys and ports remain reserved until removal is confirmed.</p>{/if}
{#if collection.cleanupFailed}<p class="error">{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1 ? ' needs' : 's need'} attention. Open the cleanup operation below to review and recover.</p>{/if}
{#if collection.cleanupActive}<p>
{collection.cleanupActive} server cleanup{collection.cleanupActive === 1
? ''
: 's'} in progress. Keys and ports remain reserved until removal is confirmed.
</p>{/if}
{#if collection.cleanupFailed}<p class="error">
{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1
? ' needs'
: 's need'} attention. Open the cleanup operation below to review and
recover.
</p>{/if}
</div>
{/if}
{#if canOperate && detail.canCancel && !cancellationRequested}
<div class="cancel-action"><Button variant="danger" loading={cancelling} onclick={cancelLaunch}>Cancel collection launch</Button><p>Stops unfinished launches and removes their containers and instance files. Completed launches and retained servers are kept.</p></div>
<div class="cancel-action">
<Button variant="danger" loading={cancelling} onclick={cancelLaunch}
>Cancel collection launch</Button
>
<p>
Stops unfinished launches and removes their containers and instance files.
Completed launches and retained servers are kept.
</p>
</div>
{/if}
{#if cancelError}<p class="error" role="alert">{cancelError}</p>{/if}
</section>
@@ -139,49 +278,170 @@
<section class="current-launches" aria-label="In progress">
<span class="eyebrow">In progress</span>
{#each currentServers as server (server.id)}
<a class="current-launch" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<a
class="current-launch"
href={contextHref(`/provisioning/${server.id}`, $page.url)}
>
<span class="activity" aria-hidden="true"></span>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
<div>
<strong
>{server.action === 'destroy'
? 'Removing: '
: ''}<HistoricalServerIdentity
name={server.server}
deletedAt={server.server_deleted_at}
/></strong
>
<p>
{server.state === 'awaiting-health'
? 'Checking the container, connection, and expected map.'
: server.stage}
</p>
</div>
<span class="operation-link"
>Follow progress <span aria-hidden="true">→</span></span
>
</a>
{/each}
</section>
{:else if firstQueued}
<aside class="waiting-note">
<span class="waiting-icon" aria-hidden="true">◷</span>
<div><strong>{waitingForPort(firstQueued) ? 'Waiting for a removal to release capacity' : 'Your collection is queued'}</strong>
<p>{queueHint(firstQueued)} Provisioning starts each server when its host and capacity are ready. Progress updates automatically.</p>
<div>
<strong
>{waitingForPort(firstQueued)
? 'Waiting for a removal to release capacity'
: 'Your collection is queued'}</strong
>
<p>
{queueHint(firstQueued)} Provisioning starts each server when its host and capacity
are ready. Progress updates automatically.
</p>
{#if otherLaunches.length}
<p>Other servers currently launching:</p>
<div class="other-launches">{#each otherLaunches as active (active.id)}<a href={contextHref(`/provisioning/${active.id}`, $page.url)}><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div>
<div class="other-launches">
{#each otherLaunches as active (active.id)}<a
href={contextHref(`/provisioning/${active.id}`, $page.url)}
><ServerIdentity name={active.server} />
<span aria-hidden="true">→</span></a
>{/each}
</div>
{/if}
</div>
</aside>
{/if}
{#if exact && detail.retainedServers.length}
<details><summary>{detail.retainedServers.length} retained server{detail.retainedServers.length === 1 ? '' : 's'} (unchanged)</summary>
<ul>{#each detail.retainedServers as name}<li><HistoricalServerIdentity {name} deletedAt={detail.retainedServerDeletedAt?.[name]} /> <span class="muted">{name}</span></li>{/each}</ul>
<details>
<summary
>{detail.retainedServers.length} retained server{detail.retainedServers
.length === 1
? ''
: 's'} (unchanged)</summary
>
<ul>
{#each detail.retainedServers as name}<li>
<HistoricalServerIdentity
{name}
deletedAt={detail.retainedServerDeletedAt?.[name]}
/> <span class="muted">{name}</span>
</li>{/each}
</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title">
<div class="queue-heading"><h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2><span class="muted">{collection.total} operation{collection.total === 1 ? '' : 's'}</span></div>
<div class="queue-heading">
<h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2>
<span class="muted"
>{collection.total} operation{collection.total === 1 ? '' : 's'}</span
>
</div>
<ol class="servers">
{#each detail.servers as server, index (server.id)}
<li class:launching={launching(server)} class:attention={needsAttention(server) || (server.cancellation && cleanupNeedsAttention(server.cancellation))}>
<a class="server" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<li
class:launching={launching(server)}
class:attention={needsAttention(server) ||
(server.cancellation && cleanupNeedsAttention(server.cancellation))}
>
<a
class="server"
href={contextHref(`/provisioning/${server.id}`, $page.url)}
>
<span
class="order"
class:ready={server.state === 'done'}
aria-hidden="true"
>{server.state === 'done'
? '✓'
: String(index + 1).padStart(2, '0')}</span
>
<div class="server-content">
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>{server.action === 'destroy' ? 'Destroy server' : server.port === null ? 'Create server · Port not assigned yet' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
<div class="server-heading">
<strong
><HistoricalServerIdentity
name={server.server}
deletedAt={server.server_deleted_at}
/></strong
><Badge
variant={needsAttention(server)
? 'danger'
: server.state === 'done'
? 'online'
: launching(server)
? 'info'
: 'neutral'}>{stateLabel(server)}</Badge
>
</div>
<p class:error={!!server.error}>
{server.error ??
(server.state === 'queued'
? queueHint(server)
: server.state === 'done'
? server.action === 'destroy'
? server.stage
: 'Startup checks passed.'
: server.stage)}
</p>
<div class="server-meta">
<span
>{server.action === 'destroy'
? 'Destroy server'
: server.port === null
? 'Create server · Port not assigned yet'
: `Create server · Port ${server.port}`}</span
><span
>{needsAttention(server)
? 'Review & recover'
: 'View operation'}
<span aria-hidden="true">→</span></span
>
</div>
</div>
</a>
{#if server.cancellation}
<a class="cleanup-link" class:error={cleanupNeedsAttention(server.cancellation)} href={contextHref(`/provisioning/${server.cancellation.cleanupId}`, $page.url)}>
<strong>{server.cancellation.state === 'done' ? 'Cleanup completed' : cleanupNeedsAttention(server.cancellation) ? 'Cleanup needs attention' : 'Cleanup in progress'}</strong>
<span>{server.cancellation.error ?? server.cancellation.stage}</span>
<span class="cleanup-operation">{cleanupNeedsAttention(server.cancellation) ? 'Review cleanup' : 'View cleanup progress'} <span aria-hidden="true">→</span></span>
<a
class="cleanup-link"
class:error={cleanupNeedsAttention(server.cancellation)}
href={contextHref(
`/provisioning/${server.cancellation.cleanupId}`,
$page.url
)}
>
<strong
>{server.cancellation.state === 'done'
? 'Cleanup completed'
: cleanupNeedsAttention(server.cancellation)
? 'Cleanup needs attention'
: 'Cleanup in progress'}</strong
>
<span>{server.cancellation.error ?? server.cancellation.stage}</span
>
<span class="cleanup-operation"
>{cleanupNeedsAttention(server.cancellation)
? 'Review cleanup'
: 'View cleanup progress'}
<span aria-hidden="true">→</span></span
>
</a>
{/if}
</li>
@@ -192,17 +452,406 @@
</div>
<style>
.page { display: grid; gap: 28px; }
h2, p { margin: 0; }h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; color: var(--text-secondary); margin-bottom: 9px; }
a { color: var(--accent-primary-hover); }a:focus-visible, button:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }.muted { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }.error { color: var(--state-danger); }
.summary { display: grid; gap: 24px; padding: 28px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.summary-heading, .queue-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }.completion { color: var(--text-secondary); font-size: var(--text-sm); white-space: nowrap; font-variant-numeric: tabular-nums; }.completion strong { color: var(--text-primary); font-weight: 600; }
.progress { display: flex; height: 6px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.progress span { transition: width var(--dur-base) var(--ease-standard); }.progress-ready { background: var(--state-online); }.progress-failed { background: var(--state-danger); }.progress-cancelled { background: var(--text-tertiary); }
.counts.has-cancellations { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.cancellation-note, .cancel-action p, .cleanup-summary { font-size: var(--text-sm); line-height: 1.6; color: var(--text-secondary); }.cancel-action { display: grid; gap: 10px; justify-items: start; }.cleanup-summary { display: grid; gap: 8px; }
.cleanup-link { display: grid; gap: 6px; margin: 0 24px 20px 68px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.cleanup-link:hover { background: var(--surface-raised); }.cleanup-link strong { color: var(--text-primary); }.cleanup-link.error, .cleanup-link.error strong { color: var(--state-danger); }.cleanup-operation { color: var(--accent-primary-hover); }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; }.counts > div + div { padding-left: 24px; border-left: 1px solid var(--border-subtle); }dt { color: var(--text-secondary); font-size: var(--text-xs); }dd { font-variant-numeric: tabular-nums; font-size: var(--text-2xl); font-weight: 600; margin: 6px 0 0; letter-spacing: -.025em; }.ready-count dd { color: var(--state-online); }.attention-count dd { color: var(--state-danger); }
.current-launches { display: grid; gap: 12px; }.current-launches > .eyebrow { margin: 0; color: var(--accent-primary-hover); }.current-launch { display: flex; align-items: center; gap: 16px; color: inherit; text-decoration: none; padding: 20px 24px; border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface)); }.current-launch > div { min-width: 0; flex: 1; }.current-launch strong { font-size: var(--text-md); }.current-launch p { margin-top: 7px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }.current-launch:hover { border-color: var(--accent-primary); }.operation-link { font-size: var(--text-xs); color: var(--accent-primary-hover); white-space: nowrap; }.activity { flex: 0 0 9px; height: 9px; border-radius: 50%; background: var(--accent-primary-hover); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.waiting-note { display: flex; gap: 16px; padding: 0 4px; }.waiting-icon { color: var(--text-secondary); font-size: var(--text-xl); line-height: 1; }.waiting-note strong { font-size: var(--text-base); }.waiting-note p { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }.other-launches { display: grid; gap: 10px; margin-top: 12px; }.other-launches a { display: flex; align-items: baseline; gap: 10px; width: fit-content; font-size: var(--text-sm); text-decoration: none; }.other-launches a:hover { text-decoration: underline; }
.queue { display: grid; gap: 16px; }.queue-heading h2 { font-size: var(--text-md); }.servers { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); }.servers li + li { border-top: 1px solid var(--border-subtle); }.servers li { position: relative; }.servers li.launching::before, .servers li.attention::before { content: ''; width: 2px; position: absolute; inset: 0 auto 0 0; background: var(--accent-primary); pointer-events: none; }.servers li.attention::before { background: var(--state-danger); }.server { display: flex; gap: 20px; padding: 22px 24px; color: inherit; text-decoration: none; }.server:hover { background: var(--surface-raised); }.server:focus-visible { outline-offset: -3px; }.order { padding-top: 2px; color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); flex: 0 0 24px; font-variant-numeric: tabular-nums; }.order.ready { color: var(--state-online); font-family: var(--font-body); font-size: var(--text-md); padding-top: 0; }.server-content { flex: 1; min-width: 0; }.server-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }.server-heading strong { font-size: var(--text-base); }.server p { margin-top: 9px; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.server p.error { color: var(--state-danger); }.server-meta { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; color: var(--text-tertiary); font-size: var(--text-xs); }.server-meta span:last-child { color: var(--accent-primary-hover); }.screen-reader { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .page { gap: 24px; }.summary { padding: 20px; gap: 20px; }h2 { font-size: var(--text-lg); }.counts, .counts.has-cancellations { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }.counts > div + div { padding-left: 0; border-left: 0; }.counts > div:nth-child(even) { padding-left: 20px; border-left: 1px solid var(--border-subtle); }dd { font-size: var(--text-xl); }.current-launch { padding: 20px; flex-wrap: wrap; gap: 12px; }.current-launch > div { flex-basis: calc(100% - 24px); }.operation-link { margin-left: 21px; }.server { gap: 12px; padding: 20px 16px; }.order { flex-basis: 20px; font-variant-numeric: tabular-nums; }.server-heading { row-gap: 10px; }.server-heading strong { flex-basis: 100%; }.completion { font-size: var(--text-xs); }.cleanup-link { margin: 0 16px 16px; } }
.page {
display: grid;
gap: 28px;
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
.eyebrow {
display: block;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
margin-bottom: 9px;
}
a {
color: var(--accent-primary-hover);
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 3px;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.error {
color: var(--state-danger);
}
.summary {
display: grid;
gap: 24px;
padding: 28px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
background: var(--surface);
}
.summary-heading,
.queue-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.completion {
color: var(--text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.completion strong {
color: var(--text-primary);
font-weight: 600;
}
.progress {
display: flex;
height: 6px;
background: var(--surface-raised);
overflow: hidden;
border-radius: var(--radius-pill);
}
.progress span {
transition: width var(--dur-base) var(--ease-standard);
}
.progress-ready {
background: var(--state-online);
}
.progress-failed {
background: var(--state-danger);
}
.progress-cancelled {
background: var(--text-tertiary);
}
.counts.has-cancellations {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.cancellation-note,
.cancel-action p,
.cleanup-summary {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--text-secondary);
}
.cancel-action {
display: grid;
gap: 10px;
justify-items: start;
}
.cleanup-summary {
display: grid;
gap: 8px;
}
.cleanup-link {
display: grid;
gap: 6px;
margin: 0 24px 20px 68px;
padding: 14px 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-xs);
line-height: 1.6;
overflow-wrap: anywhere;
}
.cleanup-link:hover {
background: var(--surface-raised);
}
.cleanup-link strong {
color: var(--text-primary);
}
.cleanup-link.error,
.cleanup-link.error strong {
color: var(--state-danger);
}
.cleanup-operation {
color: var(--accent-primary-hover);
}
.counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin: 0;
}
.counts > div + div {
padding-left: 24px;
border-left: 1px solid var(--border-subtle);
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
font-variant-numeric: tabular-nums;
font-size: var(--text-2xl);
font-weight: 600;
margin: 6px 0 0;
letter-spacing: -0.025em;
}
.ready-count dd {
color: var(--state-online);
}
.attention-count dd {
color: var(--state-danger);
}
.current-launches {
display: grid;
gap: 12px;
}
.current-launches > .eyebrow {
margin: 0;
color: var(--accent-primary-hover);
}
.current-launch {
display: flex;
align-items: center;
gap: 16px;
color: inherit;
text-decoration: none;
padding: 20px 24px;
border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface));
}
.current-launch > div {
min-width: 0;
flex: 1;
}
.current-launch strong {
font-size: var(--text-md);
}
.current-launch p {
margin-top: 7px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.current-launch:hover {
border-color: var(--accent-primary);
}
.operation-link {
font-size: var(--text-xs);
color: var(--accent-primary-hover);
white-space: nowrap;
}
.activity {
flex: 0 0 9px;
height: 9px;
border-radius: 50%;
background: var(--accent-primary-hover);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent);
}
.waiting-note {
display: flex;
gap: 16px;
padding: 0 4px;
}
.waiting-icon {
color: var(--text-secondary);
font-size: var(--text-xl);
line-height: 1;
}
.waiting-note strong {
font-size: var(--text-base);
}
.waiting-note p {
margin-top: 6px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.other-launches {
display: grid;
gap: 10px;
margin-top: 12px;
}
.other-launches a {
display: flex;
align-items: baseline;
gap: 10px;
width: fit-content;
font-size: var(--text-sm);
text-decoration: none;
}
.other-launches a:hover {
text-decoration: underline;
}
.queue {
display: grid;
gap: 16px;
}
.queue-heading h2 {
font-size: var(--text-md);
}
.servers {
padding: 0;
margin: 0;
list-style: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.servers li + li {
border-top: 1px solid var(--border-subtle);
}
.servers li {
position: relative;
}
.servers li.launching::before,
.servers li.attention::before {
content: '';
width: 2px;
position: absolute;
inset: 0 auto 0 0;
background: var(--accent-primary);
pointer-events: none;
}
.servers li.attention::before {
background: var(--state-danger);
}
.server {
display: flex;
gap: 20px;
padding: 22px 24px;
color: inherit;
text-decoration: none;
}
.server:hover {
background: var(--surface-raised);
}
.server:focus-visible {
outline-offset: -3px;
}
.order {
padding-top: 2px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-family: var(--font-numeric);
flex: 0 0 24px;
font-variant-numeric: tabular-nums;
}
.order.ready {
color: var(--state-online);
font-family: var(--font-body);
font-size: var(--text-md);
padding-top: 0;
}
.server-content {
flex: 1;
min-width: 0;
}
.server-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.server-heading strong {
font-size: var(--text-base);
}
.server p {
margin-top: 9px;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
overflow-wrap: anywhere;
}
.server p.error {
color: var(--state-danger);
}
.server-meta {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.server-meta span:last-child {
color: var(--accent-primary-hover);
}
.screen-reader {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 600px) {
.page {
gap: 24px;
}
.summary {
padding: 20px;
gap: 20px;
}
h2 {
font-size: var(--text-lg);
}
.counts,
.counts.has-cancellations {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.counts > div + div {
padding-left: 0;
border-left: 0;
}
.counts > div:nth-child(even) {
padding-left: 20px;
border-left: 1px solid var(--border-subtle);
}
dd {
font-size: var(--text-xl);
}
.current-launch {
padding: 20px;
flex-wrap: wrap;
gap: 12px;
}
.current-launch > div {
flex-basis: calc(100% - 24px);
}
.operation-link {
margin-left: 21px;
}
.server {
gap: 12px;
padding: 20px 16px;
}
.order {
flex-basis: 20px;
font-variant-numeric: tabular-nums;
}
.server-heading {
row-gap: 10px;
}
.server-heading strong {
flex-basis: 100%;
}
.completion {
font-size: var(--text-xs);
}
.cleanup-link {
margin: 0 16px 16px;
}
}
</style>