@@ -1,70 +1,74 @@
< script lang = "ts" >
import { contextHref } from '$lib/navigation-context.js' ;
import { onMount , untrack } from 'svelte' ;
import { pollVisible } from '$ui/poll.js' ;
let { data } : { data : import ( './$types' ). PageData } = $props ();
import { page } from '$app/stores' ;
import type { Host } from '$lib/hosts .js' ;
import { hostAgentUpdateLabel , hostDeletionMessage , hostReadinessLabel } from '$lib /hosts .js' ;
import { pollVisible } from '$ui/poll .js' ;
import { hostListStatus , stableHostOrder } from '$ui /host-overview .js' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import Button from '$ui/components/primitives/Button.svelte' ;
import DeleteHostDialog from '$ui/components/DeleteHostDialog .svelte' ;
let hosts = $state < Host [] >( untrack (() => data . hosts )) ;
let loading = $state ( false ); let busy = $state ( false ); let error = $state ( '' );
let notice = $state ( '' ); let deleteOpen = $state ( false ); let deletingHost = $state < Host | null >( null );
import AddHostDrawer from '$ui/components/AddHostDrawer .svelte' ;
import HostList from '$ui/components/HostList.svelte' ;
let { data } : { data : import ( './$types' ). PageData } = $props ( );
let hosts = $state ( untrack (() => data . hosts ) );
let order = $state ( untrack (() => stableHostOrder ([], data . hosts , data . serverNow )));
let clockOffset = untrack (() => data . serverNow - Date . now ());
let now = $state ( untrack (() => data . serverNow ));
let error = $state ( '' ); let addOpen = $state ( false );
let search = $state ( '' ); let status = $state ( '' );
let loadSequence = 0 ;
let name = $state ( '' ); let portMin = $state ( 28960 ); let portMax = $state ( 28980 );
let enrollment = $state < { command : string ; expiresAt : string ; hostId : string } | null > ( null );
const canManage = $derived ( $page . data . user ? . role === 'admin' );
const attention = $derived ( hosts . filter ( host => [ 'attention' , 'offline' ]. includes ( hostListStatus ( host , now ). key )). length );
const ready = $derived ( hosts . filter ( host => hostListStatus ( host , now ). key === 'ready' ). length );
const filtersVisible = $derived ( hosts . length > 5 || !! search || !! status );
const visible = $derived . by (() => {
const byId = new Map ( hosts . map ( host => [ host . id , host ]));
return order . flatMap ( id => {
const host = byId . get ( id );
if ( ! host || ! ` ${ host . name } ${ host . hostname } ` . toLowerCase (). includes ( search . trim (). toLowerCase ())) return [];
const state = hostListStatus ( host , now ). key ;
if ( status && ( status === 'attention' ? ! [ 'attention' , 'offline' ]. includes ( state ) : state !== status )) return [];
return [ host ];
});
});
async function load ( signal? : AbortSignal ) {
const sequence = ++ loadSequence ;
try { const response = await fetch ( '/api/hosts' , { signal : signal ?? AbortSignal . timeout ( 15000 ) }); const body = await response . json (); if ( ! response . ok ) throw new Error ( body . error ?? 'Could not load hosts.' ); if ( sequence === loadSequence ) { hosts = body . hosts ; error = '' ; } }
catch ( cause ) { if ( ! signal ? . aborted && sequence === loadSequence ) error = ( cause as Error ). message ; return false ; } finally { if ( sequence === loadSequence ) loading = false ; }
}
async function deleted ( result : { deleted : boolean }) {
notice = result . deleted ? ` ${ deletingHost ? . name ?? 'Host' } deleted.` : `Cleanup requested for ${ deletingHost ? . name ?? 'host' } . It remains listed until its files and agent are removed.` ;
if ( enrollment ? . hostId === deletingHost ? . id ) enrollment = null ;
deletingHost = null ;
await load ();
}
async function add ( event : SubmitEvent ) {
event . preventDefault (); busy = true ; error = '' ; enrollment = null ;
try {
const response = await fetch ( '/api/hosts' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON.stringify ({ name , portMin , portMax } ) });
const body = await response . json (); if ( ! response . ok ) throw new Error ( body . error ?? 'Could not ad d host.' );
enrollment = body . enrollment ; name = '' ; await load ();
} catch ( cause ) { error = ( cause as Error ). message ; } finally { busy = false ; }
const response = await fetch ( '/api/hosts' , { signal : signal ?? AbortSignal . timeout ( 15000 ) });
const body = await response . json (); if ( ! response . ok ) throw new Error ( body . error ?? 'Could not lo ad hosts .' );
if ( sequence === loadSequence ) { hosts = body . hosts ; clockOffset = body . serverNow - Date . now (); now = body . serverNow ; order = stableHostOrder ( order , hosts , now ) ; error = '' ; }
} catch ( cause ) { if ( ! signal ? . aborted && sequence === loadSequence ) error = ( cause as Error ). message ; return false ; }
}
onMount (() => pollVisible ( load , 30000 , false ));
onMount (() => {
const stop = pollVisible ( load , 30000 , false );
const timer = setInterval (() => now = Date . now () + clockOffset , 15000 );
return () => { stop (); clearInterval ( timer ); };
});
</ script >
< svelte:head >< title > Hosts · Electric Cherry</ title ></ svelte:head >
< section class = "page" >
< SectionHeader title = "Hosts" subtitle = "Connect game-server hosts. Runtime and game files are prepared automatically." >
{ # snippet actions ()} < Button href = "/capacity" > View capacity </ Button > { /snippet }
< SectionHeader title = "Hosts" >
{ # snippet actions ()}{ #if canManage } < Button variant = "primary" onclick = {() => addOpen = true } > Add host </ Button > { /if } {/snippet }
</ SectionHeader >
{ #if error } < p role = "alert" class = "ui-error" > { error } </ p > { /if }
{ #if notice } < p role = "status" > { notice } </ p > { /if }
{ #if canManage } < section class = "panel" >
< h2 > Add host </ h2 >< p > Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS, then prepares its runtime and published game files automatically. </ p >
< form onsubmit = { add } > <fieldset disabled = { busy } >
< label > Friendly name < input required maxlength = "200 " bind:value = { name } placeholder="Game host 1 " / ></label >
< label > First game port < input type = "number" required min = "1 " max = "65535" bind:value = { portMin } / ></ label >
< label > Last game port< input type = "number" required min = { portMin } max="65535" bind:value = { portMax } / ></ label >
< Button type = "submit" disabled = { busy } > { busy ? 'Creating…' : 'Add host' } </Button >
</ fieldset ></ form >
{ #if enrollment } < div class = "enrollment" role = "status" >< h3 > Install the agent</ h3 >< p > This single-use command expires { new Date ( enrollment . expiresAt ). toLocaleString ()} . Run it with administrator access on your game host.</ p >< pre > { enrollment . command } </ pre >< a href = { `/hosts/ ${ enrollment . hostId } ` } > Open host setup →</ a ></ div > { /if }
</ section > { : else } < p > Administrators enroll hosts and manage their settings and credentials.</ p > { /if }
{ #if loading } < p role = "status" > Loading hosts…</ p > { :else if ! hosts . length } < p > No hosts connected yet.{ canManage ? ' Add a host to begin.' : '' } </ p > { : else }
< div class = "host-grid" > { #each hosts as host ( host . id )} < article class = "panel host" >< div class = "row" >< h2 >< a href = { contextHref ( `/hosts/$ { host . id } `, $page.url)}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}– {host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={contextHref(` /hosts/ $ { host . id } `, $page.url) } > { host . decommission ? 'View cleanup' : 'Manage host' } </Button > { #if canManage && ( ! host . decommission || host . decommission . retryable )} < Button size = "sm" variant = "danger" onclick = {() => { deletingHost = host ; deleteOpen = true ; }} > { host . decommission ? 'Retry cleanup' : 'Delete host' } </Button > { /if } </ div ></ article > { /each } </ div >
{ /if }
{ #if error } < p role = "alert" class = "ui-error" > { error } Showing the last loaded hosts. < Button variant = "ghost" size = "sm" onclick = {() => load ()} > Retry</Button > </p > { /if }
{ #if hosts . length }
< div class = "summary" >< span > { hosts . length } host { hosts . length === 1 ? '' : 's' } · { ready === hosts . length ? 'All ready' : `$ { ready } ready ` } </ span > { #if attention } < button onclick = {() => { status = 'attention' ; search = '' ; }} > { attention } need attention </ button > { /if } </ div >
{ #if filtersVisible } < div class = "filters" >
< input type = "search" aria-label = "Search hosts" placeholder = "Search hosts…" bind:value = { search } / >
< select aria-label = "Filter hosts by status " bind:value = { status } > <option value = "" > All statuses </ option >< option value = "attention" > Needs attention </ option >< option value = "offline" > Offline </ option >< option value = "preparing" > Preparing </ option >< option value = "draining" > Draining </ option >< option value = "disabled" > Disabled </ option >< option value = "deleting" > Deleting </ option >< option value = "ready" > Ready </ option ></ select >
{ #if search || status } < Button variant = "ghost" size = "sm " onclick = {() => { search = '' ; status = '' ; }} > Clear filters </ Button > { /if }
</ div > { /if }
{ #if visible . length } < HostList hosts = { visible } { now } returnTo= { $page . url . pathname + $page . url . search } /> { : else } < p class = "empty" > No hosts match these filters. </ p > { /if }
{ : else } < p class = "empty" > No hosts connected yet. { canManage ? ' Add a host to begin.' : '' } </ p > { /if }
</ section >
< DeleteHostDialog bind:open = { deleteOpen } host ={ deletingHost } ondeleted = { deleted } / >
{ #if canManage } < AddHostDrawer bind:open = { addOpen } onadded ={ load } /> { /if }
< style >
. page { display : grid ; gap : 24 px ; } . panel { border : 1 px solid var ( -- border - strong ); border-radius : var ( -- radius - md ); background : var ( -- surface ); padding : 24 px ; }
h2 { font-size : var ( -- text - md ) ; margin : 0 ; } h3 { font-size : var ( -- text - sm ); } p { color : var ( -- text - secondary ); line-height : 1.6 ; font-size : var ( -- text - sm ) ; }
fieldset { display : flex ; gap : 16 px ; flex-wrap : wrap ; align-items : end ; border : 0 ; padding : 0 ; margin-top : 20 px ; } label { display : grid ; gap : 8 px ; font-size : var ( -- text - sm ); } input { width : 100 % ; max-width : 280 px ; padding : 10 px ; color : var ( -- text - primary ); background : var ( -- surface - sunken ); border : 1 px solid var ( -- border - control ); border-radius : var ( -- radius - sm ); }
pre { white-space : pre-wrap ; overflow-wrap : anywhere ; padding : 16 px ; background : var ( -- surface - sunken ); font-size : var ( -- text - sm ); } . enrollment { border-top : 1 px solid var ( -- border - subtle ); margin-top : 24 px ; padding-top : 16 px ; }
. host-grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 300 px , 1 fr )); gap : 16 px ; } . host h2 a { color : inherit ; text-decoration : none ; } . host h2 a : hover { color : var ( -- accent - primary ); } . host-actions { display : flex ; flex-wrap : wrap ; gap : 12 px ; margin-top : 20 px ; } . row { display : flex ; justify-content : space-between ; gap : 16 px ; } . state { font-size : var ( -- text - xs ); text-transform : capitalize ; color : var ( -- text - secondary ); } . ready { color : var ( -- accent - primary ); } . reason { margin-bottom : 0 ; }
. page { display : grid ; gap : 20 px ; }
. summary , . filters { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 12 px ; }
. summary , . empty { color : var ( -- text - secondary ); font-size : var ( -- text - sm ); }
. summary button { background : none ; border : 0 ; color : var ( -- state - danger ); cursor : pointer ; font : inherit ; padding : 4 px ; }
input , select { box-sizing : border-box ; padding : 10 px 12 px ; color : var ( -- text - primary ); background : var ( -- surface - sunken ) ; border : 1 px solid var ( -- border - control ); border-radius : var ( -- radius - sm ); font : var ( -- text - sm ) var ( -- font - body ) ; }
input { flex : 1 1 240 px ; min-width : 0 ; max-width : 440 px ; }
input : focus-visible , select : focus-visible , button : focus-visible { outline : 2 px solid var ( -- accent - primary ); outline-offset : 3 px ; }
. empty { padding : 24 px ; text-align : center ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); }
</ style >