Files
electric-cherry/src/routes/hosts/[id]/+page.svelte
T
josh b5037a70fe
CI / checks (push) Successful in 6m41s
Fix mobile overflow and touch targets
2026-10-16 17:48:06 -04:00

769 lines
34 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
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 HostTemperatures from '$ui/components/HostTemperatures.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>CPU temperature</dt>
<dd><HostTemperatures {host} /></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>
Prepared runtimes: {host.inventory.preparedRuntimeImages?.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}>
<Field label="Plutainer image">
<Input required bind:value={runtimeImage} /></Field
><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}>
<Field label="Friendly name">
<Input required maxlength={200} bind:value={name} /></Field
>
<div class="fields">
<Field label="First game port">
<Input
type="number"
min="1"
max="65535"
required
bind:value={portMin}
/></Field
><Field label="Last game port">
<Input
type="number"
min={portMin}
max="65535"
required
bind:value={portMax}
/></Field
>
</div>
<Field label="Local bind address">
<Input required bind:value={bindAddress} /></Field
><Field label="Public / NAT address">
<Input
bind:value={publicAddress}
placeholder="Optional public hostname or address"
/></Field
><Field label="Minimum free game storage (GiB)">
<Input
type="number"
min="0"
step="0.1"
required
bind:value={minimumFreeGB}
/></Field
><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>
<p>
This command downloads the installer from the public Electric Cherry
repository. The host fetches its public signing key from that
repository during installation and before every update check.
</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;
}
.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: 640px) {
.host-menu {
min-width: 0;
width: 100%;
}
.host-menu summary {
min-height: 44px;
}
.host-menu > div {
position: static;
min-width: 0;
}
}
</style>