Automate host preparation and speed up game file downloads
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { heartbeat } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
|
||||
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const authenticated = authenticateAgent(request);
|
||||
try {
|
||||
@@ -11,6 +12,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js');
|
||||
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
|
||||
}
|
||||
reconcileHostPreparation(host.id);
|
||||
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pendingHostOperations } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
export function GET({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
reconcileHostPreparation(host.id);
|
||||
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { config } from '$lib/server/config.js';
|
||||
|
||||
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts() }); }
|
||||
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts().map(presentHostPreparation), defaultRuntimeImage: config.komodo.defaultImage }); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { config } from '$lib/server/config.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function GET({ request, params }: Event) {
|
||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||
// Payloads and results can contain RCON keys or staged credentials. History
|
||||
// exposes metadata only; agent APIs remain the only command delivery path.
|
||||
return json({ host, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||
return json({ host: presentHostPreparation(host), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||
}
|
||||
export async function PATCH({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
|
||||
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
|
||||
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
|
||||
function automaticDownloadDetail(host: Host | undefined): string {
|
||||
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
|
||||
if (!host?.enrolled) return 'Waiting for host enrollment.';
|
||||
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
|
||||
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
|
||||
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
|
||||
return 'Download starts automatically after runtime preparation and earlier work finish.';
|
||||
}
|
||||
|
||||
async function responseBody(response: Response) {
|
||||
let body;
|
||||
@@ -101,7 +109,7 @@
|
||||
}
|
||||
if (disposed) return;
|
||||
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.');
|
||||
phase = 'published'; folder = null; notice = `${name || 'Game files'} ${version} published. You can download this version to a host below.`;
|
||||
phase = 'published'; folder = null; notice = `${name || 'Game files'} ${version} published. Active hosts will download and verify this version automatically.`;
|
||||
if (folderInput) folderInput.value = '';
|
||||
await load();
|
||||
}
|
||||
@@ -192,12 +200,12 @@
|
||||
|
||||
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Game Files" subtitle="Upload your T5 or T6 game folder, then distribute verified versions to your hosts.">
|
||||
<SectionHeader title="Game Files" subtitle="Publish your T5 or T6 game folder once. Hosts download and verify the latest versions automatically.">
|
||||
{#snippet actions()}{#if canManage}<Button disabled={uploadBusy} onclick={() => newUpload()}>Upload game folder</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if !canManage}<p class="muted">Administrators upload game files and distribute verified copies to hosts.</p>{/if}
|
||||
{#if !canManage}<p class="muted">Administrators publish game files. Active hosts prepare verified copies automatically.</p>{/if}
|
||||
{#if canManage && showUpload}
|
||||
<section class="card" id="library-upload">
|
||||
<div class="heading"><h2>{uploadId ? phase === 'published' ? 'Version published' : 'Continue folder upload' : 'Upload a game folder'}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
|
||||
@@ -216,7 +224,7 @@
|
||||
{/if}
|
||||
{#if folder}<div class="folder-summary"><strong>{folder.name}</strong><span>{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}</span><details><summary>Preview folder paths</summary><ul class="path-list">{#each folder.files.slice(0, 8) as file}<li>{file.path}</li>{/each}</ul>{#if folder.files.length > 8}<p class="muted">And {(folder.files.length - 8).toLocaleString()} more files.</p>{/if}</details></div>{/if}
|
||||
{#if phase !== 'idle'}<div class="upload-progress" aria-live="polite">
|
||||
<div class="heading"><strong>{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying and publishing…' : phase === 'published' ? 'Published and ready to distribute' : 'Upload paused'}</strong><span>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span></div>
|
||||
<div class="heading"><strong>{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying and publishing…' : phase === 'published' ? 'Published · host downloads are automatic' : 'Upload paused'}</strong><span>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span></div>
|
||||
<progress max={totalBytes || Math.max(1, totalFiles)} value={totalBytes ? progressBytes : completedFiles} aria-label="Game folder upload progress"></progress>
|
||||
<p>{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring</p>
|
||||
{#if currentPath}<p class="current-file">{currentPath}</p>{/if}
|
||||
@@ -232,6 +240,7 @@
|
||||
{/if}
|
||||
<div class="filters"><label>Game<select bind:value={filterGame}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><span class="muted">{libraries.length} libraries · Game folders and runtime images are versioned separately.</span></div>
|
||||
{#each matching as library (library.id)}
|
||||
{@const latestPublished = library.versions.find(item => item.state === 'ready')}
|
||||
<section class="card">
|
||||
<div class="heading"><div><h2>{library.name}</h2><p class="muted">{library.game.toUpperCase()} · {library.description}</p></div>{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>Upload new version</Button>{/if}</div>
|
||||
{#each library.versions as item (item.id)}
|
||||
@@ -242,11 +251,22 @@
|
||||
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
|
||||
<details><summary>Version details</summary><dl><dt>Immutable version ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{browserUpload ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl>{#if item.state === 'ready'}<a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a>{/if}</details>
|
||||
{#if item.state === 'ready'}
|
||||
{#if canManage}<div class="download"><label>Download to host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div>{#if !onlineHosts.length}<p class="muted">This version is stored and ready. <a href="/hosts">Connect a host</a> when you want to deploy game servers.</p>{/if}{/if}
|
||||
{#if item.id === latestPublished?.id}<p class="muted">This is the latest published version. Active hosts download and verify it automatically, including hosts enrolled later. Running servers keep their selected version.</p>{:else}<p class="muted">This older version remains available to servers pinned to it. Use the advanced download control to prepare it on another host.</p>{/if}
|
||||
{#if !hosts.length}<p class="muted">Files are published. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if}
|
||||
<div class="table-wrap"><table><thead><tr><th>Host</th><th>Cache state</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
|
||||
{#each item.caches as cache}<tr><td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td><td>{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}</td><td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded} aria-label="Host download progress"></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}</td><td>{cache.error ?? ''}</td></tr>{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
|
||||
{#each item.caches as cache}
|
||||
{@const cacheHost = hosts.find(host => host.id === cache.hostId)}
|
||||
{@const awaitingAutomaticDownload = item.id === latestPublished?.id && cache.status === 'missing'}
|
||||
<tr>
|
||||
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
|
||||
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</td>
|
||||
<td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded} aria-label="Host download progress"></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}</td>
|
||||
<td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
|
||||
</tr>
|
||||
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
|
||||
</tbody></table></div>
|
||||
{:else if browserUpload}<p class="muted">Resume with the original unchanged folder. Published versions can be downloaded to any enrolled host.</p>{#if canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if}
|
||||
{#if canManage}<details><summary>Advanced: download or verify this version</summary><p class="muted">Prepare an older version or retry a download now. Latest versions are prepared automatically.</p><div class="download"><label>Host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div></details>{/if}
|
||||
{:else if browserUpload}<p class="muted">Resume with the original unchanged folder. Once published, this version is prepared automatically on active hosts.</p>{#if canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if}
|
||||
{:else if item.state === 'importing'}<p class="muted">The existing host import continues independently. It is reconciled when the host reconnects.</p>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
@@ -37,11 +37,11 @@
|
||||
|
||||
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
|
||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts. Runtime and game files are prepared automatically." />
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if canManage}<section class="panel">
|
||||
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
|
||||
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS, then prepares its runtime and published game files automatically.</p>
|
||||
<form onsubmit={add}><fieldset disabled={busy}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
|
||||
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
|
||||
@@ -49,9 +49,9 @@
|
||||
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
|
||||
</fieldset></form>
|
||||
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
|
||||
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if}
|
||||
</section>{:else}<p>Administrators enroll hosts and manage their settings and credentials.</p>{/if}
|
||||
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
|
||||
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if host.readiness === 'needs-game-files'}<Button size="sm" href="/game-files">Choose game files</Button>{/if}{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
|
||||
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
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';
|
||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
@@ -19,7 +20,7 @@
|
||||
async function load(initialize = false) {
|
||||
try {
|
||||
const response = await fetch(endpoint); const body = await response.json(); 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; operations = body.operations;
|
||||
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; }
|
||||
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) { error = (cause as Error).message; } finally { loading = false; }
|
||||
}
|
||||
async function action(path: string, method: string, body?: unknown, message?: string) {
|
||||
@@ -41,10 +42,10 @@
|
||||
{#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>
|
||||
{#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 game library, runtime, and ports.</p>{/if}
|
||||
{#if host.readiness === 'needs-game-files'}<p><a href="/game-files">Choose a published game-file version and download it to this host →</a></p>{/if}
|
||||
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
|
||||
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
|
||||
</section>
|
||||
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></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>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}>
|
||||
@@ -57,8 +58,9 @@
|
||||
{#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}
|
||||
</section>
|
||||
<div class="grid"><section class="panel"><h2>Game files and runtime</h2><p><a href="/game-files">Manage game-file libraries →</a></p>{#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.</p>{/if}
|
||||
{#if canManage}<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}>Prepare runtime</Button></fieldset></form>{/if}<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported'}</p>
|
||||
<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}
|
||||
<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><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>
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user