@@ -6,7 +6,6 @@
import { goto } from '$app/navigation' ;
import { pollVisible } from '$ui/poll.js' ;
import Tabs from '$ui/components/primitives/Tabs.svelte' ;
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte' ;
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import Badge from '$ui/components/primitives/Badge.svelte' ;
@@ -16,8 +15,14 @@
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte' ;
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte' ;
import { provisioningPageSize , provisioningPageOffset , type ProvisioningListRow as Deployment , type ProvisioningListPage } from '$lib/provisioning-list.js' ;
import { provisioningActions , actionLabels , actionCompleted } from '$lib/provisioning.js' ;
import { provisioningListStatuses , provisioningListSources , provisioningPageSize , provisioningPageOffset , type ProvisioningListRow as Deployment , type ProvisioningListPage } from '$lib/provisioning-list.js' ;
import { provisioningActions , actionLabels } from '$lib/provisioning.js' ;
import { contextHref } from '$lib/navigation-context.js' ;
import { confirmAction } from '$ui/confirm.js' ;
import { serverTitle } from '$ui/server-labels.js' ;
import { elapsedTime } from '$ui/provisioning-presentation.js' ;
import { provisioningQueueStatus , provisioningWarnings } from '$ui/provisioning-queue.js' ;
let lifecycleOpen = $state ( false );
let active = $state < Deployment [] >([]);
@@ -36,13 +41,19 @@
let limit = $state ( 25 );
let offset = $state ( 0 );
let historyState = $state ( 'all' );
let source = $state ( 'all' );
let expanded = $state < Record < string , boolean >>({});
let fleetActionsOpen = $state ( false );
let now = $state ( Date . now ());
let historyUpdatedAt = $state < string | null >( null );
let cancelling = $state ( false );
let cancellationNotice = $state ( '' );
$effect (() => {
const params = $page . url . searchParams ;
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' ;
historyState = provisioningListStatuses . some ( value => value === params . get ( 'status' )) ? params . get ( 'status' ) ! : 'all' ;
source = provisioningListSources . some ( value => value === params . get ( 'source' )) ? params . get ( 'source' ) ! : 'all' ;
search = params . get ( 'server' ) ?? '' ;
limit = provisioningPageSize ( Number ( params . get ( 'limit' ) ?? 25 ));
offset = provisioningPageOffset ( Number ( params . get ( 'offset' ) ?? 0 ));
@@ -50,21 +61,27 @@
function saveContext ( reset = false ) {
if ( reset ) offset = 0 ;
const url = new URL ( $page . url );
for ( const [ key , value ] of Object . entries ({ view : tab === 'active' ? '' : tab , action : action === 'all' ? '' : action , status : historyState === 'all' ? '' : historyState , server : search , limit : limit !== 25 ? String ( limit ) : '' , offset : offset ? String ( offset ) : '' })) {
for ( const [ key , value ] of Object . entries ({ view : tab === 'active' ? '' : tab , action : action === 'all' ? '' : action , status : historyState === 'all' ? '' : historyState , source : source === 'all' ? '' : source , server : search , limit : limit !== 25 ? String ( limit ) : '' , offset : offset ? String ( offset ) : '' })) {
if ( value ) url . searchParams . set ( key , value ); else url . searchParams . delete ( key );
}
void goto ( url , { replaceState : true , noScroll : true , keepFocus : true });
}
async function cancelRequests ( ids : string []) {
async function cancelRequests ( rows : Deployment [], bulk = false ) {
if ( cancelling || ! rows . length ) return ;
const ids = rows . map ( row => row . id );
if ( ! await confirmAction ({ title : bulk ? `Cancel all ${ ids . length } active operations?` : 'Cancel operation?' ,
message : ` ${ bulk ? `This affects all ${ ids . length } cancellable active operations across the fleet, regardless of the current filters.` : `Cancel ${ actionLabels [ rows [ 0 ]. action ]. toLowerCase () } on ${ serverTitle ( rows [ 0 ]. server ) } .` } Cancelling stops remaining work and health checks. Changes already made are not undone; commands already delivered to a host may still finish.` ,
confirmLabel : bulk ? 'Cancel all operations' : 'Cancel operation' , danger : true })) return ;
fleetActionsOpen = false ;
cancelling = true ; cancellationNotice = '' ; let cancelled = 0 ; const unavailable : string [] = [];
for ( const id of ids ) {
try { const response = await fetch ( `/api/provisioning/ ${ encodeURIComponent ( id ) } /cancel` , { method : 'POST' }); if ( response . ok ) cancelled ++ ; else unavailable . push ( id ); }
catch { unavailable . push ( id ); }
}
cancellationNotice = ` ${ cancelled } operation ${ cancelled === 1 ? '' : 's' } cancelled. ${ unavailable . length ? ` ${ unavailable . length } could not be cancelled; refresh to see their current state.` : '' } ` ;
cancelling = false ; await load ();
cancelling = false ; await load (); historyRefresh ++ ;
}
const tabs = $derived . by (() => [{ id : 'active' , label : 'Active' , badge :active.length },{ id : 'history' , label : 'History' }]) ;
const tabs = [{ id : 'active' , label : 'Active' },{ id : 'history' , label : 'History' }];
let action = $state ( 'all' );
let search = $state ( '' );
let refreshing = false ;
@@ -75,7 +92,7 @@
const res = await fetch ( '/api/provisioning?view=active' , { signal , cache : 'no-store' });
if ( ! res . ok ) throw new Error ( 'Could not load provisioning activity.' );
const result : ProvisioningListPage = await res . json ();
active = result . deployments ;
active = result . deployments ; now = Date . now ();
needsReview = result . needsReview ;
error = '' ;
} catch ( err ) { if ( ! signal ? . aborted ) error = ( err as Error ). message ; return false ; }
@@ -84,7 +101,7 @@
// History is fetched on navigation/filter changes or explicit refresh, never by the active poll.
$effect (() => {
if ( tab !== 'history' ) return ;
const params = new URLSearchParams ({ view : 'history' , action , status : historyState ,
const params = new URLSearchParams ({ view : 'history' , action , status : historyState , source ,
server : search , limit : String ( limit ), offset : String ( offset ) });
void historyRefresh ;
const controller = new AbortController ();
@@ -97,101 +114,143 @@
const response = await fetch ( `/api/provisioning? ${ params } ` , { signal : controller.signal , cache : 'no-store' });
if ( ! response . ok ) throw new Error ( 'Could not load provisioning history.' );
const result : ProvisioningListPage = await response . json ();
if ( ! controller . signal . aborted ) { history = result . deployments ; historyTotal = result . total ; }
if ( ! controller . signal . aborted ) { history = result . deployments ; historyTotal = result . total ; historyUpdatedAt = new Date (). toISOString (); now = Date . now (); }
} catch ( err ) { if ( ! controller . signal . aborted ) historyError = ( err as Error ). message ; }
finally { if ( ! controller . signal . aborted ) historyLoading = false ; }
})();
return () => controller . abort ();
});
onMount (() => pollVisible ( load , 5000 ) );
onMount (() => { const stop = pollVisible ( load , 5000 ); const clock = setInterval (() => now = Date . now (), 30000 ); return () => { stop (); clearInterval ( clock ); }; } );
function fmt ( value : string ) { return $timeFormat . dateTime ( value ); }
function warnings ( d : Deployment ) : string [] { try { return JSON . parse ( d . warnings ); } catch { return []; } }
function age ( value : string ) {
const minutes = Math . max ( 0 , Math . floor (( now - Date . parse ( value )) / 60000 ));
return minutes < 1 ? 'Just now' : minutes < 60 ? ` ${ minutes } min ago` : minutes < 1440 ? ` ${ Math . floor ( minutes / 60 ) } hr ago` : ` ${ Math . floor ( minutes / 1440 ) } d ago` ;
}
const filtered = $derived ( action !== 'all' || source !== 'all' || historyState !== 'all' || Boolean ( search ));
const visibleActive = $derived ( active . filter ( row => {
if ( action !== 'all' && row . action !== action ) return false ;
if ( historyState !== 'all' && ( historyState === 'pending' ? ! [ 'queued' , 'waiting' ]. includes ( row . state ) : row . state !== historyState )) return false ;
if ( search && ! ` ${ row . server } ${ serverTitle ( row . server ) } ` . toLowerCase (). includes ( search . toLowerCase ())) return false ;
if ( source !== 'all' ) {
const sources = row . sources ?? [];
if ( source === 'manual' && ( sources . length || row . triggered_by !== 'ui' )) return false ;
if ( source === 'system' && ( sources . length || row . triggered_by === 'ui' )) return false ;
if ([ 'campaign' , 'beta' , 'maintenance' ]. includes ( source ) && ! sources . some ( item => source === 'maintenance' ? [ 'maintenance' , 'watchdog' ]. includes ( item . kind ) : item . kind === source )) return false ;
}
return true ;
}));
const queueSummary = $derived . by (() => {
const groups = { queued : 0 , waiting : 0 , verifying : 0 , running : 0 };
for ( const row of visibleActive ) {
const group = provisioningQueueStatus ( row ). group ;
if ( group in groups ) groups [ group as keyof typeof groups ] ++ ;
}
return Object . entries ( groups ). filter (([, count ]) => count ). map (([ label , count ]) => ` ${ count } ${ label === 'verifying' ? 'verifying health' : label === 'running' ? 'in progress' : label } ` ). join ( ' · ' );
});
function clearFilters() { action = 'all' ; source = 'all' ; historyState = 'all' ; search = '' ; saveContext ( true ); }
function reviewAttention() { tab = 'history' ; action = 'all' ; source = 'all' ; search = '' ; historyState = 'attention' ; saveContext ( true ); }
</ script >
< svelte:head >< title > Provisioning | Electric Cherry</ title ></ svelte:head >
< div class = "page" >
< SectionHeader title = "Provisioning" subtitle = "Create, start, restart, rebuild, stop, and destroy server instances." >
{ # snippet actions ()} < Button onclick = {() => lifecycleOpen = true } > Manage server </ Button >< Button variant = "primary" onclick = {() => open = true } > Create server </ Button >< details class = "fleet-actions" >< summary > Fleet actions </ summary >< Button variant = "danger" onclick = {() => housePartyOpen = true } > Destroy fleet …</ Button ></ details > { /snippet }
< SectionHeader title = "Provisioning" >
{ # snippet actions ()}
< Button onclick = {() => lifecycleOpen = true } > Server actions </ Button >< Button variant = "primary" onclick = {() => open = true } > Create server </ Button >
< details class = "fleet-actions" bind:open = { fleetActionsOpen } > <summary > Fleet actions</ summary >< div class = "fleet-menu" >
{ #if active . some ( row => row . canCancel )} < Button disabled = { cancelling } onclick= {() => cancelRequests ( active . filter ( row => row . canCancel && ! row . server_deleted_at ), true )} > Cancel all active operations</ Button > { /if }
< Button variant = "danger" onclick = {() => { fleetActionsOpen = false ; housePartyOpen = true ; }} > Destroy fleet …</ Button >
</ div ></ details >
{ /snippet }
</ SectionHeader >
{ #if $page . url . searchParams . has ( 'returnTo' )} < BackLink fallback = "/" /> { /if }
< Tabs { tabs } bind:value = { tab } onchange= {() => saveContext ( true )} ariaLabel = "Provisioning views" idPrefix = "deployment-tabs" panelId = "deployment-panel" / >
< div class = "toolbar" >
< div class = "view-tabs" >< Tabs { tabs } bind:value = { tab } onchange= {() => saveContext ( true )} ariaLabel = "Provisioning views" idPrefix = "deployment-tabs" panelId = "deployment-panel" /></ div >
< label class = "search" > Server< input type = "search" bind:value = { search } oninput= {() => saveContext ( true )} placeholder = "Search server or lobby" maxlength = "100" /></ label >
< label > Operation< select bind:value = { action } onchange= {() => saveContext ( true )} >< option value = "all" > All operations</ option > { #each provisioningActions as value } < option value = { value } > { actionLabels [ value ]} </option > { /each } </ select ></ label >
< label > Status< select bind:value = { historyState } onchange= {() => saveContext ( true )} >< option value = "all" > All statuses</ option >< option value = "pending" > Waiting / queued</ option >< option value = "running" > In progress</ option >< option value = "awaiting-health" > Verifying health</ option >< option value = "done" > Complete</ option >< option value = "attention" > Needs attention</ option >< option value = "failed" > Failed</ option >< option value = "interrupted" > Interrupted</ option >< option value = "warning" > Warnings</ option >< option value = "cancelled" > Cancelled</ option ></ select ></ label >
< label > Source< select bind:value = { source } onchange= {() => saveContext ( true )} >< option value = "all" > All sources</ option >< option value = "manual" > Manual</ option >< option value = "campaign" > Campaign</ option >< option value = "beta" > Beta</ option >< option value = "maintenance" > Maintenance</ option >< option value = "system" > System</ option ></ select ></ label >
{ #if filtered } < Button variant = "ghost" onclick = { clearFilters } > Clear filters </ Button > { /if }
</ div >
< LifecycleDialog bind:open = { lifecycleOpen } / >
< 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'; saveContext(true ); void load(); }} />
< HousePartyDialog bind:open = { housePartyOpen } onqueued= { count => { housePartyNotice = `Destruction queued for $ { count } server$ { count === 1 ? '' : 's' }. `; tab = 'active'; clearFilters( ); 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 error } < p role = "alert" class = "error" > { error } < button onclick = {() => void load ()} > Retry</button ></ p > { /if }
{ #if tab === 'active' }
{ #if needsReview } < div class = "ui -summary" >< span > { needsReview } recorded operations need review </ span >< Button size = "sm" onclick = {() => { historyState = 'attention' ; tab = 'history' ; saveContext ( true ); }} > Review history </ Button ></ div > { /if }
{ #if active . some ( d => d . canCancel )} < div class = "ui-summary" >< span > Cancelling stops remaining work and health checks. Changes already made are not undone.</ span >< Button disabled = { cancelling } onclick= {() => cancelRequests ( active . filter ( d => d . canCancel ). map ( d => d . id ))} > Cancel all active operations</ Button ></ div > { /if }
< section >
< h2 > In flight < span > { active . length } </ span ></ h2 >
{ #if loading } < p > Loading provisioning activity...</ p >
{ :else if active . length === 0 } < EmptyState title = "No server operations in flight" description = "Server lifecycle operations appear here automatically." />
{ : else } < div class = "rows" > { #each active as d ( d . id )}{ @render row ( d )}{ /each } </ div > { /if }
</ section >
{ : else }
< section >
< div class = "row-head" >< h2 > History</ h2 >< Button size = "sm" disabled = { historyLoading } onclick= {() => historyRefresh ++ } > Refresh history</ Button ></ div >
< div class = "filters" >
< label > Operation < select bind:value = { action } onchange= { event => { action = event . currentTarget . value ; saveContext ( true ); }} >< option value = "all" > All operations</ option > { #each provisioningActions as value } < option value = { value } > { actionLabels [ value ]} </option > { /each } </ select ></ label >
< label > Status < select bind:value = { historyState } onchange= { event => { historyState = event . currentTarget . value ; saveContext ( true ); }} >< option value = "all" > All statuses</ option >< option value = "attention" > Needs review</ option ></ select ></ label >
< label > Server < input bind:value = { search } oninput= { event => { search = event . currentTarget . value ; saveContext ( true ); }} placeholder = "Filter by server name" /></ label >
</ div >
{ #if historyError } < p role = "alert" class = "error" > { historyError } < button onclick = {() => historyRefresh ++ } > Retry</button ></ p > { /if }
{ #if historyLoading } < p > Loading provisioning history...</ p >
{ :else if ! historyError && history . length === 0 } < EmptyState title = "No matching operations" description = "Try another search or operation type." />
{ : else } < div class = "rows" > { #each history as d ( d . id )}{ @render row ( d )}{ /each } </ div > { /if }
< nav class = "row-head" aria-label = "Provisioning history pages" >
< span role = "status" > { history . length ? `$ { offset + 1 } – $ { offset + history . length } ` : '0'} of {historyTotal} operations</span>
<div class="filters">
<Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button>
<Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button>
{ #if error } < p role = "alert" class = "error" > { error } < button onclick = {() => void load ()} > Retry</button ></ p > { /if }
< div class = "queue-heading" >
{ #if tab === 'active' } < p class = "queue -summary" >< strong > { filtered ? `$ { visibleActive . length } of $ { active . length } ` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools">
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
{#if tab === 'active'}<span class="muted">{error ? 'Updates interrupted' : 'Updates every 5s'}</span>
{ :else}<span class="muted">{historyUpdatedAt ? ` Updated $ { age ( historyUpdatedAt ). toLowerCase ()} ` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if}
</div>
</na v>
</section>
{/if}
</di v>
{#if tab === 'active'}
{#if loading}<p class="muted">Loading provisioning activity…</p>
{:else if !visibleActive.length}<EmptyState title={filtered ? 'No matching active operations' : 'No active operations'} description={filtered ? 'Try another search or clear the filters.' : undefined} />
{:else}{@render operationTable(visibleActive)}{/if}
{:else}
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
{#if historyLoading}<p class="muted">Loading provisioning history…</p>
{:else if !historyError && !history.length}<EmptyState title="No matching operations" description="Try another search or clear the filters." />
{:else}{@render operationTable(history)}{/if}
<nav class="pagination" aria-label="Provisioning history pages">
<span role="status">{history.length ? ` $ { offset + 1 } – $ { offset + history . length } ` : '0'} of {historyTotal} operations</span>
{#if historyTotal > limit || offset > 0}<div class="queue-tools"><Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button><Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button></div>{/if}
</nav>
{/if}
</div>
</div>
{#snippet row(d : Deployment)}
<article class="deployment-row"><a class="row-link" href={` /provisioning/ $ { encodeURIComponent ( d . id )} ? returnTo = $ { encodeURIComponent ( $page . url . pathname + $page . url . search )} ` } >
< div class = "row-head" >
< div >< Badge variant = { d . action === 'create' ? 'info' : 'neutral' } > { actionLabels [ d . action ]} </Badge >
< strong class = "server" >< HistoricalServerIdentity name = { d . server } deletedAt= { d . server_deleted_at } /></ strong >
</ div >
< StatusBadge state = { d . state } / >
</ div >
< p > { d . state === 'done' ? actionCompleted [ d . action ] : d . stage } </ p >
< div class = "meta" >< span > Started { fmt ( d . started_at )} </ span > { #if d . finished_at } < span > Finished { fmt ( d . finished_at )} </ span > { /if } < span > By { d . triggered_by } </ span ></ div >
{ #if d . error } < p class = "error" > { d . error } </ p > { /if }
{ #each warnings ( d ) as warning } < p class = "error" > { warning } </ p > { /each }
</ a > { #if d . canCancel && ! d . server_deleted_at } < div class = "row-actions" >< Button size = "sm" disabled = { cancelling } onclick= {() => cancelRequests ([ d . id ])} > Cancel operation</ Button ></ div > { /if } </ article >
{#snippet operationTable(rows : Deployment[] )}
<div class="table-scroll"><table >
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)}
{@const primarySource = row.sources?.[0]}
{@const operationHref = contextHref(` /provisioning/ $ { encodeURIComponent ( row . id )} `, $page.url)}
{@const isExpanded = expanded[row.id] ?? false}
<tr>
<td class="server-cell"><HistoricalServerIdentity name={row.server} deletedAt={row.server_deleted_at} href={row.server_exists ? contextHref(` /servers/ $ { encodeURIComponent ( row . server )} `, $page.url) : undefined} /></td>
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td><button type="button" class="timestamp" aria-label={` Started $ { fmt ( row . started_at )} `}><time datetime={row.started_at}>{age(row.started_at)}</time><span class="exact-time" aria-hidden="true">{fmt(row.started_at)}</span></button></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={` operation - $ { row . id } `} aria-label={` $ { isExpanded ? 'Hide' : 'Show' } $ { actionLabels [ row . action ]. toLowerCase ()} details for $ { serverTitle ( row . server )} `} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '− ' : '+'}</span></button></td>
</tr>
<tr hidden={!isExpanded} id={` operation - $ { row . id } `} class="detail-row"><td colspan="6">
<div class="operation-details">
<p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if}
{#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
{#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if}
<div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(` /servers/ $ { encodeURIComponent ( row . server )} ? tab = diagnostics `, $page.url) } >Diagnostics</ a >< a href = { contextHref ( `/servers/$ { encodeURIComponent ( row . server )} ? tab = logs `, $page.url) } > Open logs </ a > { /if }{ #if row . canCancel && ! row . server_deleted_at } < Button variant = "danger" size = "sm" disabled = { cancelling } onclick= {() => cancelRequests ([ row ])} > Cancel operation</ Button > { /if } </ div >
</ div >
</ td ></ tr >
{ /each } </ tbody >
</ table ></ div >
{ /snippet }
< style >
. row-link { color : inherit ; text-decoration : none ; display : block ; }. row-actions { margin-top : 12 px ; }
. page { display : flex ; flex-direction : column ; gap : 28 px ; }
. row-head , . filters , . meta { display : flex ; align-items : center ; gap : 16 px ; flex-wrap : wrap ; }
. row-head { justify-content : space-between ; }
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 ); }
. rows { display : grid ; gap : 12 px ; }
. deployment-row { display : block ; text-decoration : none ; color : inherit ; padding : 20 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface ) ; overflow-wrap : anywhere ; }
. deployment-row : hover { border-color : var ( -- border - strong ); background : var ( -- surface - raised ) ; }
. deployment-row : focus-visible { outline : 2 px solid var ( -- border - focus ); outline-offset : 3 px ; }
. server { margin-left : 12 px ; font-family : var ( -- font - mono ); font-size : 14 px ; }
. meta { font-size : 13 px ; color : var ( -- text - secondary ) ; }
. error { color : var ( -- state - danger ); }
label { display : flex ; align-items : center ; gap : 8 px ; font-size : 13 px ; }
input , select { background : var ( -- surface ); color : inherit ; border : 1 px solid var ( -- border - subtle ); border-radius : 6 px ; padding : 8 px ; max-width : 100 % ; }
@ media ( max-width : 640px ) { . row-head { align-items : flex-start ; } . server { display : block ; margin : 10 px 0 ; } }
. panels { display : grid ; gap : 24 px ; }
. fleet-actions { position : relative ; font-size : 13 px ; }
. fleet-actions summary { padding : 10 px ; cursor : pointer ; }
. fleet-actions [ open ] : global ( . btn ) { position : absolute ; right : 0 ; top : 100 % ; z-index : 10 ; white-space : nowrap ; box-shadow : var ( -- shadow - raised ); }
. page , . panels { display : grid ; gap : 24 px ; min-width : 0 ; }
. toolbar { display : flex ; align-items : flex-end ; flex-wrap : wrap ; gap : 12 px ; }. view-tabs { max-width : 100 % ; }
label { display : grid ; gap : 6 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); }. search { flex : 1 ; min-width : 220 px ; }
input , select { padding : 10 px 12 px ; min-height : 40 px ; background : var ( -- surface - sunken ) ; color : var ( -- text - primary ); border : 1 px solid var ( -- border - control ); border-radius : var ( -- radius - sm ); font : inherit ; font-size : var ( -- text - sm ) ; }
. queue-heading , . queue-tools , . pagination { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 12 px ; }. queue-heading , . pagination { justify-content : space-between ; }
h2 { font-size : var ( -- text - md ); margin : 0 ; } h2 span { color : var ( -- text - tertiary ); font-size : var ( -- text - sm ); margin-left : 8 px ; font-weight : 400 ; }
p { margin : 0 ; }. queue-summary { font-size : var ( -- text - sm ); }. queue-summary span , . muted , . pagination { color : var ( -- text - secondary ); font-size : var ( -- text - sm ) ; }
. table-scroll { overflow-x : auto ; } table { width : 100 % ; border-collapse : collapse ; text-align : left ; font-size : var ( -- text - sm ) ; }
th { padding : 10 px 12 px ; color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); font-weight : var ( -- weight - medium ); border-bottom : 1 px solid var ( -- border - strong ); }
td { padding : 16 px 12 px ; border-bottom : 1 px solid var ( -- border - subtle ); vertical-align : middle ; } tbody tr : hover { background : var ( -- surface ) ; }
a { color : var ( -- accent - primary - hover ); }. server-cell { min-width : 160 px ; font-weight : var ( -- weight - semibold ); }. source-cell { max-width : 240 px ; overflow-wrap : anywhere ; }
small { display : block ; margin-top : 6 px ; color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); }. align-right { text-align : right ; }
. text-button , . timestamp { border : 0 ; background : transparent ; color : var ( -- text - secondary ); font : inherit ; padding : 6 px 0 ; cursor : pointer ; white-space : nowrap ; }
. timestamp { position : relative ; cursor : help ; }. exact-time { display : none ; position : absolute ; right : 0 ; bottom : 100 % ; padding : 6 px 10 px ; z-index : 1 ; background : var ( -- surface - overlay ); border : 1 px solid var ( -- border - strong ) ; border-radius : var ( -- radius - sm ); color : var ( -- text - primary ); font-size : var ( -- text - xs ); }. timestamp : hover . exact-time , . timestamp : focus . exact-time { display : block ; }
. detail-row { background : var ( -- surface ); }. operation-details { display : grid ; gap : 16 px ; line-height : 1.6 ; overflow-wrap : anywhere ; }
dl { display : flex ; flex-wrap : wrap ; gap : 12 px 32 px ; margin : 0 ; } dt { color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); } dd { margin : 0 ; }. mono { font-family : var ( -- font - mono ); }
. detail-links { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 16 px ; }. error { color : var ( -- state - danger ); }. warning { color : var ( -- state - warning ) ; }
. fleet-actions { position : relative ; font-size : var ( -- text - sm ); }. fleet-actions summary { padding : 10 px ; cursor : pointer ; }. fleet-menu { position : absolute ; right : 0 ; top : 100 % ; z-index : 10 ; display : grid ; gap : 8 px ; padding : 12 px ; background : var ( -- surface - overlay ); border : 1 px solid var ( -- border - strong ); border-radius : var ( -- radius - md ) ; min-width : 250 px ; box-shadow : var ( -- shadow - raised ) ; }
@ media ( max-width : 720px ) { table { min-width : 820 px ; }. search { flex-basis : 100 % ; } th , td { padding : 12 px 8 px ; }. fleet-menu { right : auto ; left : 0 ; } }
</ style >