@@ -1,138 +0,0 @@
< script lang = "ts" >
import { onMount } from 'svelte' ;
import { pollVisible } from '$ui/poll.js' ;
import ServerIdentity from '$ui/components/ServerIdentity.svelte' ;
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte' ;
import { page } from '$app/stores' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte' ;
import Badge from '$ui/components/primitives/Badge.svelte' ;
import type { CollectionDetail } from '$lib/deployment-plan.js' ;
type CollectionServer = CollectionDetail [ 'servers' ][ number ];
let detail = $state < CollectionDetail | null >( null );
let error = $state ( '' );
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' , 'cancelled' ]. includes ( server . state );
const exact = $derived ( collection ? . mode === 'exact' );
const currentServers = $derived ( detail ? . servers . filter ( launching ) ?? []);
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 for empty server' , 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 . state === 'queued' && server . queuePosition === 1 ) return 'Up next' ;
if ( server . action === 'destroy' && server . state === 'done' ) return 'Removed' ;
if ( server . action === 'destroy' && server . state === 'running' ) return 'Removing' ;
return labels [ server . state ] ?? server . state ;
}
function queueHint ( server : CollectionServer ) {
if ( waitingForPort ( server )) return server . stage ;
if ( server . queuePosition === 1 ) return 'Next in the launch queue. Starts when a place opens.' ;
if ( server . queuePosition ) return ` ${ server . queuePosition - 1 } queued server ${ server . queuePosition === 2 ? '' : 's' } ahead. Starts automatically when it is this lobby’ s turn.` ;
return 'Waiting for its turn in the launch queue.' ;
}
async function load() {
const id = $page . params . id ?? '' ;
const current = ++ requestNumber ;
try {
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 ; }
}
$effect (() => { $page . params . id ; detail = null ; 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 >
< div class = "page" >
< SectionHeader title = { collection ? . name ?? 'Collection launch' } >
{ # snippet crumbs ()} < Breadcrumbs items = {[{ label : 'Provisioning' , href : '/provisioning' }, { 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 > { exact ? collection . active ? 'Applying your collection' : collection . failed ? 'Collection finished with issues' : 'Collection applied' : collection . active ? currentServers . length ? 'Bringing your lineup online' : 'Your lineup is queued' : collection . failed ? 'Launch complete with issues' : 'Your lineup is ready' } </ h2 ></ div >
< span class = "completion" >< strong > { collection . done + collection . failed } </ 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 } aria-valuetext= { ` ${ collection . done } completed, ${ collection . failed } need attention, ${ collection . active } remaining` } >
< 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 >
</ div >
< dl class = "counts" >
< 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 >
</ dl >
< p class = "screen-reader" role = "status" > { collection . done } completed, { collection . provisioning + collection . verifying } in progress, { collection . queued } queued, { collection . failed } need attention.</ p >
</ section >
{ #if currentServers . length }
< 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 = "/provisioning/ { server . id } " >
< 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 >
</ 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' : otherLaunches . length ? 'Waiting for an earlier launch' : firstQueued ? . queuePosition && firstQueued . queuePosition > 1 ? 'Earlier servers are ahead in the queue' : 'Your launch is up next' } </ strong >
{ #if otherLaunches . length }
< p > Your collection will start automatically as earlier launches finish. Currently launching:</ p >
< div class = "other-launches" > { #each otherLaunches as active ( active . id )} < a href = "/provisioning/ { active . id } " >< ServerIdentity name = { active . server } / > < span aria-hidden = "true" > →</ span ></ a > { /each } </ div >
{ : else } < p > { firstQueued ? queueHint ( firstQueued ) : 'Your servers will begin automatically.' } Progress updates automatically.</ p > { /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 >
{ /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 >
< ol class = "servers" >
{ #each detail . servers as server , index ( server . id )}
< li class:launching = { launching ( server )} class:attention= { needsAttention ( server )} >
< a class = "server" href = "/provisioning/ { server . id } " >
< 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' ? 'Remove server' : `Create server · Port $ { server . port } ` } </ span >< span > { needsAttention ( server ) ? 'Review & recover' : 'View operation' } < span aria-hidden = "true" > →</ span ></ span ></ div >
</ div >
</ a >
</ li >
{ /each }
</ ol >
</ section >
{ :else if ! error } < p class = "muted" role = "status" > Loading collection…</ p > { /if }
</ div >
< style >
. page { display : grid ; gap : 28 px ; }
h2 , p { margin : 0 ; } h2 { font-size : 21 px ; letter-spacing : var ( -- tracking - tight ); }. eyebrow { display : block ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .1 em ; color : var ( -- text - secondary ); margin-bottom : 9 px ; }
a { color : var ( -- accent - primary - hover ); } a : focus-visible , button : focus-visible { outline : 2 px solid var ( -- border - focus ); outline-offset : 3 px ; }. muted { color : var ( -- text - secondary ); font-size : 12 px ; line-height : 1.6 ; }. error { color : var ( -- state - danger ); }
. summary { display : grid ; gap : 24 px ; padding : 28 px ; border : 1 px 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 : 16 px ; flex-wrap : wrap ; }. completion { color : var ( -- text - secondary ); font-size : 13 px ; white-space : nowrap ; font-variant-numeric : tabular-nums ; }. completion strong { color : var ( -- text - primary ); font-weight : 600 ; }
. progress { display : flex ; height : 6 px ; 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 ); }
. counts { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 16 px ; margin : 0 ; }. counts > div + div { padding-left : 24 px ; border-left : 1 px solid var ( -- border - subtle ); } dt { color : var ( -- text - secondary ); font-size : 12 px ; } dd { font-variant-numeric : tabular-nums ; font-size : 28 px ; font-weight : 600 ; margin : 6 px 0 0 ; letter-spacing : -.025 em ; }. ready-count dd { color : var ( -- state - online ); }. attention-count dd { color : var ( -- state - danger ); }
. current-launches { display : grid ; gap : 12 px ; }. current-launches > . eyebrow { margin : 0 ; color : var ( -- accent - primary - hover ); }. current-launch { display : flex ; align-items : center ; gap : 16 px ; color : inherit ; text-decoration : none ; padding : 20 px 24 px ; border : 1 px 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 : 16 px ; }. current-launch p { margin-top : 7 px ; color : var ( -- text - secondary ); font-size : 13 px ; line-height : 1.6 ; overflow-wrap : anywhere ; }. current-launch : hover { border-color : var ( -- accent - primary ); }. operation-link { font-size : 12 px ; color : var ( -- accent - primary - hover ); white-space : nowrap ; }. activity { flex : 0 0 9 px ; height : 9 px ; border-radius : 50 % ; background : var ( -- accent - primary - hover ); box-shadow : 0 0 0 5 px color-mix ( in srgb , var ( -- accent - primary ) 12 % , transparent ); }
. waiting-note { display : flex ; gap : 16 px ; padding : 0 4 px ; }. waiting-icon { color : var ( -- text - secondary ); font-size : 26 px ; line-height : 1 ; }. waiting-note strong { font-size : 14 px ; }. waiting-note p { margin-top : 6 px ; color : var ( -- text - secondary ); font-size : 13 px ; line-height : 1.6 ; }. other-launches { display : grid ; gap : 10 px ; margin-top : 12 px ; }. other-launches a { display : flex ; align-items : baseline ; gap : 10 px ; width : fit-content ; font-size : 13 px ; text-decoration : none ; }. other-launches a : hover { text-decoration : underline ; }
. queue { display : grid ; gap : 16 px ; }. queue-heading h2 { font-size : 17 px ; }. servers { padding : 0 ; margin : 0 ; list-style : none ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); overflow : hidden ; background : var ( -- surface ); }. servers li + li { border-top : 1 px solid var ( -- border - subtle ); }. servers li { position : relative ; }. servers li . launching :: before , . servers li . attention :: before { content : '' ; width : 2 px ; 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 : 20 px ; padding : 22 px 24 px ; color : inherit ; text-decoration : none ; }. server : hover { background : var ( -- surface - raised ); }. server : focus-visible { outline-offset : -3 px ; }. order { padding-top : 2 px ; color : var ( -- text - tertiary ); font-size : 12 px ; font-family : var ( -- font - mono ); flex : 0 0 24 px ; }. order . ready { color : var ( -- state - online ); font-family : var ( -- font - body ); font-size : 17 px ; padding-top : 0 ; }. server-content { flex : 1 ; min-width : 0 ; }. server-heading { display : flex ; justify-content : space-between ; align-items : flex-start ; gap : 12 px ; flex-wrap : wrap ; }. server-heading strong { font-size : 14 px ; }. server p { margin-top : 9 px ; color : var ( -- text - secondary ); font-size : 12 px ; line-height : 1.6 ; overflow-wrap : anywhere ; }. server p . error { color : var ( -- state - danger ); }. server-meta { display : flex ; justify-content : space-between ; gap : 12 px ; flex-wrap : wrap ; margin-top : 12 px ; color : var ( -- text - tertiary ); font-size : 11 px ; }. server-meta span : last-child { color : var ( -- accent - primary - hover ); }. screen-reader { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; overflow : hidden ; clip-path : inset ( 50 % ); white-space : nowrap ; border : 0 ; }
@ media ( max-width : 600px ) { . page { gap : 24 px ; }. summary { padding : 20 px ; gap : 20 px ; } h2 { font-size : 19 px ; }. counts { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 20 px ; }. counts > div + div { padding-left : 0 ; border-left : 0 ; }. counts > div : nth-child ( even ) { padding-left : 20 px ; border-left : 1 px solid var ( -- border - subtle ); } dd { font-size : 25 px ; }. current-launch { padding : 20 px ; flex-wrap : wrap ; gap : 12 px ; }. current-launch > div { flex-basis : calc ( 100 % - 24 px ); }. operation-link { margin-left : 21 px ; }. server { gap : 12 px ; padding : 20 px 16 px ; }. order { flex-basis : 20 px ; }. server-heading { row-gap : 10 px ; }. server-heading strong { flex-basis : 100 % ; }. completion { font-size : 12 px ; } }
</ style >