Simplify game files to automatic T5 and T6 uploads

This commit is contained in:
2026-10-04 12:23:36 -04:00
parent 482e27e735
commit 68323c0baa
19 changed files with 588 additions and 232 deletions
+4 -4
View File
@@ -18,16 +18,16 @@ export async function GET({ url }: { url: URL }) {
if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready');
const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' },
{ id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
}
+97 -71
View File
@@ -10,7 +10,6 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean }
@@ -19,8 +18,7 @@
let libraries = $state<OverviewLibrary[]>([]); let hosts = $state<Host[]>([]);
let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false);
let showUpload = $state(false); let game = $state<'t5' | 't6'>('t5');
let name = $state(''); let version = $state(''); let description = $state('');
let filterGame = $state('all'); let targets = $state<Record<string, string>>({});
let targets = $state<Record<string, string>>({});
let folder = $state.raw<SelectedGameFolder | null>(null);
let uploadStatus = $state.raw<UploadStatus | null>(null);
let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state('');
@@ -30,12 +28,13 @@
let activeRequest: XMLHttpRequest | null = null; let disposed = false;
const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
const matching = $derived(libraries.filter(library => filterGame === 'all' || library.game === filterGame));
const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0);
const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0);
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';
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
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.';
@@ -61,7 +60,7 @@
busy = true; error = ''; notice = '';
try {
await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) }));
notice = 'Library download queued. Progress will update below.'; await load();
notice = 'Game files queued for download. Progress will update below.'; await load();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
function resetUpload() {
@@ -69,10 +68,9 @@
acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false;
if (folderInput) folderInput.value = '';
}
async function newUpload(library?: OverviewLibrary) {
resetUpload(); showUpload = true; game = library?.game ?? 't5'; name = library?.name ?? '';
description = library?.description ?? ''; version = '';
await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' });
async function newUpload(library: OverviewLibrary) {
resetUpload(); showUpload = true; game = library.game;
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
}
function chooseFolder(event: Event) {
uploadError = ''; folder = null;
@@ -82,14 +80,13 @@
const selected = selectGameFolder(Array.from(input.files));
if (uploadStatus) checkSelection(selected, uploadStatus);
folder = selected;
if (!name && !uploadId) name = game === 't5' ? 'Black Ops I game files' : 'Black Ops II game files';
} catch (cause) { uploadError = (cause as Error).message; input.value = ''; }
}
function checkSelection(selected: SelectedGameFolder, status: UploadStatus) {
const originals = new Map(status.files.map(file => [file.path, file]));
if (selected.files.length !== originals.size || selected.files.some(({ path, file }) => {
const original = originals.get(path); return !original || original.size !== file.size || original.lastModified !== file.lastModified;
})) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Upload changed game files as a new version.');
})) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Start a new upload for changed game files.');
}
function applyStatus(status: UploadStatus) {
uploadStatus = status;
@@ -109,12 +106,12 @@
}
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. Active hosts will download and verify this version automatically.`;
phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`;
if (folderInput) folderInput.value = '';
await load();
}
async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) {
resetUpload(); showUpload = true; uploadId = item.id; game = library.game; name = library.name; version = item.version; description = item.description;
resetUpload(); showUpload = true; uploadId = item.id; game = library.game;
uploadBusy = true;
try {
const status = await getStatus();
@@ -122,7 +119,7 @@
else { phase = 'paused'; uploadError = status.error ?? ''; }
} catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; }
finally { uploadBusy = false; }
await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' });
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
}
function pauseUpload() { pauseRequested = true; activeRequest?.abort(); }
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> {
@@ -158,7 +155,7 @@
uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing';
try {
if (!uploadId) {
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, name, version, description, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
uploadId = result.version.id;
}
const status = await getStatus();
@@ -200,90 +197,119 @@
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page">
<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>
<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 error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">Administrators publish 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}
{#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>
<p>Select your complete T5 or T6 game folder, including its installed DLC. The files upload directly to this controller; a game host is only needed when you are ready to run servers.</p>
<section class="card" id="game-upload">
<div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.</p>
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}>
<fieldset disabled={uploadBusy || !!uploadId} class="fields">
<label>Game<select bind:value={game}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
<label>Library name<input required maxlength="160" bind:value={name} placeholder="Black Ops II game files" /></label>
<label>Version label<input required maxlength="120" bind:value={version} placeholder="2026-10-04" /></label>
<label class="wide">Description <span class="muted">(optional)</span><textarea maxlength="4000" bind:value={description} rows="2"></textarea></label>
</fieldset>
{#if phase !== 'verifying' && phase !== 'published'}
<label class="folder-picker">{uploadId ? 'Select the original unchanged folder' : 'Game folder'}<input bind:this={folderInput} type="file" webkitdirectory multiple onchange={chooseFolder} disabled={uploadBusy} aria-describedby="folder-help" /></label>
<p class="muted" id="folder-help">{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}</p>
{/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 · host downloads are automatic' : '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 files…' : phase === 'published' ? 'Ready · 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}
{#if phase === 'verifying'}<p class="muted">The controller checks every file and publishes this version when verification finishes. Verification continues if you leave this page.</p>{:else if phase === 'paused'}<p class="muted">Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.</p>{/if}
{#if phase === 'verifying'}<p class="muted">The controller checks every file before making this upload available to hosts. Verification continues if you leave this page.</p>{:else if phase === 'paused'}<p class="muted">Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.</p>{/if}
</div>{/if}
<div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button>
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : 'Upload and publish'}</Button>{/if}
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${game.toUpperCase()} files`}</Button>{/if}
{#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
</div>
</form>
</section>
{/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')}
{#if !loaded}<p role="status">Loading game files…</p>{/if}
{#each libraries as library (library.game)}
{@const latest = library.versions.find(item => item.id === library.latestVersionId)}
{@const unfinished = library.versions.filter(item => item.state !== 'ready')}
{@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
<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)}
{@const browserUpload = !item.sourceHostId}
<article class="version">
<div class="heading"><h3>{item.version}</h3><Badge variant={item.state === 'failed' ? 'danger' : item.state === 'ready' ? 'online' : 'info'}>{item.state === 'ready' ? 'Published' : item.state === 'importing' ? browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing' : 'Failed'}</Badge></div>
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}</p>
{#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 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}
{@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>
{#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}
<div class="heading">
<div><h2>{gameName(library.game)}</h2><p class="muted">{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}</p></div>
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
</div>
{#if latest}
<div class="heading"><p>{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}</p><Badge variant="online">Available</Badge></div>
<p class="muted">Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.</p>
{@render hostCopies(latest, true)}
{@render uploadDetails(latest)}
{:else}
<p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
{/if}
{#if unfinished.length}
<div class="uploads">
<h3>Unfinished uploads</h3>
{#each unfinished as item (item.id)}
{@const browserUpload = !item.sourceHostId}
<article class="upload-entry">
<div class="heading"><p>Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
{#if browserUpload}
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</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}
</div>
{/if}
{#if history.length}
<details class="history">
<summary>Previous uploads ({history.length})</summary>
<p class="muted">Previous files remain available for servers that already use them.</p>
{#each history as item (item.id)}
<article class="upload-entry">
<h3>Uploaded {uploadDate(item)}</h3>
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
{@render uploadDetails(item)}
<details><summary>Host copies</summary>{@render hostCopies(item, false)}</details>
</article>
{/each}
</details>
{/if}
</section>
{:else}{#if loaded}<EmptyState title="No game libraries yet" description="Upload a complete T5 or T6 game folder to get started. You can add a host after the files are published." />{:else}<p role="status">Loading game libraries…</p>{/if}{/each}
{/each}
</div>
{#snippet uploadDetails(item: OverviewLibrary['versions'][number])}
<details><summary>Upload details</summary><dl><dt>Upload ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
{/snippet}
{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)}
{#if !hosts.length}<p class="muted">Files are ready. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
{#each item.caches as cache}
{@const cacheHost = hosts.find(host => host.id === cache.hostId)}
{@const awaitingAutomaticDownload = latest && 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' ? ' · previous upload' : ''}{: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>
{#if canManage}<details><summary>Advanced: download or verify these files</summary><p class="muted">Retry a download or prepare a previous upload on a host. The latest files are downloaded 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}
{/snippet}
<style>
.page, .card, .version, form { display:grid; gap:20px; }
.page, .card, .uploads, .upload-entry, form { display:grid; gap:20px; }
.card { padding:24px; min-width:0; border:1px solid var(--border-subtle); background:var(--surface); border-radius:var(--radius-md); }
.version { padding-top:20px; border-top:1px solid var(--border-subtle); }
.heading, .filters, .download, .upload-actions { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
.uploads, .upload-entry { padding-top:20px; border-top:1px solid var(--border-subtle); }
.history>p, .history>.upload-entry { margin-top:16px; }
.heading, .download, .upload-actions { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
.download, .upload-actions { justify-content:flex-start; align-items:end; }
.fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; border:0; padding:0; margin:0; min-width:0; }
.wide { grid-column:1/-1; } label { display:grid; gap:6px; font-size:var(--text-sm); }
input, select, textarea { width:100%; min-width:0; box-sizing:border-box; padding:10px; background:var(--surface); color:inherit; border:1px solid var(--border-strong); border-radius:var(--radius-sm); }
input:disabled, select:disabled, textarea:disabled { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
label { display:grid; gap:6px; font-size:var(--text-sm); }
input, select { width:100%; min-width:0; box-sizing:border-box; padding:10px; background:var(--surface); color:inherit; border:1px solid var(--border-strong); border-radius:var(--radius-sm); }
input:disabled, select:disabled { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
.folder-summary,.upload-progress { padding:20px; display:grid; gap:12px; background:var(--surface-sunken); border:1px solid var(--border-subtle); border-radius:var(--radius-md); min-width:0; }
.folder-summary>span,.upload-progress span,.upload-actions>span { color:var(--text-secondary); font-size:var(--text-sm); }
.path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; }
@@ -293,5 +319,5 @@
small { display:block; } a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; }
.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
@media(max-width:640px) { .fields { grid-template-columns:1fr; } .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }
@media(max-width:640px) { .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }
</style>
+1 -1
View File
@@ -41,7 +41,7 @@
{#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}
<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.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 === '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>