Show running instances on host pages

This commit is contained in:
2026-10-05 13:56:37 -04:00
parent a6c40cde9b
commit a142f06c77
4 changed files with 87 additions and 3 deletions
+23 -2
View File
@@ -11,7 +11,11 @@
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
import HostPreparation from '$ui/components/HostPreparation.svelte';
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
import ServerCard from '$ui/components/ServerCard.svelte';
import type { ServerObservation } from '$lib/server-observation.js';
import { syncServerTime } from '$ui/stores/servers.js';
let host = $state<Host | null>(null);
let instances = $state<ServerObservation[]>([]);
let calibration = $state<CapacityCalibration | null>(null);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
let deleteOpen = $state(false);
@@ -24,7 +28,7 @@
async function load(initialize = false, signal?: AbortSignal) {
const target = endpoint; const sequence = ++requestSequence;
try {
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = '';
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; instances = body.instances ?? []; syncServerTime(body.serverNow); calibration = body.calibration ?? null; error = '';
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
} catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
}
@@ -45,7 +49,7 @@
</script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, settings, and credentials.">
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
@@ -56,6 +60,22 @@
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
</section>
<section class="panel" aria-labelledby="running-instances-heading">
<h2 id="running-instances-heading">Running instances · {instances.length}</h2>
<p>Current instances on this host, including any that are starting or unavailable.</p>
{#if !host.connected && instances.length}<p role="status">The host is offline. Instance details reflect the latest reports.</p>{/if}
{#if instances.length}
<div class="instances">
{#each instances as instance (instance.name)}
<ServerCard name={instance.name} health={instance.health} players={instance.last_players}
map={instance.last_map} gametype={instance.last_gametype} roster={instance.roster}
lastSeenAt={instance.last_seen_at} lastError={instance.last_error} telemetry={instance.telemetry}
activeSince={instance.active_since} inactiveSince={instance.inactive_since}
gameStartedAt={instance.game_started_at} gameEndedAt={instance.game_ended_at} />
{/each}
</div>
{:else}<p>No instances are currently deployed on this host.</p>{/if}
</section>
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
{#if calibration}<section class="panel"><h2>Learned server capacity</h2>
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
@@ -90,5 +110,6 @@
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
<style>
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
</style>