Group server and host details into task sections

This commit is contained in:
2026-10-05 19:09:15 -04:00
parent cd577b646c
commit 913dc99181
5 changed files with 147 additions and 62 deletions
+13
View File
@@ -0,0 +1,13 @@
export type ServerSection = 'live' | 'matches' | 'configuration' | 'diagnostics' | 'history';
/** Legacy URLs still select their original content inside the new section. */
export function serverSection(tab: string | null): ServerSection {
if (tab === 'matches' || tab === 'history' || tab === 'diagnostics') return tab;
if (tab === 'config' || tab === 'inventory' || tab === 'configuration') return 'configuration';
if (tab === 'logs') return 'diagnostics';
return 'live';
}
export function serverSectionAnchor(tab: string | null): string | null {
return tab === 'chat' || tab === 'logs' || tab === 'inventory' ? tab : null;
}
+2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
@@ -210,6 +211,7 @@
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head> <svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." /> <SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." />
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if} {#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
+50 -23
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.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 { contextHref } from '$lib/navigation-context.js';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -24,6 +27,14 @@
let drainOpen = $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 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); 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 endpoint = $derived(`/api/hosts/${$page.params.id}`);
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const cleaning = $derived(!!host?.decommission); const cleaning = $derived(!!host?.decommission);
@@ -50,6 +61,7 @@
$effect(() => { $effect(() => {
void endpoint; void endpoint;
return untrack(() => { return untrack(() => {
host = null; instances = []; calibration = null; loading = true; error = ''; notice = ''; enrollment = null;
let initialize = true; let initialize = true;
return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000); return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000);
}); });
@@ -57,17 +69,35 @@
</script> </script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head> <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="Running instances, readiness, inventory, settings, and credentials."> <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(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>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} {#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>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> </SectionHeader>
<BackLink fallback="/hosts" />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#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 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 {new Date(host.decommission.requestedAt).toLocaleString()}. 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} {#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 {new Date(host.decommission.requestedAt).toLocaleString()}. 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: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div> <section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</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.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 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} {#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/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>
<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'}
<div class="overview-actions"><Button onclick={() => changeTab('servers')}>View {instances.length} servers</Button></div>
<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'} ({allowance.samples} samples)</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>Use agent 1.4.6 or later for resource sampling. 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. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if}
{:else if tab === 'servers'}
<section class="panel" aria-labelledby="running-instances-heading"> <section class="panel" aria-labelledby="running-instances-heading">
<h2 id="running-instances-heading">Running instances · {instances.length}</h2> <h2 id="running-instances-heading">Running instances · {instances.length}</h2>
<p>Current instances on this host, including any that are starting or unavailable.</p> <p>Current instances on this host, including any that are starting or unavailable.</p>
@@ -84,35 +114,31 @@
</div> </div>
{:else}<p>No instances are currently deployed on this host.</p>{/if} {:else}<p>No instances are currently deployed on this host.</p>{/if}
</section> </section>
{:else if tab === 'setup'}
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if} {#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>
<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'} ({allowance.samples} samples)</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>Use agent 1.4.6 or later for resource sampling. 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. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if}
<div class="grid"><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><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></div>
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2> <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>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl> <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>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : '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.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.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}<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 below, 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} {#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>
<div class="grid"><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} <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> <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} {#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><section class="panel"><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> </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 {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if} {#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section></div> </section>
{/if}
</div>
{/if} {/if}
</section> </section>
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} /> <DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
@@ -125,5 +151,6 @@
<style> <style>
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; } .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; } } .host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; }
.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> </style>
+62 -38
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
@@ -96,11 +98,22 @@
let rconProbe = $state<RconProbeResult | null>(null); let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false); let probingRcon = $state(false);
let destroyOpen = $state(false); let destroyOpen = $state(false);
const sectionIds = ['scoreboard', 'matches', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history']; const sectionIds = ['live', 'scoreboard', 'matches', 'chat', 'logs', 'configuration', 'config', 'inventory', 'diagnostics', 'history'];
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard'); const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'live');
const activeSection = $derived(serverSection(activeTab));
const loadedServerName = $derived(server?.name);
$effect(() => {
const anchor = serverSectionAnchor(activeTab);
const currentName = loadedServerName;
if (!anchor || !currentName) return;
let cancelled = false;
void tick().then(() => { if (!cancelled) document.getElementById(anchor)?.scrollIntoView({ block: 'start' }); });
return () => { cancelled = true; };
});
function changeTab(tab: string) { function changeTab(tab: string) {
const url = new URL($page.url); const url = new URL($page.url);
url.searchParams.set('tab', tab); url.searchParams.set('tab', tab);
url.hash = '';
void goto(url, { noScroll: true, keepFocus: true }); void goto(url, { noScroll: true, keepFocus: true });
} }
let tabLoading = $state(false); let tabLoading = $state(false);
@@ -112,32 +125,32 @@
let tabSequence = 0; let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) { async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'history' && tab !== 'inventory') return; if (tab !== 'history' && tab !== 'configuration') return;
const sequence = ++tabSequence; const sequence = ++tabSequence;
try { try {
if (tab === 'history') { if (tab === 'history') {
const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal); const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal);
if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return; if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
history = body.deployments; historyTotal = body.total; history = body.deployments; historyTotal = body.total;
} else { } else {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal }); const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.'); if (!response.ok) throw new Error('Could not refresh inventory.');
const body = await response.json(); const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return; if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
inventory = body.scripts; inventory = body.scripts;
} }
tabError = ''; tabError = '';
return true; return true;
} catch (error) { } catch (error) {
if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabError = (error as Error).message; if (sequence === tabSequence && name === currentName && activeSection === tab && !signal?.aborted) tabError = (error as Error).message;
return false; return false;
} finally { if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabLoading = false; } } finally { if (sequence === tabSequence && name === currentName && activeSection === tab && !signal?.aborted) tabLoading = false; }
} }
$effect(() => { $effect(() => {
const currentName = name; const currentName = name;
const tab = activeTab; const tab = activeSection;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25; tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'inventory'; tabLoading = tab === 'history' || tab === 'configuration';
return pollVisible(signal => loadTab(currentName, tab, signal), 10000); return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
}); });
$effect(() => { name; rconProbe = null; }); $effect(() => { name; rconProbe = null; });
@@ -196,14 +209,11 @@
} }
// Tab config — Diagnostics gets a warning badge when there's an issue. // Tab config — Diagnostics gets a warning badge when there's an issue.
let tabs = $derived([ let tabs = $derived([
{ id: 'scoreboard', label: 'Scoreboard', badge: null }, { id: 'live', label: 'Live' },
{ id: 'chat', label: 'Chat' },
{ id: 'matches', label: 'Matches' }, { id: 'matches', label: 'Matches' },
{ id: 'logs', label: 'Logs' }, { id: 'configuration', label: 'Configuration' },
{ id: 'config', label: 'Config' }, { id: 'diagnostics', label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null }, { id: 'history', label: 'History' }
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'history', label: 'History', badge: history.length || null }
]); ]);
</script> </script>
@@ -289,32 +299,29 @@
</section> </section>
{/if} {/if}
<div class="tabs-row"> <div class="tabs-row">
<Tabs {tabs} value={activeTab} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" /> <Tabs {tabs} value={activeSection} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
</div> </div>
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} class:history-panel={activeTab === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}> <div class="tab-panel" class:diagnostics={activeSection === 'diagnostics'} class:history-panel={activeSection === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeSection}>
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if} {#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
{#if activeTab === 'scoreboard'} {#if activeSection === 'live'}
<nav class="section-links" aria-label="Live sections"><a href="#scoreboard">Scoreboard</a><a href="#chat">Chat</a></nav>
<section id="scoreboard" aria-label="Scoreboard">
<Card padding="lg"> <Card padding="lg">
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key} {#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card> </Card>
{:else if activeTab === 'chat'} </section>
<section id="chat" aria-label="Chat">
<Card padding="lg"> <Card padding="lg">
{#key name}<ChatPanel server={name} />{/key} {#key name}<ChatPanel server={name} />{/key}
</Card> </Card>
{:else if activeTab === 'logs'} </section>
<Card padding="lg"> {:else if activeSection === 'configuration'}
<LogPanel <nav class="section-links" aria-label="Configuration sections"><a href="#config">Config</a><a href="#inventory">Inventory</a></nav>
server={server.name} <section id="config" aria-label="Config">
tabs={[
{ key: 'console', label: 'console.log' },
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
]}
/>
</Card>
{:else if activeTab === 'config'}
{#key name}<ConfigInspector fixedServer={name} />{/key} {#key name}<ConfigInspector fixedServer={name} />{/key}
{:else if activeTab === 'inventory'} </section>
<section id="inventory" aria-label="Inventory">
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Installed files</h2> <h2>Installed files</h2>
@@ -339,7 +346,10 @@
{/if} {/if}
</Card> </Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" /> <ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
{:else if activeTab === 'diagnostics'} </section>
{:else if activeSection === 'diagnostics'}
<nav class="section-links" aria-label="Diagnostic sections"><a href="#connection">Connection health</a><a href="#logs">Logs</a></nav>
<section id="connection" aria-label="Connection health">
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>RCON diagnostic</h2> <h2>RCON diagnostic</h2>
@@ -414,9 +424,21 @@
</div> </div>
</Card> </Card>
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div> <div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
{:else if activeTab === 'matches'} </section>
<section id="logs" aria-label="Logs">
<Card padding="lg">
<LogPanel
server={server.name}
tabs={[
{ key: 'console', label: 'console.log' },
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
]}
/>
</Card>
</section>
{:else if activeSection === 'matches'}
{#key name}<MatchHistory server={name} />{/key} {#key name}<MatchHistory server={name} />{/key}
{:else if activeTab === 'history'} {:else if activeSection === 'history'}
<div class="history-links"><Button href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</Button><Button href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</Button></div> <div class="history-links"><Button href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</Button><Button href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</Button></div>
{#if operations.length} {#if operations.length}
<Card padding="lg"> <Card padding="lg">
@@ -478,6 +500,8 @@
{/if} {/if}
<style> <style>
.section-links { display:flex; gap:20px; flex-wrap:wrap; font-size:var(--text-sm); }
.tab-panel > section { display:grid; gap:16px; min-width:0; scroll-margin-top:80px; }
.history-links { display:flex; flex-wrap:wrap; gap:12px; } .history-links { display:flex; flex-wrap:wrap; gap:12px; }
.server-actions { text-align: right; } .server-actions { text-align: right; }
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); } .server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
@@ -527,8 +551,8 @@
} }
/* Tabs row */ /* Tabs row */
.tab-panel.diagnostics, .tab-panel {
.tab-panel.history-panel { min-width: 0;
display: grid; display: grid;
gap: var(--space-6); gap: var(--space-6);
} }
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { serverSection, serverSectionAnchor } from '../../src/lib/server-sections.js';
describe('server detail bookmarks', () => {
it.each([
['scoreboard', 'live', null], ['chat', 'live', 'chat'], ['matches', 'matches', null],
['config', 'configuration', null], ['inventory', 'configuration', 'inventory'],
['logs', 'diagnostics', 'logs'], ['diagnostics', 'diagnostics', null], ['history', 'history', null]
])('preserves the destination of tab=%s', (tab, section, anchor) => {
expect(serverSection(tab)).toBe(section);
expect(serverSectionAnchor(tab)).toBe(anchor);
});
it('defaults unsupported tabs to Live and accepts the new sections', () => {
expect(serverSection(null)).toBe('live');
expect(serverSection('missing')).toBe('live');
expect(serverSection('live')).toBe('live');
expect(serverSection('configuration')).toBe('configuration');
});
});