@@ -1,177 +1,178 @@
< script lang = "ts" >
import { onDestroy , onMount , untrack } from 'svelte' ;
import { page } from '$app/stores' ;
import type { PageData } from './$types' ;
import type { ProvisioningDetail } from '$lib/provisioning-detail.js' ;
import { actionLabels } from '$lib/provisioning.js' ;
import { timeFormat } from '$ui/stores/time-zone.js' ;
import { onMount } from 'svelte' ;
import { pollVisible } from '$ui/poll.js' ;
import { serverTitle } from '$ui/server-labels.js' ;
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte ';
import OperationHistory from '$ui/components/OperationHistory.svelte ';
import type { OperationStep } from '$lib/operation-flow .js' ;
import { page } from '$app/stores' ;
import { toast } from '$ui/toast.js ';
import { createProvisioningRefresh , type ProvisioningRefreshState } from '$ui/provisioning-refresh.js ';
import { elapsedTime , isProvisioningDetail , provisioningOutcome , provisioningReturn } from '$ui/provisioning-presentation .js' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte' ;
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte' ;
import Badge from '$ui/components/primitives/Badge.svelte' ;
import Button from '$ui/components/primitives/Button.svelte' ;
import DeployServerDialog from '$ui/components/DeployServerDialog .svelte' ;
import Card from '$ui/components/primitives/Card .svelte' ;
import CopyValue from '$ui/components/CopyValue.svelte' ;
import ProvisioningProgress from '$ui/components/ProvisioningProgress.svelte' ;
import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte' ;
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte' ;
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js ';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte ';
import { actionLabels , actionCompleted , type ProvisioningAction } from '$lib/provisioning.js' ;
interface Deployment {
id : string ; expected_map? : string ; server : string ; server_deleted_at? : string | null ; action : ProvisioningAction ; state : string ;
stage : string ; started_at : string ; finished_at : string | null ;
triggered_by : string ; error : string | null ; warnings : string ; server_exists : number ;
}
interface Detail { deployment : Deployment ; events : { id : number ; created_at : string ; message : string }[]; sources : { kind : 'campaign' | 'beta' | 'maintenance' | 'watchdog' ; source_id : string }[]; recipe : LaunchRecipeSummary | null ; blueprintName : string | null ; canResumeVerification : boolean ; canResumeCleanup : boolean ; canResumeRebuild : boolean ; canCancelRemoval : boolean ; collection : { id : string ; name : string } | null }
let detail = $state < Detail | null >( null );
let error = $state ( '' );
let refreshing = false ;
let now = $state ( Date . now ());
let open = $state ( false );
let resuming = $state ( false );
let deployment = $derived ( detail ? . deployment );
let active = $derived ( ! deployment ? . server_deleted_at && [ 'queued' , 'awaiting-health' , 'waiting' , 'running' ]. includes ( deployment ? . state ?? '' ));
let warnings = $derived . by (() => {
try { return JSON . parse ( deployment ? . warnings ?? '[]' ) as string []; } catch { return []; }
let { data } : { data : PageData } = $props () ;
const initial = untrack (() => data . detail );
let view = $state < ProvisioningRefreshState < ProvisioningDetail >>( {
id : initial.deployment.id , detail : initial , refreshing : false , busy : null ,
loadError : '' , actionError : '' , notice : '' , refreshedAt : initial.generatedAt
});
const createSteps = [ 'Prepare server files' , 'Create Komodo deployment' , 'Start container' , 'Verify Komodo health' , 'Verify RCON health' , 'Verify selected map' ] ;
const createStages = [ 'Preparing server files' , 'Creating Komodo deployment' , 'Starting container' , 'Waiting for Komodo health' , 'Waiting for RCON health' , 'Waiting for selected map' ] ;
const destroySteps = [ 'Wait for eligibility' , 'Stop and remove container' , 'Delete Komodo deployment' , 'Remove server files and registry' ] ;
const destroyStages = [ 'Waiting to verify' , 'Stopping and removing container' , 'Deleting Komodo deployment' , 'Removing server files and registry' ] ;
let steps = $derived ( deployment ? . action === 'destroy' ? [ 'Request destruction' , ... destroySteps ]
: deployment ? . action === 'rebuild' ? [ 'Request rebuild' , ... destroySteps , 'Queue fresh instance' , ... createSteps ]
: deployment ? . action === 'create' ? [ 'Request creation' , ... createSteps ]
: [ 'Request operation' , 'Wait for eligibility' , ` ${ deployment ? actionLabels [ deployment . action ] : 'Start' } container` , deployment ? . action === 'stop' ? 'Verify container stopped' : 'Verify container and RCON health '] );
let currentStep = $derived . by (() => {
if ( ! deployment ) return 0 ;
if ( deployment . state === 'done' || deployment . state === 'warning' ) return steps . length ;
const { action , state , stage } = deployment ;
if ( state === 'waiting' && stage !== 'Removing server files and registry' ) return 1 ;
if ( action === 'start' || action === 'restart' || action === 'stop' ) return state === 'awaiting-health' ? 3 : state === 'running' ? 2 : stage.includes ( 'container' ) ? 2 : 1 ;
if ( state === 'queued' ) return action === 'rebuild' ? 5 : 0 ;
const stages = action === 'destroy' ? [ 'Starting' , ... destroyStages ]
: action === 'rebuild' ? [ 'Starting' , ... destroyStages , 'Queued for server creation' , ... createStages ]
: [ 'Starting' , ... createStages ];
const index = stages . findIndex ( value => stage === value || stage . startsWith ( value + ':' ));
if ( index < 0 && state === 'awaiting-health' ) return action === 'rebuild' ? 9 : 4 ;
return Math . max ( 0 , index );
let now = $state ( Date . parse ( initial . generatedAt )) ;
let launchOpen = $state ( false ) ;
const controller = createProvisioningRefresh < ProvisioningDetail >({ validate : isProvisioningDetail , onChange : value => { view = value ; } }) ;
const detail = $derived ( view . detail ) ;
const deployment = $derived ( detail ? . deployment );
const outcome = $derived ( detail ? provisioningOutcome ( detail ) : null );
const returnTo = $derived . by (() => {
const requested = $page . url . searchParams . get ( 'returnTo ');
if ( requested ) return provisioningReturn ( requested );
if ( detail ? . collection ) return { href : `/collections/launches/ ${ encodeURIComponent ( detail . collection . id ) } ` , label : `Back to ${ detail . collection . name } ` } ;
if ( detail ? . sources . length === 1 && detail . sources [ 0 ]. href ) return { href : detail.sources [ 0 ]. href , label : `Back to ${ detail . sources [ 0 ]. label } ` } ;
return provisioningReturn ( null ) ;
});
let explanation = $derived . by (() => {
if ( ! deployment ) return '' ;
if ( deployment . server_deleted_at ) return 'This server was deleted. Its recorded operation and results are preserved below. ';
if ( detail ? . canResumeRebuild ) return 'The old Komodo instance was deleted. Resume file cleanup, then create a fresh instance in the reserved server slot.' ;
if ([ 'start' , 'restart' , 'stop' ]. includes ( deployment . action )) {
if ( deployment . state === 'done' ) return deployment . action === 'stop' ? 'Komodo confirmed the container is stopped. Start it again when ready.' : 'The container is healthy and RCON responds.' ;
if ([ 'failed' , 'interrupted' ]. includes ( deployment . state )) return 'Inspect the server before submitting another operation. An uncertain response may have reached Komodo.' ;
return deployment . state === 'waiting' ? 'Waiting for other work and a safe server state before continuing.' : 'The request is saved. Verifying the resulting container state automatically.' ;
}
if ( deployment . state === 'cancelled' ) return 'The queued removal was cancelled before removal began. The server and its files were kept.' ;
if ( detail ? . canResumeCleanup ) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.' ;
if ( deployment . state === 'waiting' && deployment . stage === 'Removing server files and registry' ) return 'Local cleanup is queued. Komodo deletion has already succeeded.' ;
if ( deployment . state === 'done' ) return [ 'create' , 'rebuild' ]. includes ( deployment . action )
? 'The container is healthy, RCON responds, and the reported engine map matches your selection.'
: 'The container, deployment, and server registration have been removed.' ;
if ( deployment . state === 'warning' ) return 'The operation finished with the warnings below. Review any remaining cleanup.' ;
if ( deployment . state === 'failed' || deployment . state === 'interrupted' ) return detail ? . canResumeVerification
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.' ;
if ( deployment . state === 'queued' ) return 'Your request is saved in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.' ;
if ( deployment . action === 'rebuild' && deployment . state === 'waiting' ) return 'The rebuild waits for other work to finish, an empty server, and available recovery and startup capacity. The old instance and all of its files will be deleted before a fresh instance is created.' ;
if ( deployment . state === 'waiting' ) return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.' ;
if ( deployment . stage . startsWith ( 'Waiting for selected map' )) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.' ;
if ( deployment . stage . startsWith ( 'Waiting for RCON' )) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.' ;
if ( deployment . state === 'awaiting-health' ) return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.' ;
return [ 'create' , 'rebuild' ]. includes ( deployment . action ) ? 'Installing the latest promoted setup and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.' ;
const serverHref = $derived ( deployment && deployment . server_exists && ! deployment . server_deleted_at ? `/servers/ ${ encodeURIComponent ( deployment . server ) } ` : null );
const recipeStatus = $derived ( detail ? . progress ? . recipeStatus ?? null ) ;
const recipeHeading = $derived ( recipeStatus === 'queued' ? 'Setup captured when queued' : recipeStatus === 'attempted' ? 'Files selected for this attempt' : 'Recorded launch files ') ;
$effect (() => {
const snapshot = data . detail ;
untrack (() => {
launchOpen = false ;
now = Date . parse ( snapshot . generatedAt ) ;
controller . setOperation ( snapshot . deployment . id , snapshot , snapshot . generatedAt );
}) ;
});
function duration ( start : string , end : number ) {
const seconds = Math . max ( 0 , Math . floor (( end - new Date ( start ). getTime ()) / 1000 ));
if ( seconds < 60 ) return ` ${ seconds } s` ;
if ( seconds < 3600 ) return ` ${ Math . floor ( seconds / 60 ) } m ${ seconds % 60 } s` ;
return ` ${ Math . floor ( seconds / 3600 ) } h ${ Math . floor ( seconds % 3600 / 60 ) } m` ;
}
let stageStarted = $derived ( detail ? . events . slice (). reverse (). find ( event => event . message === deployment ? . stage ) ? . created_at ?? deployment ? . started_at );
async function resume() {
if ( ! deployment || resuming ) return ;
resuming = true ;
onMount (() => pollVisible (() => { now = Date . now (); return controller . refresh (); }, 5000 , false ));
onDestroy (() => controller . dispose ( ));
function fmt ( value : string | null | undefined ) { return value ? $timeFormat . dateTime ( value ) : 'Not recorded' ; }
async function copyLink() {
try {
const res = await fetch ( `/api/provisioning/ ${ encodeURIComponent ( deployment . id ) } /resume` , { method : 'POST' } );
const body = await res . json () ;
if ( ! res . ok ) throw new Error ( body . error ?? 'Could not resume the operation.' );
await load ( );
} catch ( err ) { error = ( err as Error ). message ; }
finally { resuming = false ; }
const url = new URL ( $page . url );
url . search = '' ; url . hash = '' ;
await navigator . clipboard . writeText ( url . toString () );
toast ( 'Operation link copied.' , { variant : 'success' } );
} catch { toast ( 'Copy unavailable. Copy this page’ s address from your browser.' , { variant : 'warning' }) ; }
}
async function cancelRemoval() {
if ( ! deployment || resuming ) return ;
resuming = true ; error = '' ;
try {
const response = await fetch ( `/api/provisioning/ ${ encodeURIComponent ( deployment . id ) } /cancel` , { method : 'POST' });
const body = await response . json ();
if ( ! response . ok ) throw new Error ( body . error ?? 'Could not cancel this request.' );
await load ();
} catch ( err ) { error = ( err as Error ). message ; await load (); error = ( err as Error ). message ; }
finally { resuming = false ; }
}
async function load() {
if ( refreshing ) return ;
refreshing = true ;
const id = $page . params . id ?? '' ;
try {
const res = await fetch ( '/api/provisioning/' + encodeURIComponent ( id ));
if ( ! res . ok ) throw new Error ( res . status === 404 ? 'Provisioning operation not found.' : 'Could not refresh provisioning operation.' );
const body = await res . json ();
if ( $page . params . id === id ) { detail = body ; error = '' ; }
} catch ( err ) { error = ( err as Error ). message ; }
finally { refreshing = false ; }
}
$effect (() => { $page . params . id ; detail = null ; void load (); });
onMount (() => pollVisible (() => { now = Date . now (); return load (); }, 5000 , false ));
function fmt ( iso : string ) { return $timeFormat . dateTime ( iso ); }
</ script >
< svelte:head >< title > { deployment ? actionLabels [ deployment . action ] + ' ' + deployment . server : 'Provisioning' } | Electric Cherry</ title ></ svelte:head >
< div class = "page" >
< SectionHeader title = { deployment ? actionLabels [ deployment . action ] + ' server' : 'Provisioning' } subtitle= { deployment ? serverTitle ( deployment . server ) : undefined } >
{ # snippet crumbs ()} < Breadcrumbs items = {[{ label : 'Provisioning' , href : '/provisioning' }, { label : deployment ? serverTitle ( deployment . server ) : 'Details' }]} / > { /snippet }
</ SectionHeader >
{ #if error } < p role = "alert" class = "error" > { error } < button onclick = { load } > Retry</button ></ p > { /if }
{ #if detail ? . collection } < a href = "/collections/launches/ { detail . collection . id } " > ← Back to { detail . collection . name } collection</ a > { /if }
{ #if deployment }
< section class = "summary" >
< div class = "top" >< StatusBadge state = { deployment . state } / >
{ #if deployment . server_deleted_at } < Badge > Deleted</ Badge > { :else if deployment . server_exists } < a href = "/servers/ { encodeURIComponent ( deployment . server )} " > View server</ a > { /if }
</ div >
< h2 > { deployment . state === 'done' ? actionCompleted [ deployment . action ] : deployment . stage } </ h2 >
< p class = "explanation" aria-live = "polite" > { explanation } </ p >
< div class = "timing" >< span > Total time < strong > { duration ( deployment . started_at , deployment . finished_at ? new Date ( deployment . finished_at ). getTime () : now )} </ strong ></ span > { #if active && stageStarted } < span > This step < strong > { duration ( stageStarted , now )} </ strong ></ span > { /if } </ div >
{ #if detail ? . canResumeVerification && ! deployment . server_deleted_at } < div >< Button disabled = { resuming } loading= { resuming } onclick = { resume } > Resume health verification </ Button ></ div > { /if }
{ #if detail ? . canResumeRebuild && ! deployment . server_deleted_at } < div >< Button disabled = { resuming } loading= { resuming } onclick = { resume } > Resume rebuild cleanup </ Button ></ div > { /if }
{ #if detail ? . canResumeCleanup && ! deployment . server_deleted_at } < div >< Button disabled = { resuming } loading= { resuming } onclick = { resume } > Resume cleanup </ Button ></ div > { /if }
{ #if detail ? . canCancelRemoval && ! deployment . server_deleted_at } < div >< Button disabled = { resuming } loading= { resuming } onclick = { cancelRemoval } > Cancel queued removal </ Button >< p class = "muted" > Available until removal begins. Cancelling keeps the server and its files.</ p ></ div > { /if }
{ #if deployment . server_exists && ! deployment . server_deleted_at && deployment . state !== 'done' && deployment . state !== 'cancelled' } < div class = "recovery-links" >< a href = "/servers/ { encodeURIComponent ( deployment . server )} ?tab=logs" > Open startup logs</ a >< a href = "/servers/ { encodeURIComponent ( deployment . server )} ?tab=diagnostics" > Diagnose connection</ a > { #if deployment . stage . includes ( 'map' )} < a href = "/servers/ { encodeURIComponent ( deployment . server )} ?tab=config" > Review map configuration</ a > { /if } </ div > { /if }
{ #if deployment . error } < p role = "alert" class = "error" > { deployment . error } </ p > { /if }
{ #each warnings as warning } < p class = "warning" > { warning } </ p > { /each }
{ #if deployment . expected_map } < p > Expected map: < strong > { deployment . expected_map } </ strong ></ p > { /if }
< dl >< div >< dt > Started</ dt >< dd > { fmt ( deployment . started_at )} </ dd ></ div >< div >< dt > Finished</ dt >< dd > { deployment . finished_at ? fmt ( deployment . finished_at ) : 'In progress' } </ dd ></ div >< div >< dt > Requested by</ dt >< dd > { deployment . triggered_by } </ dd ></ div ></ dl >
{ #if detail ? . sources . length } < div class = "recovery-links" aria-label = "Requesting work" > { #each detail . sources as source } < a href = { source . kind === 'campaign' ? `/campaigns/$ { encodeURIComponent ( source . source_id )} ` : source.kind === 'beta' ? ` /betas/ $ { encodeURIComponent ( source . source_id )} ` : source.kind === 'watchdog' ? ` /maintenance/ $ { encodeURIComponent ( source . source_id )} ` : ` /maintenance?view=activity#job-$ { source . source_id } `}>{source.kind === 'campaign' ? 'Campaign' : source.kind === 'beta' ? 'Beta' : source.kind === 'watchdog' ? 'Maintenance finding' : 'Maintenance'} {source.source_id}</a>{/each}</div>{/if}
</section>
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? ` Launched from $ { detail . blueprintName } ` : 'Recorded launch files' } </h2 >< Button variant = "ghost" onclick = {() => open = true } > Launch another server →</ Button ></ div >< p class = "muted" > Launch this map with the latest promoted files using a fresh server ID and port. This does not repair the existing server.</ p >< LaunchRecipe recipe = { detail . recipe } historical /></ section >< DeployServerDialog bind:open sourceDeploymentId = { deployment . id } / > { /if }
< OperationHistory steps = { steps . map (( label , index ) : OperationStep => ({ label , state :deployment.state === 'cancelled' ? 'pending' : index < currentStep ? 'done' : index === currentStep ? ( ! active && [ 'failed' , 'interrupted' ]. includes ( deployment . state ) ? 'failed' : 'current' ) : 'pending' }))} events= { detail ? . events ?? []} { active } />
< svelte:head >< title > { deployment ? `$ { actionLabels [ deployment . action ]} $ { deployment . server } ` : 'Provisioning'} | Electric Cherry</title ></svelte:head>
{ :else if ! error } < p > Loading provisioning operation...</ p > { /if }
<div class="page">
<SectionHeader title={deployment ? ` $ { actionLabels [ deployment . action ]} · $ { serverTitle ( deployment . server )} ` : 'Provisioning operation'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: returnTo.href.startsWith('/provisioning') ? returnTo.href : '/provisioning' }, { label: 'Operation' }]} />{/snippet}
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
</SectionHeader>
<div class="identity-row">
{#if deployment}<div class="operation-id"><span>Operation</span><CopyValue value={deployment.id} label="operation ID" /></div>{/if}
<div class="identity-links"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button></div>
</div>
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Refresh unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div><Button size="sm" disabled={view.refreshing || !!view.busy} onclick={() => controller.forceRefresh()}>Retry refresh</Button></div>{/if}
{#if deployment && detail && outcome}
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title">
<div class="outcome-top"><div class="badges"><StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning">Recovery capacity held</Badge>{/if}</div><span class="record-label">Operation outcome</span></div>
<div class="outcome-message" aria-live="polite" aria-atomic="true"><h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p></div>
{#if deployment.error}<div class="failure" role="alert"><strong>Recorded error</strong><p>{deployment.error}</p></div>{/if}
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · {fmt(detail.activity.failureExcerpt.created_at)}</span>{detail.activity.failureExcerpt.message}</p>{/if}
{#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancelRemoval}
<div class="recovery-actions">
{#if detail.canResumeVerification}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Health verification requested. The existing instance will be observed without another creation or restart.')}>Resume health verification</Button>{/if}
{#if detail.canResumeRebuild}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Rebuild cleanup requested. Fresh creation waits for local cleanup to finish.')}>Resume rebuild cleanup</Button>{/if}
{#if detail.canResumeCleanup}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Local cleanup requested. Remote container deletion will not be repeated.')}>Resume cleanup</Button>{/if}
{#if detail.canCancelRemoval}<div><Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Queued removal cancelled. The server and its files were kept.')}>Cancel queued removal</Button><p class="action-help">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
</div>
{/if}
{#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if}
{#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if}
{#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/if}
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded {fmt(detail.progress.recovery.at)}</span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={` /provisioning/ $ { encodeURIComponent ( detail . progress . recovery . operationId )} `}>View recovery operation →</a>{/if}</div>{/if}
{#if serverHref && !['done', 'cancelled'].includes(deployment.state)}<div class="diagnostics"><a href={` $ { serverHref } ? tab = logs ` } >Open server logs</ a >< a href = { ` ${ serverHref } ?tab=diagnostics` } > Diagnose connection </ a > { #if detail . progress ? . phase === 'verify-map' || detail . progress ? . checks . some ( check => check . key === 'map' && check . result !== 'pass' )} < a href = { ` ${ serverHref } ?tab=config` } > Review map configuration </ a > { /if } </ div > { /if }
< div class = "timing" >
< div >< span > { outcome . active ? 'Total elapsed' : 'Recorded duration' } </ span >< strong > { elapsedTime ( deployment . started_at , deployment . finished_at ? Date . parse ( deployment . finished_at ) : now )} </ strong ></ div >
{ #if outcome . active } < div >< span > Time in phase</ span >< strong > { elapsedTime ( detail . progress ? . phaseStartedAt , now )} </ strong ></ div > { /if }
< div >< span > Last evaluated</ span >< time datetime = { detail . progress ? . observedAt ?? undefined } > { fmt ( detail . progress ? . observedAt )} </time ></ div >
</ div >
< div class = "freshness" >< span > Page updated < time datetime = { view . refreshedAt ?? undefined } > { fmt ( view . refreshedAt )} </time > · updates automatically</ span >< Button size = "sm" variant = "ghost" disabled = { view . refreshing || !! view . busy } loading= { view . refreshing } onclick = {() => controller . forceRefresh ()} > Refresh</Button ></ div >
</ section >
{ #key deployment . id }
< div class = "detail-grid" >
< div class = "execution-column" >< ProvisioningProgress { detail } />< ProvisioningActivity { detail } /></ div >
< aside class = "context-column" aria-label = "Operation context" >
< Card as = "section" padding = "md" ariaLabel = "Operation details" >
< div class = "context-section" >< h2 > Operation details</ h2 >< dl class = "metadata" >
< div >< dt > Requested by</ dt >< dd > { deployment . triggered_by } </ dd ></ div >
< div >< dt > Requested at</ dt >< dd >< time datetime = { deployment . started_at } > { fmt ( deployment . started_at )} </time ></ dd ></ div >
< div >< dt > Submission attempted</ dt >< dd > { fmt ( deployment . submitted_at )} </ dd ></ div >
< div >< dt > Finished</ dt >< dd > { deployment . finished_at ? fmt ( deployment . finished_at ) : outcome . active ? 'In progress' : 'Not recorded' } </ dd ></ div >
{ #if deployment . server_deleted_at } < div >< dt > Identity deleted</ dt >< dd > { fmt ( deployment . server_deleted_at )} </ dd ></ div > { /if }
</ dl ></ div >
</ Card >
{ #if detail . sources . length || detail . collection } < Card as = "section" padding = "md" ariaLabel = "Related work" >< div class = "context-section" >< h2 > Related work</ h2 >< p > The requesting work may have further checks after this operation finishes.</ p >< ul class = "related" >
{ #if detail . collection } < li >< a href = { `/collections/launches/ ${ encodeURIComponent ( detail . collection . id ) } ` } > { detail . collection . name } </a >< span > Collection launch</ span ></ li > { /if }
{ #each detail . sources as source ( `$ { source . kind } : $ { source . source_id } `)}<li>{#if source.href}<a href={source.href}>{source.label}</a>{:else}<strong>{source.label}</strong><span>Source record no longer available</span>{/if}<code>{source.source_id}</code></li>{/each}
</ul></div></Card>{/if}
{#if detail.capacity.pools.length}<Card as="section" padding="md" ariaLabel="Capacity"><div class="context-section"><h2>Capacity</h2>
{#each detail.capacity.pools as pool (pool.kind)}<div class="capacity-pool"><div class="capacity-heading"><strong>{pool.kind === 'startup' ? 'Startup slots' : 'Restart recovery slots'}</strong><span>{pool.used} / {pool.limit} in use</span></div>
{#if pool.ownsSlot}<p>This operation holds a slot.</p>{/if}
{#if pool.requestOrder !== null}<p>Request order: {pool.requestOrder}. Eligible requests may pass blocked work; this is not a guaranteed start order.</p>{/if}
{#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={` /provisioning/ $ { encodeURIComponent ( holder . id )} ` } >{ serverTitle ( holder . server )} </ a > { : else } < span > { serverTitle ( holder . server )} · operation not recorded</ span > { /if } </ li > { /each } </ ul ></ details > { /if }
</ div > { /each } < p class = "small" > Capacity snapshot { fmt ( detail . capacity . evaluatedAt )} </ p >
</ div ></ Card > { /if }
< Card as = "section" padding = "md" ariaLabel = "Recorded launch inputs" >< div class = "context-section recipe" >< h2 > { recipeHeading } </ h2 >
{ #if detail . recipe }
{ #if detail . blueprintName } < p > From { detail . blueprintName } </ p > { /if }
< p > { recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.' } </ p >
< p class = "small" > Captured < time datetime = { detail . recipe . capturedAt } > { fmt ( detail . recipe . capturedAt )} </time ></ p >
< LaunchRecipe recipe = { detail . recipe } historical selectionStatus = { recipeStatus } / >
< div class = "launch-another" >< Button variant = "ghost" onclick = {() => launchOpen = true } > Launch another server →</ Button >< p > Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</ p ></ div >
{ : else } < p > {[ 'create' , 'rebuild' ]. includes ( deployment . action ) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.' } </ p > { /if }
</ div ></ Card >
</ aside >
</ div >
{ #if detail . recipe } < DeployServerDialog bind:open = { launchOpen } sourceDeploymentId= { deployment . id } /> { /if }
{ /key }
{ : else } < div class = "loading" role = "status" > Loading provisioning operation…</ div > { /if }
</ div >
< style >
. recovery-links { display : flex ; gap : 16 px ; flex-wrap : wrap ; font-size : 14 px ; }
. page { display : grid ; gap : 28 px ; } section { display : grid ; gap : 16 px ; }
. summary { padding : 24 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface ) ; }
. saved-recipe { padding : 24 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface - sunken ); }. explanation { color : var ( -- text - secondary ); max-width : 800 px ; }. timing { display : flex ; gap : 24 px ; font-size : 12 px ; color : var ( -- text - secondary ); }. timing strong { color : var ( -- text - primary ); margin-left : 8 px ; font-family : var ( -- font - mono ); font-weight : 500 ; }
. top { display : flex ; align-items : center ; justify-content : space-between ; gap : 16 px ; flex-wrap : wrap ; overflow-wrap : anywhere ; }
h2 { font-size : 18 px ; margin : 0 ; } p { margin : 0 ; line-height : 1.6 ; } a { color : var ( -- accent - primary - hover ) ; }
dl { display : flex ; gap : 32 px ; flex-wrap : wrap ; margin : 0 ; } dt , . muted { color : var ( -- text - secondary ) ; font-size : 13 px ; } dd { margin : 6 px 0 0 ; font-size : 14 px ; }
. error { color : var ( -- state - danger ); } . warning { color : var ( -- state - warning ); }
@ media ( max-width : 640px ) {
. timing { flex-direction : column ; gap : var ( -- space -2 ) ; }
}
. page { display : grid ; gap : var ( -- space -6 ) ; min-width : 0 ; }
. page : global ( . subtitle ), . page : global ( . title ) { overflow-wrap : anywhere ; }
. page : global ( . btn ) { white-space : normal ; }
h2 , h3 , p { margin : 0 ; } a { color : var ( -- accent - primary - hover ); text-underline-offset : 3 px ; }
a : focus-visible , summary : focus-visible { outline : 2 px solid var ( -- border - focus ) ; outline-offset : 3 px ; border-radius : 2 px ; }
p { line-height : 1.65 ; overflow-wrap : anywhere ; }
. identity-row , . operation-id , . identity-links , . badges , . outcome-top , . diagnostics , . freshness , . recovery-actions { display : flex ; align-items : center ; gap : var ( -- space -3 ) ; flex-wrap : wrap ; }
. identity-row { justify-content : space-between ; margin-top : calc ( -1 * var ( -- space -3 )); gap : var ( -- space -2 ) var ( -- space -5 ); }. operation-id { gap : var ( -- space -2 ); min-width : 0 ; }. operation-id > span , . identity-links { font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); }. identity-links { gap : var ( -- space -4 ); }
. outcome { display : grid ; gap : var ( -- space -5 ); padding : var ( -- space -6 ); border : 1 px solid var ( -- border - strong ); border-radius : var ( -- radius - lg ); background : var ( -- surface ); }
. outcome . attention { border-color : color-mix ( in srgb , var ( -- state - warning ) 35 % , var ( -- border - strong )); }. outcome-top { justify-content : space-between ; }. record-label { color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); }. outcome h2 { font-size : var ( -- text - xl ); line-height : 1.35 ; letter-spacing : var ( -- tracking - tight ); overflow-wrap : anywhere ; }
. outcome-message { display : grid ; gap : var ( -- space -2 ); }. outcome-message p { color : var ( -- text - secondary ) ; font-size : var ( -- text - base ); max-width : 85 ch ; }
. failure , . action-error , . refresh-error { border : 1 px solid color-mix ( in srgb , var ( -- state - danger ) 35 % , var ( -- border - subtle )); border-radius : var ( -- radius - md ); padding : var ( -- space -4 ); color : var ( -- state - danger ); font-size : var ( -- text - sm ); }. failure p , . action-error p { margin-top : var ( -- space -2 ); color : var ( -- text - primary ); }. refresh-error { display : flex ; justify-content : space-between ; align-items : center ; gap : var ( -- space -4 ); flex-wrap : wrap ; }. refresh-error p { color : var ( -- text - secondary ); margin-top : var ( -- space -1 ); }
. failure-excerpt { display : grid ; gap : var ( -- space -1 ); font-size : var ( -- text - sm ); color : var ( -- text - secondary ); }. failure-excerpt span { font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); }
. warnings { border-left : 3 px solid var ( -- state - warning ); padding-left : var ( -- space -4 ); font-size : var ( -- text - sm ); }. warnings h3 { font-size : var ( -- text - sm ); color : var ( -- state - warning ); }. warnings ul { margin : var ( -- space -2 ) 0 0 ; padding-left : var ( -- space -4 ); line-height : 1.6 ; }. warnings li { overflow-wrap : anywhere ; }
. notice { color : var ( -- state - online ); font-size : var ( -- text - sm ); }. action-help { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); margin-top : var ( -- space -2 ); }. dependency , . diagnostics { font-size : var ( -- text - sm ); }. diagnostics { gap : var ( -- space -3 ) var ( -- space -5 ); }
. recovery-note { display : grid ; gap : var ( -- space -2 ); padding : var ( -- space -4 ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface - sunken ); font-size : var ( -- text - sm ); }. recovery-note p , . recovery-note span { color : var ( -- text - secondary ); }. recovery-note span { font-size : var ( -- text - xs ); }
. timing { border-top : 1 px solid var ( -- border - subtle ); padding-top : var ( -- space -5 ); display : flex ; flex-wrap : wrap ; gap : var ( -- space -4 ) var ( -- space -8 ); }. timing > div { display : grid ; gap : var ( -- space -2 ); }. timing span { font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); }. timing strong { font-size : var ( -- text - md ); font-variant-numeric : tabular-nums ; font-weight : var ( -- weight - medium ); }. timing time { font-size : var ( -- text - sm ); color : var ( -- text - secondary ); }
. freshness { justify-content : space-between ; color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); line-height : 1.65 ; margin-top : calc ( -1 * var ( -- space -2 )); }
. detail-grid { display : grid ; grid-template-columns : minmax ( 0 , 1 fr ) minmax ( 280 px , 340 px ); gap : var ( -- space -8 ); align-items : start ; }. execution-column { display : grid ; gap : var ( -- space -8 ); min-width : 0 ; }. context-column { display : grid ; gap : var ( -- space -5 ); min-width : 0 ; }. context-section { display : grid ; gap : var ( -- space -4 ); min-width : 0 ; }. context-section h2 { font-size : var ( -- text - md ); }. context-section p { font-size : var ( -- text - sm ); color : var ( -- text - secondary ); }. context-section p . small { font-size : var ( -- text - xs ); }
. metadata { display : grid ; gap : var ( -- space -4 ); margin : 0 ; }. metadata dt { color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); }. metadata dd { margin : var ( -- space -1 ) 0 0 ; font-size : var ( -- text - sm ); line-height : 1.6 ; overflow-wrap : anywhere ; }
. related , . capacity-pool ul { list-style : none ; padding : 0 ; margin : 0 ; display : grid ; gap : var ( -- space -4 ); }. related li { display : grid ; gap : var ( -- space -1 ); font-size : var ( -- text - sm ); overflow-wrap : anywhere ; }. related span , . related code { color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); }. related strong { font-weight : var ( -- weight - medium ); } code { font-family : var ( -- font - mono ); }
. capacity-pool { display : grid ; gap : var ( -- space -3 ); font-size : var ( -- text - xs ); }. capacity-pool + . capacity-pool { border-top : 1 px solid var ( -- border - subtle ); padding-top : var ( -- space -4 ); }. capacity-heading { display : flex ; justify-content : space-between ; gap : var ( -- space -2 ); flex-wrap : wrap ; }. capacity-heading strong { font-weight : var ( -- weight - medium ); }. capacity-heading span { color : var ( -- text - secondary ); }. capacity-pool summary { cursor : pointer ; color : var ( -- text - secondary ); }. capacity-pool ul { gap : var ( -- space -2 ); margin-top : var ( -- space -3 ); line-height : 1.6 ; }
. launch-another { display : grid ; gap : var ( -- space -2 ); justify-items : start ; border-top : 1 px solid var ( -- border - subtle ); padding-top : var ( -- space -4 ); }. launch-another p { font-size : var ( -- text - xs ); }
. loading { min-height : 240 px ; padding : var ( -- space -6 ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - lg ); color : var ( -- text - secondary ); }
@ media ( max-width : 1000px ) { . detail-grid { grid-template-columns : minmax ( 0 , 1 fr ); }. context-column { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); } }
@ media ( max-width : 600px ) { . page { gap : var ( -- space -5 ); }. outcome { padding : var ( -- space -4 ); gap : var ( -- space -4 ); }. outcome h2 { font-size : var ( -- text - lg ); }. outcome-message p { font-size : var ( -- text - sm ); }. context-column { grid-template-columns : minmax ( 0 , 1 fr ); }. identity-row , . identity-links { align-items : flex-start ; }. operation-id { display : block ; }. operation-id > span { display : block ; margin-bottom : var ( -- space -1 ); }. outcome-top { align-items : flex-start ; }. record-label { display : none ; }. timing { gap : var ( -- space -4 ); }. timing > div : last-child { flex-basis : 100 % ; }. freshness { align-items : flex-start ; }. freshness > span { flex : 1 1 170 px ; }. detail-grid { gap : var ( -- space -6 ); } }
</ style >