@@ -0,0 +1,137 @@
< script lang = "ts" >
import { onMount , untrack } from 'svelte' ;
import { beforeNavigate , afterNavigate , goto } from '$app/navigation' ;
import { page } from '$app/state' ;
import type { CapacityData , CapacityGame , CapacityQuery , CapacityRange } from '$lib/capacity.js' ;
import { hostReadinessLabel } from '$lib/hosts.js' ;
import { pollVisible } from '$ui/poll.js' ;
import { timeFormat } from '$ui/stores/time-zone.js' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import Button from '$ui/components/primitives/Button.svelte' ;
import Field from '$ui/components/primitives/Field.svelte' ;
import Select from '$ui/components/primitives/Select.svelte' ;
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte' ;
let { data } : { data : CapacityData } = $props ();
let snapshot = $state < CapacityData | null >( null );
let refreshing = $state ( false );
let navigating = $state ( false );
let refreshError = $state ( '' );
let navigationError = $state ( '' );
let request : AbortController | null = null ;
let sequence = 0 ;
const report = $derived ( snapshot ?? data );
const summary = $derived ( report . current . summary );
const scopeName = $derived ( report . query . hostId ? report . current . hostOptions . find ( host => host . id === report . query . hostId ) ? . name ?? 'Selected host' : 'All hosts' );
const hostOptions = $derived ([{ value : '' , label : 'All hosts' }, ... report . current . hostOptions . map ( host => ({ value : host.id , label : host.name })),
...( report . query . hostId && ! report . current . hostOptions . some ( host => host . id === report . query . hostId ) ? [{ value : report.query.hostId , label : 'Unavailable host' }] : [])]);
const gameOptions = [{ value : 'all' , label : 'All games' }, { value : 't5' , label : 'Black Ops I (T5)' }, { value : 't6' , label : 'Black Ops II (T6)' }];
const ranges : { value : CapacityRange ; label : string }[] = [{ value : '1h' , label : 'Last hour' }, { value : '24h' , label : '24 hours' }, { value : '7d' , label : '7 days' }];
const numbers = new Intl . NumberFormat ( 'en-US' , { maximumFractionDigits : 1 });
function count ( value : number | null ) { return value === null ? '—' : numbers . format ( value ); }
function memory ( value : number | null ) { return value === null ? '—' : numbers . format ( value / 1024 ** 3 ); }
function queryParams ( query : CapacityQuery ) {
const params = new URLSearchParams ({ range : query.range , game : query.game });
if ( query . hostId ) params . set ( 'host' , query . hostId );
return params ;
}
function scopeUrl ( change : Partial < CapacityQuery >) { return `/capacity? ${ queryParams ({ ... report . query , ... change } )}` ; }
function cancel() { sequence ++ ; request ? . abort (); request = null ; refreshing = false ; }
async function refresh ( signal? : AbortSignal ) {
if ( navigating || refreshing ) return ;
const current = ++ sequence ;
request = new AbortController ();
refreshing = true ;
try {
const response = await fetch ( `/api/capacity? ${ queryParams ( data . query ) } ` , {
signal : AbortSignal.any ([ request . signal , AbortSignal . timeout ( 25 _000 ), ...( signal ? [ signal ] : [])])
});
const body = await response . json ();
if ( ! response . ok ) throw new Error ( body . error ?? 'Could not refresh capacity.' );
if ( current === sequence ) { snapshot = body ; refreshError = '' ; }
} catch ( cause ) {
if ( current === sequence && ! signal ? . aborted && ! request ? . signal . aborted ) refreshError = ( cause as Error ). message ;
return false ;
} finally { if ( current === sequence ) { refreshing = false ; request = null ; } }
}
async function changeScope ( change : Partial < CapacityQuery >) {
navigationError = '' ;
try { await goto ( scopeUrl ( change ), { keepFocus : true , noScroll : true }); }
catch ( cause ) { navigating = false ; navigationError = ( cause as Error ). message || 'Could not load that capacity view.' ; }
}
beforeNavigate (() => { cancel (); navigating = true ; });
afterNavigate (() => { navigating = false ; });
$effect (() => { void data ; untrack (() => { cancel (); snapshot = null ; refreshError = '' ; }); });
onMount (() => { const stop = pollVisible ( refresh , 30 _000 , false ); return () => { stop (); cancel (); }; });
</ script >
< svelte:head >< title > Capacity · Electric Cherry</ title >< meta name = "description" content = "Available game-server capacity, resource history, and host limits." /></ svelte:head >
< div class = "page" aria-busy = { navigating } >
< SectionHeader title = "Capacity" subtitle = "See what your hosts can run, what is limiting them, and how headroom changes." >
{ # snippet actions ()} < Button href = "/hosts" > Manage hosts</ Button >< Button disabled = { refreshing || navigating } onclick= {() => void refresh ()} > { refreshing ? 'Refreshing…' : 'Refresh' } </ Button > { /snippet }
</ SectionHeader >
{ #if refreshError || navigationError } < div class = "refresh-error" role = "alert" >< strong > { navigationError || refreshError } </ strong >< span > Showing the last successfully loaded capacity report.</ span ></ div > { /if }
< div class = "toolbar" >
< div class = "filters" >< Field label = "Host" >< Select value = { report . query . hostId ?? '' } options= { hostOptions } disabled = { navigating } onchange= { event => void changeScope ({ hostId : ( event . currentTarget as HTMLSelectElement ). value || null })} /></ Field >
< Field label = "Launch game" >< Select value = { report . query . game } options= { gameOptions } disabled = { navigating } onchange= { event => void changeScope ({ game : ( event . currentTarget as HTMLSelectElement ). value as CapacityGame })} /></ Field ></ div >
< nav class = "ranges" aria-label = "History range" > { #each ranges as range } < a href = { scopeUrl ({ range : range.value })} aria-current= { report . query . range === range . value ? 'page' : undefined } data-sveltekit-keepfocus data-sveltekit-noscroll > { range . label } </ a > { /each } </ nav >
</ div >
< section class = "overview" aria-labelledby = "capacity-now-title" >
< div class = "estimate" >< div class = "eyebrow" id = "capacity-now-title" > Available now · { scopeName } </ div >< div class = "estimate-number" > { count ( summary . estimatedServers )} < span > additional servers</ span ></ div >
< p > { summary . estimatedServers === null ? 'Waiting for enough host information to estimate capacity.' : `Estimated to fit across $ { summary . eligibleHosts } eligible $ { summary . eligibleHosts === 1 ? 'host' : 'hosts' }. ` } { report . query . game === 'all' ? 'Hosts ready for either T5 or T6 are counted once.' : `For $ { report . query . game === 't5' ? 'Black Ops I' : 'Black Ops II' } launches . ` } </ p ></ div >
< div class = "queue-context" >< a href = "/provisioning" >< strong > { count ( summary . queued )} </ strong >< span > servers queued < span aria-hidden = "true" > →</ span ></ span ></ a >< p > Across the fleet. Launches wait for capacity and startup slots automatically.</ p ></ div >
</ section >
< div class = "resources" aria-label = "Current resource headroom" >
< article >< span class = "resource-label" > CPU available</ span >< div class = "resource-value" > { count ( summary . cpuCoresAvailable )} < small > cores</ small ></ div >< p > After OS reserve and pending starts</ p ></ article >
< article >< span class = "resource-label" > Memory available</ span >< div class = "resource-value" > { memory ( summary . memoryBytesAvailable )} < small > GiB</ small ></ div >< p > After OS reserve and pending starts</ p ></ article >
< article >< span class = "resource-label" > Ports available</ span >< div class = "resource-value" > { count ( summary . portsAvailable )} < small > ports</ small ></ div >< p > Free game ports on eligible hosts</ p ></ article >
< article >< span class = "resource-label" > Startup slots</ span >< div class = "resource-value" > { count ( summary . startupSlots )} < small > { summary . startupLimit === null ? 'available' : `/ $ { summary . startupLimit } `}</small></div><p>{count(summary.activeStartups)} starting · controller-wide</p></article>
</div>
<div class="freshness"><span>{report.current.updatedAt ? ` Capacity updated $ { $timeFormat . dateTime ( report . current . updatedAt )} ` : 'Waiting for host reports'}</span><span>Refreshes every 30 seconds while visible</span></div>
{#if report.current.warnings.length}<aside class="warnings" aria-label="Capacity notices">{#each report.current.warnings as warning}<p>{warning}</p>{/each}</aside>{/if}
<CapacityChart history={report.history} scope={scopeName} />
<section class="hosts" aria-labelledby="host-capacity-title">
<div class="section-heading"><div><h2 id="host-capacity-title">Host capacity</h2><p>Find the resource that limits the next launch.</p></div><span>{summary.eligibleHosts} / {summary.totalHosts} eligible</span></div>
{#if !report.current.hosts.length}<div class="empty-hosts"><h3>{report.query.hostId ? 'This host is unavailable' : 'No hosts yet'}</h3><p>{report.query.hostId ? 'Choose another host to view its capacity.' : 'Connect a host to start seeing capacity and history.'}</p><Button href="/hosts">Open hosts</Button></div>
{:else}<div class="host-grid">{#each report.current.hosts as host (host.id)}<article class="host-card">
<div class="host-title"><h3><a href={` /hosts/ $ { host . id } `}>{host.name} <span aria-hidden="true">↗</span></a></h3><span class="status" class:eligible={host.eligible}>{!host.connected ? 'Offline' : hostReadinessLabel(host.readiness)}</span></div>
<div class="host-estimate"><strong>{count(host.estimatedServers)}</strong><span>additional servers{#if host.eligible && host.limitingResource}<small>Limited by {host.limitingResource.toLowerCase()}</small>{/if}</span></div>
<div class="game-tags">{#if host.games.length}{#each host.games as game}<span>{game === 't5' ? 'Black Ops I' : 'Black Ops II'}</span>{/each}{:else}<span>No games ready</span>{/if}</div>
<dl class="host-resources"><div><dt>CPU</dt><dd>{count(host.cpuCoresAvailable)} <small>cores</small></dd><span>{count(host.limits.cpu)} servers fit</span></div><div><dt>Memory</dt><dd>{memory(host.memoryBytesAvailable)} <small>GiB</small></dd><span>{count(host.limits.memory)} servers fit</span></div><div><dt>Ports</dt><dd>{host.portsAvailable} <small>/ {host.totalPorts}</small></dd><span>{host.limits.ports} servers fit</span></div></dl>
{#if host.reasons.length}<ul class="host-reasons">{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
<details class="allowances"><summary>Per-server resource estimates</summary>
<div class="allowance-grid">{#each [{ label: 'Running', value: host.calibration.running }, { label: 'Starting', value: host.calibration.startup }] as allowance}<div><strong>{allowance.label}</strong><p>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)} GiB</p><span>{allowance.value.source === 'bootstrap' ? 'Default allowance' : ` $ { allowance . value . source === 'host' ? 'Host' : 'Fleet' } observations · $ { allowance . value . samples } samples ` } </ span ></ div > { /each } </ div >
< p > Starts may need more headroom than running servers. Provisioning checks actual capacity before reserving a host and port.</ p >
</ details >
< p class = "host-updated" > { host . updatedAt ? `Reported $ { $timeFormat . dateTime ( host . updatedAt )} ` : 'No host report yet' } </ p >
</ article > { /each } </ div > { /if }
</ section >
< details class = "methodology" >< summary > How capacity is estimated</ summary >< p > Additional servers are estimated from each eligible host’ s available CPU, memory, and unreserved ports, using its learned running-server allowance. The smallest resource allowance sets that host’ s limit. Shared hosts are counted once.</ p >< p > This is a planning estimate. Queued launches do not reserve capacity, and starting servers may require a larger allowance. Actual placement also checks current host readiness, game files, runtime, and available startup slots.</ p >< p > The timeline shows measured host resource headroom after the operating-system reserve. It does not reconstruct historical launch eligibility, queued jobs, or port availability.</ p ></ details >
</ div >
< style >
. page { display : grid ; gap : var ( -- space -5 ); min-width : 0 ; } . toolbar { display : flex ; align-items : end ; justify-content : space-between ; flex-wrap : wrap ; gap : var ( -- space -4 ); }
. filters { display : grid ; grid-template-columns : minmax ( 180 px , 260 px ) minmax ( 180 px , 260 px ); gap : var ( -- space -3 ); }
. ranges { display : flex ; gap : 3 px ; padding : 4 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface ); }
. ranges a { padding : 9 px 13 px ; border-radius : var ( -- radius - sm ); text-decoration : none ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); white-space : nowrap ; }
. ranges a [ aria-current ] { color : var ( -- text - primary ); background : var ( -- surface - raised ); } . ranges a : focus-visible { outline : 2 px solid var ( -- accent - primary ); outline-offset : 2 px ; }
. overview { display : flex ; justify-content : space-between ; align-items : center ; gap : var ( -- space -8 ); background : var ( -- surface ); border : 1 px solid var ( -- border - subtle ); border-left : 3 px solid var ( -- accent - primary ); border-radius : var ( -- radius - lg ); padding : var ( -- space -6 ) var ( -- space -8 ); }
. eyebrow { font-size : var ( -- text - xs ); color : var ( -- text - secondary ); font-weight : var ( -- weight - medium ); } . estimate-number { display : flex ; flex-wrap : wrap ; align-items : baseline ; column-gap : var ( -- space -4 ); margin-top : var ( -- space -3 ); font-size : 56 px ; font-weight : var ( -- weight - semibold ); line-height : 1.1 ; font-variant-numeric : tabular-nums ; letter-spacing : -.035 em ; }
. estimate-number span { font-size : var ( -- text - md ); font-weight : var ( -- weight - regular ); color : var ( -- text - secondary ); letter-spacing : normal ; } . estimate p , . queue-context p { color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); line-height : 1.6 ; margin : var ( -- space -3 ) 0 0 ; }
. queue-context { flex : 0 0 210 px ; border-left : 1 px solid var ( -- border - strong ); padding-left : var ( -- space -6 ); } . queue-context a { color : var ( -- text - primary ); text-decoration : none ; display : flex ; align-items : baseline ; gap : var ( -- space -3 ); } . queue-context a : hover span { color : var ( -- accent - primary - hover ); } . queue-context strong { font-size : var ( -- text -2 xl ); font-weight : var ( -- weight - medium ); font-variant-numeric : tabular-nums ; } . queue-context span { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); }
. resources { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : var ( -- space -3 ); } . resources article { background : var ( -- surface ); padding : var ( -- space -5 ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); }
. resource-label { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); } . resource-value { font-size : var ( -- text -2 xl ); font-weight : var ( -- weight - medium ); margin-top : var ( -- space -3 ); font-variant-numeric : tabular-nums ; display : flex ; align-items : baseline ; flex-wrap : wrap ; gap : 7 px ; } . resource-value small { font-size : var ( -- text - sm ); color : var ( -- text - tertiary ); font-weight : var ( -- weight - regular ); } . resources p { color : var ( -- text - tertiary ); font-size : 11 px ; line-height : 1.5 ; margin : var ( -- space -2 ) 0 0 ; }
. freshness { display : flex ; justify-content : space-between ; flex-wrap : wrap ; gap : var ( -- space -2 ); color : var ( -- text - tertiary ); font-size : 11 px ; margin-top : -6 px ; }
. warnings , . refresh-error { border-left : 2 px solid var ( -- state - warning ); padding : var ( -- space -3 ) var ( -- space -4 ); background : var ( -- surface ); border-radius : 0 var ( -- radius - sm ) var ( -- radius - sm ) 0 ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); line-height : 1.6 ; } . warnings p { margin : 0 ; } . warnings p + p { margin-top : var ( -- space -2 ); } . refresh-error { display : grid ; gap : var ( -- space -1 ); }
. hosts { display : grid ; gap : var ( -- space -4 ); } . section-heading { display : flex ; justify-content : space-between ; align-items : center ; gap : var ( -- space -4 ); } h2 { font-size : var ( -- text - lg ); margin : 0 ; } . section-heading p { font-size : var ( -- text - sm ); color : var ( -- text - tertiary ); margin : 6 px 0 0 ; } . section-heading > span { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); white-space : nowrap ; }
. host-grid { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : var ( -- space -4 ); } . host-card , . empty-hosts { background : var ( -- surface ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - lg ); padding : var ( -- space -5 ); min-width : 0 ; } . host-title { display : flex ; justify-content : space-between ; gap : var ( -- space -3 ); align-items : center ; } h3 { font-size : var ( -- text - md ); margin : 0 ; } . host-title h3 { overflow-wrap : anywhere ; } . host-title a { text-decoration : none ; color : var ( -- text - primary ); } . host-title a : hover { color : var ( -- accent - primary - hover ); } . host-title a span { color : var ( -- text - tertiary ); font-size : var ( -- text - sm ); }
. status { padding : 3 px 8 px ; font-size : 11 px ; background : var ( -- surface - raised ); color : var ( -- text - secondary ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - pill ); white-space : nowrap ; } . status . eligible { color : var ( -- state - online ); }
. host-estimate { display : flex ; align-items : center ; gap : var ( -- space -3 ); margin-top : var ( -- space -5 ); } . host-estimate strong { font-size : var ( -- text -2 xl ); font-weight : var ( -- weight - medium ); font-variant-numeric : tabular-nums ; } . host-estimate span { font-size : var ( -- text - xs ); color : var ( -- text - secondary ); } . host-estimate small { display : block ; color : var ( -- text - tertiary ); margin-top : 4 px ; font-size : 11 px ; }
. game-tags { display : flex ; flex-wrap : wrap ; gap : var ( -- space -2 ); margin-top : var ( -- space -3 ); } . game-tags span { font-size : 10 px ; color : var ( -- text - secondary ); background : var ( -- surface - sunken ); padding : 4 px 7 px ; border-radius : 4 px ; }
. host-resources { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : var ( -- space -3 ); margin : var ( -- space -5 ) 0 0 ; padding : var ( -- space -4 ) 0 ; border-top : 1 px solid var ( -- border - subtle ); border-bottom : 1 px solid var ( -- border - subtle ); } . host-resources dt { font-size : 11 px ; color : var ( -- text - tertiary ); } . host-resources dd { margin : 8 px 0 5 px ; font-size : var ( -- text - md ); font-variant-numeric : tabular-nums ; } . host-resources dd small , . host-resources div > span { font-size : 11 px ; color : var ( -- text - tertiary ); }
. host-reasons { padding-left : 16 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); line-height : 1.7 ; margin : var ( -- space -3 ) 0 0 ; } . allowances { margin-top : var ( -- space -4 ); } summary { cursor : pointer ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); padding : 4 px 0 ; } . allowance-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : var ( -- space -3 ); background : var ( -- surface - sunken ); border-radius : var ( -- radius - sm ); padding : var ( -- space -3 ); margin-top : var ( -- space -3 ); } . allowance-grid strong { font-size : var ( -- text - xs ); font-weight : var ( -- weight - medium ); } . allowance-grid p { font-size : var ( -- text - xs ); margin : 8 px 0 4 px ; color : var ( -- text - secondary ); } . allowance-grid span { font-size : 10 px ; color : var ( -- text - tertiary ); } . allowances > p , . host-updated { font-size : 11 px ; line-height : 1.6 ; color : var ( -- text - tertiary ); margin : var ( -- space -3 ) 0 0 ; }
. methodology { padding : 0 2 px ; } . methodology p { font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); line-height : 1.7 ; max-width : 115 ch ; } . empty-hosts p { color : var ( -- text - secondary ); font-size : var ( -- text - sm ); line-height : 1.6 ; }
@ media ( max-width : 900px ) { . resources { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); } . host-grid { grid-template-columns : 1 fr ; } }
@ media ( max-width : 600px ) { . page { gap : var ( -- space -4 ); } . filters { grid-template-columns : 1 fr 1 fr ; width : 100 % ; } . overview { padding : var ( -- space -5 ); flex-direction : column ; align-items : stretch ; gap : var ( -- space -4 ); } . estimate-number { font-size : 48 px ; } . queue-context { flex-basis : auto ; border-left : 0 ; border-top : 1 px solid var ( -- border - subtle ); padding : var ( -- space -3 ) 0 0 ; } . queue-context p { margin-top : 4 px ; } . queue-context strong { font-size : var ( -- text - xl ); } . resources article { padding : var ( -- space -4 ); } . resource-value { font-size : 28 px ; } . host-card { padding : var ( -- space -4 ); } . section-heading { align-items : flex-start ; } . ranges { width : 100 % ; } . ranges a { text-align : center ; flex : 1 ; } }
</ style >