@@ -4,7 +4,6 @@
import { page } from '$app/stores' ;
import { goto } from '$app/navigation' ;
import { pollVisible } from '$ui/poll.js' ;
import { confirmAction } from '$ui/confirm.js' ;
import Tabs from '$ui/components/primitives/Tabs.svelte' ;
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte' ;
import ListFooter from '$ui/components/primitives/ListFooter.svelte' ;
@@ -15,10 +14,8 @@
import Button from '$ui/components/primitives/Button.svelte' ;
import EmptyState from '$ui/components/primitives/EmptyState.svelte' ;
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte' ;
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte' ;
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte' ;
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte' ;
import type { BlueprintSummary , CollectionSummary } from '$lib/deployment-plan.js' ;
import type { CollectionSummary } from '$lib/deployment-plan.js' ;
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte' ;
import { provisioningActions , actionLabels , actionCompleted , type ProvisioningAction } from '$lib/provisioning.js' ;
@@ -35,17 +32,9 @@
let loading = $state ( true );
let error = $state ( '' );
let open = $state ( false );
let blueprintId = $state ( '' );
let builderOpen = $state ( false );
let editingBlueprint = $state < BlueprintSummary | null >( null );
let collectionOpen = $state ( false );
let housePartyOpen = $state ( false );
let housePartyNotice = $state ( '' );
let collections = $state < CollectionSummary [] >([]);
let blueprints = $state < BlueprintSummary [] >([]);
let blueprintsLoading = $state ( true );
let blueprintError = $state ( '' );
let removing = $state ( '' );
let tab = $state ( 'active' );
let limit = $state ( 25 );
let collectionLimit = $state ( 10 );
@@ -54,7 +43,7 @@
let cancellationNotice = $state ( '' );
$effect (() => {
const params = $page . url . searchParams ;
tab = [ 'active' , 'blueprints' , 'history' ]. includes ( params . get ( 'view' ) ?? '' ) ? params . get ( 'view' ) ! : 'active' ;
tab = [ 'active' , 'history' ]. includes ( params . get ( 'view' ) ?? '' ) ? params . get ( 'view' ) ! : 'active' ;
action = provisioningActions . some ( value => value === params . get ( 'action' )) ? params . get ( 'action' ) ! : 'all' ;
historyState = params . get ( 'status' ) === 'attention' ? 'attention' : 'all' ;
search = params . get ( 'server' ) ?? '' ;
@@ -79,8 +68,7 @@
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 : 'blueprints' , label : 'Blueprints' , badge :blueprintsLoading ? null : blueprints . length },{ id : ' history', label : 'History' }]);
$effect (() => { if ( tab === 'blueprints' ) void loadBlueprints (); });
const tabs = $derived . by (() => [{ id : 'active' , label : 'Active' , badge :active.length },{ id : 'history' , label : 'History' }]);
let action = $state ( 'all' );
let search = $state ( '' );
let refreshing = false ;
@@ -102,30 +90,7 @@
} catch ( err ) { error = ( err as Error ). message ; }
finally { loading = false ; refreshing = false ; }
}
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 ; }
}
onMount (() => pollVisible ( load , 5000 ));
async function loadBlueprints() {
try {
const res = await fetch ( '/api/blueprints' );
if ( ! res . ok ) throw new Error ( 'Could not load your blueprints.' );
blueprints = ( await res . json ()). blueprints ;
blueprintError = '' ;
} catch ( err ) { blueprintError = ( err as Error ). message ; }
finally { blueprintsLoading = false ; }
}
async function removeBlueprint ( id : string ) {
if ( ! await confirmAction ({ title : 'Remove blueprint?' , message : 'Existing servers and launch history will be kept.' , confirmLabel : 'Remove blueprint' , danger :true })) return ;
removing = id ; blueprintError = '' ;
try {
const res = await fetch ( '/api/blueprints/' + encodeURIComponent ( id ), { method : 'DELETE' });
if ( ! res . ok ) throw new Error ( 'Could not remove blueprint.' );
blueprints = blueprints . filter ( b => b . id !== id );
} catch ( err ) { blueprintError = ( err as Error ). message ; }
finally { removing = '' ; }
}
function fmt ( value : string ) { return $timeFormat . dateTime ( value ); }
function warnings ( d : Deployment ) : string [] { try { return JSON . parse ( d . warnings ); } catch { return []; } }
</ script >
@@ -137,50 +102,18 @@
</ SectionHeader >
< Tabs { tabs } bind:value = { tab } onchange= {() => saveContext ( true )} ariaLabel = "Provisioning views" idPrefix = "deployment-tabs" panelId = "deployment-panel" />
< LifecycleDialog bind:open = { lifecycleOpen } / >
< DeployServerDialog bind:open onblueprint = { loadBlueprints } / >
< BlueprintBuilder bind:open = { builderOpen } blueprint= { editingBlueprint } onsaved = { blueprintSaved } / >
< DeployCollectionDialog bind:open = { collectionOpen } { blueprintId } / >
< DeployServerDialog bind:open / >
< HousePartyDialog bind:open = { housePartyOpen } onqueued= { count => { housePartyNotice = `Destruction queued for $ { count } server$ { count === 1 ? '' : 's' }. Follow progress in the Active tab . `; tab = 'active'; void load(); }} />
{ #if housePartyNotice } < p role = "status" > { housePartyNotice } </ p > { /if }
{ #if cancellationNotice } < p role = "status" > { cancellationNotice } </ p > { /if }
< div id = "deployment-panel" role = "tabpanel" aria-labelledby = { "deployment-tabs-" + tab } class="panels" >
{ #if tab === 'blueprints' }
< div class = "ui-actions" >< Button variant = "primary" onclick = {() => { editingBlueprint = null ; builderOpen = true ; }} > Create blueprint </ Button ></ div >
< section >
< h2 > Your blueprints < span > { blueprints . length } </ span ></ h2 >
{ #if blueprintError } < p role = "alert" class = "error" > { blueprintError } < button onclick = { loadBlueprints } > Retry</button ></ p > { /if }
{ #if blueprintsLoading } < p > Loading blueprints…</ p >
{ :else if ! blueprints . length } < div class = "blueprint-empty" >< strong > Plan the whole lineup.</ strong >< p > Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</ p >< button onclick = {() => { editingBlueprint = null ; builderOpen = true ; }} > Create your first blueprint →</ button ></ div >
{ : else }
< div class = "blueprints" >
{ #each blueprints . slice ( 0 , limit ) as blueprint ( blueprint . id )}
< article class = "blueprint" >
< div class = "blueprint-top" >
< span class = "eyebrow" > Server collection</ span >
< span class = "file-count" > { blueprint . totalServers } target server{ blueprint . totalServers === 1 ? '' : 's' } </ span >
</ div >
< h3 > { blueprint . name } </ h3 >
< p > { blueprint . description || 'Fill missing lobbies or match your fleet to this lineup exactly.' } </ p >
< ul class = "blueprint-lineup" > { #each blueprint . entries as entry } < li >< strong > { entry . count } × </ strong > { entry . mapName } < small > { entry . game . toUpperCase ()} </ small ></ li > { /each } </ ul >
< 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 >
</ article >
{ /each }
</ div >
{ /if }
</ section >
< ListFooter total = { blueprints . length } bind:limit onchange = {() => saveContext ()} label="blueprints" />
{ : else }
{ #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 blueprint ' : 'Launching' : 'Queued' : collection . failed ? 'Needs attention' : 'Completed' } </Badge ></ div >
< 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 >
@@ -211,7 +144,6 @@
< ListFooter total = { history . length } bind:limit onchange = {() => saveContext ()} label="operations" />
</ section >
{ /if }
{ /if }
</ div >
</ div >
@@ -237,11 +169,6 @@
. row-head , . filters , . meta { display : flex ; align-items : center ; gap : 16 px ; flex-wrap : wrap ; }
. row-head { justify-content : space-between ; }
. blueprint p , . blueprint-empty p { color : var ( -- text - secondary ); font-size : 13 px ; line-height : 1.6 ; }
. blueprint-lineup { list-style : none ; padding : 0 ; margin : 8 px 0 ; display : grid ; gap : 9 px ; font-size : 13 px ; }. blueprint-lineup strong { color : var ( -- accent - primary - hover ); margin-right : 5 px ; }. blueprint-lineup small { color : var ( -- text - secondary ); margin-left : 6 px ; }. edit { background : none ; border : 0 ; color : var ( -- accent - primary - hover ); font : inherit ; font-size : 12 px ; cursor : pointer ; }. blueprint-footer { flex-wrap : wrap ; }
. eyebrow { display : block ; text-transform : uppercase ; letter-spacing : .1 em ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); margin-bottom : 8 px ; }
. blueprints { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( min ( 290 px , 100 % ), 1 fr )); gap : 16 px ; }. blueprint { padding : 22 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface ); display : flex ; flex-direction : column ; }. blueprint h3 { margin : 8 px 0 0 ; font-size : 19 px ; overflow-wrap : anywhere ; }. blueprint > p { flex : 1 ; overflow-wrap : anywhere ; }. blueprint-top , . blueprint-footer { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }. blueprint-top . eyebrow { margin : 0 ; }. file-count { font-size : var ( -- text - xs ); color : var ( -- text - secondary ); white-space : nowrap ; }. blueprint-footer { border-top : 1 px solid var ( -- border - subtle ); padding-top : 16 px ; margin-top : 16 px ; }
. remove , . blueprint-empty button { background : none ; color : var ( -- accent - primary - hover ); border : 0 ; font : inherit ; font-size : 12 px ; cursor : pointer ; }. remove { color : var ( -- text - secondary ); }. blueprint-empty { border : 1 px dashed var ( -- border - strong ); border-radius : var ( -- radius - md ); padding : 24 px ; }. blueprint-empty strong { font-size : 15 px ; }. blueprint-empty button { padding : 8 px 0 0 ; }
section { display : grid ; gap : 16 px ; }
h2 { font-size : 18 px ; margin : 0 ; } h2 span { opacity : .6 ; margin-left : 8 px ; }
p { margin : 8 px 0 ; } a { color : var ( -- accent ); }