769 lines
34 KiB
Svelte
769 lines
34 KiB
Svelte
<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>
|