168 lines
21 KiB
Svelte
168 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import { untrack } from 'svelte';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
let requestSequence = 0;
|
||
import { page } from '$app/stores';
|
||
import { goto } from '$app/navigation';
|
||
import type { Host, CapacityCalibration } from '$lib/hosts.js';
|
||
import { hostCalibrationHint } from '$ui/host-overview.js';
|
||
import { countLabel } from '$ui/plural.js';
|
||
import { hostAgentLaneLabel, hostAgentLaneState, hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
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';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
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);
|
||
let drainOpen = $state(false);
|
||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
||
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
||
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'servers', label: 'Servers' }, { id: 'setup', label: 'Setup' }, { id: 'settings', label: 'Settings' }];
|
||
const tab = $derived(tabs.some(tab => tab.id === $page.url.searchParams.get('tab')) ? $page.url.searchParams.get('tab')! : 'overview');
|
||
function changeTab(value: string) {
|
||
const url = new URL($page.url);
|
||
url.searchParams.set('tab', value);
|
||
url.hash = '';
|
||
void goto(url, { noScroll: true, keepFocus: true });
|
||
}
|
||
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
||
const canManage = $derived($page.data.user?.role === 'admin');
|
||
const cleaning = $derived(!!host?.decommission);
|
||
function bytes(value: unknown) { return typeof value === 'number' ? `${(value / 1073741824).toFixed(1)} GiB` : 'Unknown'; }
|
||
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; 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; }
|
||
}
|
||
async function action(path: string, method: string, body?: unknown, message?: string) {
|
||
busy = true; error = ''; notice = '';
|
||
try { const response = await fetch(endpoint + path, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? 'Host request failed.'); if (result.enrollment) enrollment = result.enrollment; notice = message ?? ''; await load(); }
|
||
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||
}
|
||
async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); }
|
||
async function mode(value: 'active' | 'draining') {
|
||
await action('', 'PATCH', { mode: value }, value === 'draining'
|
||
? 'Drain started. Empty instances migrate as destination capacity becomes available.'
|
||
: 'Automatic setup started. This host will enable after the runtime and both game libraries are verified.');
|
||
if (!error) drainOpen = false;
|
||
}
|
||
$effect(() => {
|
||
void endpoint;
|
||
return untrack(() => {
|
||
host = null; instances = []; calibration = null; loading = true; error = ''; notice = ''; enrollment = null;
|
||
let initialize = true;
|
||
return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000);
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||
<section class="page"><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
|
||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
|
||
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/capacity?rebalance=1&rebalanceHost=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Rebalance</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
|
||
</SectionHeader>
|
||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested <RelativeTime value={host.decommission.requestedAt} />. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
|
||
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: <RelativeTime value={host.lastHeartbeat} missing="Not yet reported" /></span></div>
|
||
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
|
||
{#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/if}
|
||
</section>
|
||
<Tabs {tabs} value={tab} onchange={changeTab} ariaLabel="Host sections" idPrefix="host-tabs" panelId="host-panel" />
|
||
<div class="host-panel" id="host-panel" role="tabpanel" aria-labelledby={`host-tabs-${tab}`}>
|
||
{#if tab === 'overview'}
|
||
<section class="panel"><h2>Agent workers</h2>
|
||
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)}
|
||
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: <RelativeTime value={lane.lastPollAt} missing="Not yet reported" /></dd>
|
||
{/each}</dl>{:else}<p>This agent has not reported worker status yet.</p>{/if}
|
||
</section>
|
||
<section class="panel"><h2>Inventory</h2><dl>
|
||
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
|
||
</dl></section> {#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>
|
||
<dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
|
||
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt>
|
||
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({countLabel(allowance.samples, 'sample')})</dd>
|
||
{/each}</dl>
|
||
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
|
||
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>{hostCalibrationHint(host.inventory.agentVersion)} Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running.</p>{/if}
|
||
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
|
||
</section>{/if}
|
||
{:else if tab === 'servers'}
|
||
<section class="panel" aria-labelledby="running-instances-heading">
|
||
<h2 id="running-instances-heading">Running servers · {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>
|
||
{:else if tab === 'setup'}
|
||
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
|
||
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2>
|
||
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd><RelativeTime value={host.inventory.agentUpdate?.checkedAt} missing="Not reported" /></dd></dl>
|
||
{#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if}
|
||
{#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if}
|
||
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
|
||
{#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access in Settings, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if}
|
||
</section>
|
||
<section class="panel"><h2>Cached game files and runtimes</h2>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported yet.</p>{/if}
|
||
<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported yet'}</p>
|
||
{#if canManage}<details><summary>Advanced: custom runtime or manual retry</summary><p>The configured default runtime is prepared automatically. Use this to prepare another image or retry a pull now.</p><form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Pull runtime image</Button></fieldset></form></details>{/if}
|
||
</section>
|
||
<div class="actions"><Button href={contextHref('/game-files', $page.url)}>Game Files</Button><Button onclick={() => changeTab('settings')}>Agent access</Button></div>
|
||
{:else if tab === 'settings'}
|
||
<section class="panel"><h2>Host availability</h2>
|
||
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode !== 'disabled' || !!host.enablingSince} onclick={() => mode('active')}>{host.enablingSince ? 'Enabling…' : 'Enable'}</Button><Button disabled={busy || host.mode !== 'active'} onclick={() => drainOpen = true}>Drain host</Button></div>{/if}
|
||
<p>Draining blocks new deployments and migrates empty instances to other hosts with capacity. The host disables when migration is complete and it is empty. Enabling refreshes the runtime and verifies BO1 and BO2 game files before returning the host to the capacity pool.</p></section>
|
||
<section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
|
||
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
|
||
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section><section class="panel" id="agent-access"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||
</section>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
|
||
<Dialog bind:open={drainOpen} title="Drain {host?.name ?? 'host'}?" {busy}>
|
||
<p>New deployments will stop on this host. Each instance will wait until its lobby is empty and another host has capacity, then be destroyed here and rebuilt there with the same server identity and map. Its address or port may change.</p>
|
||
<p>If capacity is unavailable, instances stay here until space becomes available. After all migrations finish, this host will be disabled.</p>
|
||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||
{#snippet actions()}<Button disabled={busy} onclick={() => drainOpen = false}>Cancel</Button><Button variant="danger" disabled={busy} onclick={() => mode('draining')}>{busy ? 'Starting drain…' : 'Confirm drain'}</Button>{/snippet}
|
||
</Dialog>
|
||
|
||
<style>
|
||
.host-menu { position:relative; }
|
||
.host-menu summary { cursor:pointer; padding:10px 14px; color:var(--text-secondary); font-size:var(--text-sm); }
|
||
.host-menu summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||
.host-menu>div { position:absolute; right:0; top:100%; z-index:5; padding:12px; min-width:160px; background:var(--surface-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); }
|
||
|
||
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
|
||
.host-panel { display:grid; gap:24px; min-width:0; }
|
||
.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); } .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); }
|
||
</style>
|