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
+29 -22
View File
@@ -1,7 +1,7 @@
# Electric Cherry # Electric Cherry
Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host
agents. Docker and Plutainer run the games; EC handles hosts, game libraries, agents. Docker and Plutainer run the games; EC handles hosts, game files,
collections, configuration, campaigns, maintenance and operation history. collections, configuration, campaigns, maintenance and operation history.
Komodo is not required for native hosts. Komodo is not required for native hosts.
@@ -29,18 +29,19 @@ The default Compose data mount preserves the previous
`EC_DATA_DIR` to your preferred persistent directory. Back up that directory, `EC_DATA_DIR` to your preferred persistent directory. Back up that directory,
including SQLite metadata and published game-file storage. including SQLite metadata and published game-file storage.
1. **Library → Game Files**: select T5 or T6 and upload the complete local game 1. **Library → Game Files**: upload the complete local folder under T5 or T6,
folder, including its DLC files. Name the library and version, then publish it. including its DLC files. There are no library names or version labels to enter.
Each upload becomes the latest files for that game after verification.
Uploads work before any host is enrolled. Uploads work before any host is enrolled.
2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its 2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its
generated enrollment command. The host automatically prepares its Plutainer generated enrollment command. The host automatically prepares its Plutainer
runtime and the latest published version of every game library. The host page runtime and the latest T5 and T6 files, when available. The host page
shows setup progress. Automatic agent updates are enabled by default. shows setup progress. Automatic agent updates are enabled by default.
3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when 3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when
launching. launching.
4. Launch a server or collection with a host and game-file version. The default 4. Launch a server or collection on a host. The latest files for each game and
runtime is selected automatically. EC reserves host-specific ports and verifies the default runtime are selected automatically. EC reserves host-specific
startup. ports and verifies startup.
See [Native host management](docs/native-hosts.md) for installation, credentials, See [Native host management](docs/native-hosts.md) for installation, credentials,
storage, failure recovery, API contracts and the live-host acceptance checklist. storage, failure recovery, API contracts and the live-host acceptance checklist.
@@ -72,25 +73,31 @@ and safely retires existing servers as they become eligible.
Collections save target maps and lobby counts. Fill missing servers adds only the Collections save target maps and lobby counts. Fill missing servers adds only the
shortfall; Match collection exactly also queues safe removal of excess servers. shortfall; Match collection exactly also queues safe removal of excess servers.
Launch previews select a host, pinned base-game versions and an independent Launch previews use the latest verified files for each game and select an
runtime image. Every new launch uses the latest promoted configuration, scripts independent runtime image. If newer game files become available before launch,
and plugins when execution starts. Saved Plutonium keys are reused by exact refresh the preview to confirm the updated files. Every new launch uses the
server ID and retained after removal. latest promoted configuration, scripts and plugins when execution starts. Saved
Plutonium keys are reused by exact server ID and retained after removal.
## Libraries and campaigns ## Libraries and campaigns
Game-file versions contain the uploaded T5/T6 folder, with its directory layout Game Files has one upload slot each for T5 and T6. Uploads preserve the folder's
and included DLC preserved. There is no separate DLC declaration. Versions record directory layout and included DLC; there is no separate DLC declaration.
the game, label, file manifest, checksums and size. Uploads use bounded chunks, EC records immutable upload IDs, file manifests, checksums and sizes automatically.
support pause/resume and verify checksums before publication. Actual game files Uploads use bounded chunks, support pause/resume and verify checksums before
use dedicated filesystem storage, never SQLite blobs. Publishing a new version publication. Actual game files
automatically prepares it on active hosts, including hosts enrolled later. Host use dedicated filesystem storage, never SQLite blobs. The last upload to finish
downloads transfer up to four files concurrently, resume interrupted transfers, verification becomes that game's latest files, even if it started earlier.
Active hosts always prepare the latest T5 and T6 files when available, including
hosts enrolled later. Host downloads transfer up to four files concurrently,
resume interrupted transfers,
report progress and verify checksums before becoming usable. Failed preparation report progress and verify checksums before becoming usable. Failed preparation
retries automatically with backoff; the host page reports any blocking condition. retries automatically with backoff; the host page reports any blocking condition.
Set `PLUTAINER_IMAGE` to change the default runtime prepared on hosts. Existing Set `PLUTAINER_IMAGE` to change the default runtime prepared on hosts. Existing
servers retain their selected library version and runtime. servers retain their existing files and runtime without a forced restart.
Multiple instances on one host share read-only base files. Previous uploads, verified host copies and server references are preserved.
Existing named libraries appear together under their game, with no renaming or
reupload required. Multiple instances on one host share read-only base files.
Scripts, plugins, configuration baselines and overlays retain their versioning, Scripts, plugins, configuration baselines and overlays retain their versioning,
beta, promotion and rollback workflows. Campaigns stage bytes remotely without beta, promotion and rollback workflows. Campaigns stage bytes remotely without
@@ -101,7 +108,7 @@ operation history.
T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded `dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
after the main config. T6 variants retain their distinct catalog selections. after the main config. T6 variants retain their distinct catalog selections.
Game-file libraries and Plutonium runtime revisions are tracked independently. Game-file uploads and Plutonium runtime revisions are tracked independently.
Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it
in an authorization header. Anonymous reports are rejected before they can in an authorization header. Anonymous reports are rejected before they can
@@ -147,7 +154,7 @@ exercising enrollment. Production agent transport requires HTTPS.
`src/lib/server/hosts` owns enrollment, readiness and command transport; `src/lib/server/hosts` owns enrollment, readiness and command transport;
`src/lib/server/backends` provides instance identity and remote file access; `src/lib/server/backends` provides instance identity and remote file access;
`src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign `src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign
queues to agents; `src/lib/server/game-files` owns immutable libraries and queues to agents; `src/lib/server/game-files` owns immutable game-file uploads and
streaming distribution; `agent/` contains the service, installer and agent tests. streaming distribution; `agent/` contains the service, installer and agent tests.
Automated tests use simulated Docker/game hosts. Real Debian installer and Automated tests use simulated Docker/game hosts. Real Debian installer and
+46 -24
View File
@@ -18,17 +18,19 @@ the agent owns local execution and its durable operation journal.
installation, verifies the downloaded agent and updater SHA-256 over HTTPS, installation, verifies the downloaded agent and updater SHA-256 over HTTPS,
enrolls it and enables `electric-cherry-agent.service` and its automatic update enrolls it and enables `electric-cherry-agent.service` and its automatic update
timer. It needs no Git, Node.js or local build. timer. It needs no Git, Node.js or local build.
4. In **Game Files**, select T5 or T6 and upload your local game folder. Name the 4. In **Game Files**, upload your complete local folder under T5 or T6. Include
library and version and publish the DLC files in that folder; there is no separate DLC declaration, library
it after the upload finishes. Include the DLC files in that folder; there is name or version label. The upload becomes the latest files for that game after
no separate DLC declaration. Uploading also works before enrolling any host. verification. Uploading also works before enrolling any host.
5. Hosts automatically prepare the configured Plutainer runtime and the latest 5. Hosts automatically prepare the configured Plutainer runtime and the latest
published version of every game library. **Automatic host setup** reports verified T5 and T6 files whenever available. **Automatic host setup** reports
progress and errors. Wait for verification before launching. Hosts report progress and errors. Wait for verification before launching. Hosts report
connection and readiness separately; deployment also checks the particular connection and readiness separately; deployment also checks the particular
library, image, capacity and ports you selected. game files, image, capacity and ports required for the launch.
6. Launch a server or collection. Select the host, immutable game-file version(s) 6. Launch a server or collection. Select the host; the latest game files and
and use the automatically selected default runtime, or an advanced override. default runtime are selected automatically. An advanced runtime override is
available. If the latest files change after previewing, refresh the preview
before launching.
EC selects lobby IDs and reserves UDP/TCP ports atomically. EC selects lobby IDs and reserves UDP/TCP ports atomically.
Saved Plutonium server keys remain associated with their exact server names. Saved Plutonium server keys remain associated with their exact server names.
@@ -37,19 +39,25 @@ copying or per-container setup. EC does not supply the game content itself.
The controller reconciles automatic setup on agent heartbeats and operation The controller reconciles automatic setup on agent heartbeats and operation
polls. It first pulls `PLUTAINER_IMAGE` (default polls. It first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the newest published version of `ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for
each library, one setup operation at a time. Publishing more files or enrolling T5 and for T6 when available, one setup operation at a time. Uploading new files
another host needs no manual propagation. Setup waits for an active, connected, or enrolling another host needs no manual propagation. Setup waits for an active, connected,
healthy host with sufficient storage. Failed operations retry automatically with healthy host with sufficient storage. Failed operations retry automatically with
backoff from one minute to fifteen minutes; their errors remain visible in the backoff from one minute to fifteen minutes; their errors remain visible in the
host's setup panel and operation history. Disconnected hosts resume when they host's setup panel and operation history. Disconnected hosts resume when they
reconnect. Draining and disabled hosts do not receive new automatic setup work. reconnect. Draining and disabled hosts do not receive new automatic setup work.
Verified older versions remain cached and running servers keep their selected An automatic download that has not been delivered to the agent can be replaced
files and runtime. Automatic setup ensures that the configured image is present; when a newer upload becomes available. A delivered operation finishes safely
it does not refresh an already cached mutable image tag. Advanced controls on the before the host prepares the latest files. Failed or incomplete uploads never
replace the latest verified files.
Verified previous uploads remain cached and running servers keep their existing
files and runtime; uploading new files does not restart servers. Automatic setup
ensures that the configured image is present; it does not refresh an already
cached mutable image tag. Advanced controls on the
host and game-file pages can explicitly pull a custom runtime, prepare an older host and game-file pages can explicitly pull a custom runtime, prepare an older
library version or retry preparation immediately. upload or retry preparation immediately.
The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes
it. Use a dedicated, root-owned directory. For a separate disk, use a child of its it. Use a dedicated, root-owned directory. For a separate disk, use a child of its
@@ -160,8 +168,13 @@ API outline:
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result. - `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
- `POST /api/agent/decommission/:id`: cleanup worker reports file removal and - `POST /api/agent/decommission/:id`: cleanup worker reports file removal and
agent uninstall using its own operation-scoped receipt credential. agent uninstall using its own operation-scoped receipt credential.
- `POST /api/game-files`: administrator creates a folder upload with game, name, - `GET /api/game-files`: returns `{libraries, hosts}`. `libraries` always contains
version, optional description and `files: [{path, size, lastModified}]`. two game slots, T5 and T6, including empty slots. Each slot has a nullable
`latestVersionId` identifying its latest verified upload, plus upload history.
- `POST /api/game-files`: administrator creates a folder upload with
`{game: "t5" | "t6", files: [{path, size, lastModified}]}`. Upload IDs and
internal revision metadata are generated automatically; no library name,
version label or description is required.
- `GET /api/game-files/:id/upload`: durable per-file offsets and publication status. - `GET /api/game-files/:id/upload`: durable per-file offsets and publication status.
- `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk - `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk
with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers. with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers.
@@ -243,7 +256,13 @@ retries the completion receipt from memory. A power loss in that final window
can leave the controller awaiting confirmation even though removal succeeded; can leave the controller awaiting confirmation even though removal succeeded;
inspect the machine if that status persists. The controller does not guess. inspect the machine if that status persists. The controller does not guess.
## Libraries and ports ## Game files and ports
Game Files always shows T5 and T6, each with its own upload button and latest
verified files. Successful uploads become latest in publication completion
order, not by their start date or an entered label. A newer upload still being
transferred or verified leaves the current files available. Previous uploads
appear in history, and interrupted uploads can be resumed separately.
Choose the actual game folder, not its parent, a ZIP archive or the Plutonium Choose the actual game folder, not its parent, a ZIP archive or the Plutonium
launcher data folder. The selected folder's name is removed once; nested paths launcher data folder. The selected folder's name is removed once; nested paths
@@ -262,23 +281,26 @@ unchanged folder; relative paths, sizes and modification times must match. Keep
the page open while transferring. Once publication starts, the controller hashes the page open while transferring. Once publication starts, the controller hashes
every complete file and publishes the directory atomically; closing the browser every complete file and publishes the directory atomically; closing the browser
does not stop that verification. An interrupted controller can retry publication. does not stop that verification. An interrupted controller can retry publication.
Published versions cannot be updated; Verified uploads are immutable; changing files means uploading another folder
changing files requires a new version. Hosts download up to four files for the same game. Hosts download up to four files concurrently with HTTP Range
concurrently with HTTP Range resumption, report progress, verify all checksums resumption, report progress, verify all checksums
and atomically promote the complete cache. Checksums are calculated while bytes and atomically promote the complete cache. Checksums are calculated while bytes
arrive, avoiding another complete read of newly downloaded files. Resumed files arrive, avoiding another complete read of newly downloaded files. Resumed files
include their saved prefix in verification. Progress reports are rate-limited so include their saved prefix in verification. Progress reports are rate-limited so
controller round trips do not stall every transferred chunk. controller round trips do not stall every transferred chunk.
Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled
on heartbeats, including vanished caches. Older immutable versions remain usable on heartbeats, including vanished caches. Previous immutable uploads remain usable
when their exact manifest is verified. when their exact manifest is verified.
The controller and reverse proxy must accept 8 MiB upload requests and folder The controller and reverse proxy must accept 8 MiB upload requests and folder
manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes
still enforce these limits, declared file sizes and checksums. Set a proxy body still enforce these limits, declared file sizes and checksums. Set a proxy body
limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx).
Existing published versions and cached host libraries remain usable after the Existing uploads, host caches and server references remain usable. Previously
upload workflow replaces source-host imports. named libraries are grouped under T5 or T6, and the most recently published
upload for each game becomes its latest files. Their immutable IDs and stored
content are preserved; no reupload is needed. Legacy source-host imports retain
their existing IDs and can complete through their original workflow.
Reservations include host ID, local bind address, protocol and port. Wildcard Reservations include host ID, local bind address, protocol and port. Wildcard
binds conflict with concrete addresses on the same host. UDP and TCP reservations binds conflict with concrete addresses on the same host. UDP and TCP reservations
+5 -1
View File
@@ -1,4 +1,8 @@
export type GameLibraryGame = 't5' | 't6'; export type GameLibraryGame = 't5' | 't6';
export const GAME_FILE_GAMES = [
{ id: 't5', name: 'Black Ops I (T5)' },
{ id: 't6', name: 'Black Ops II (T6)' }
] as const;
export type GameLibraryState = 'importing' | 'ready' | 'failed'; export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
@@ -18,7 +22,7 @@ export interface GameLibraryVersion {
} }
export interface GameLibrary { export interface GameLibrary {
id: string; name: string; game: GameLibraryGame; description: string; id: string; name: string; game: GameLibraryGame; description: string;
createdAt: string; versions: GameLibraryVersion[]; createdAt: string; latestVersionId: string | null; versions: GameLibraryVersion[];
} }
export function formatGameBytes(bytes: number): string { export function formatGameBytes(bytes: number): string {
+36 -36
View File
@@ -7,6 +7,7 @@ import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hos
import { ensureGameFileSchema } from './schema.js'; import { ensureGameFileSchema } from './schema.js';
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js'; import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js'; import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js';
import { GAME_FILE_GAMES } from '../../game-files.js';
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js'; import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; } function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
@@ -35,23 +36,29 @@ export function getLibraryFile(versionId: string, path: string) {
.get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null; .get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null;
} }
export function newestReadyLibrary(game: string): GameLibraryVersion | null { export function newestReadyLibrary(game: string): GameLibraryVersion | null {
const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined; const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.published_at DESC, v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined;
return row ? versionFromRow(row) : null; return row ? versionFromRow(row) : null;
} }
/** Preparation needs only the latest published version of each library, never its full manifest. */ /** Hosts follow the latest successful publication for each game, including legacy libraries. */
export function latestPublishedLibraries() { export function latestPublishedLibraries() {
return database().prepare(`SELECT v.id, l.name, l.game, v.version, v.size_bytes AS sizeBytes, v.manifest_sha256 AS manifestSha256 const latest = database().prepare(`SELECT v.id, l.game, v.version, v.size_bytes AS sizeBytes, v.manifest_sha256 AS manifestSha256, v.published_at AS publishedAt
FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
WHERE v.state='ready' AND v.id=(SELECT newest.id FROM game_file_versions newest WHERE v.state='ready' AND l.game=?
WHERE newest.library_id=l.id AND newest.state='ready' ORDER BY newest.created_at DESC,newest.rowid DESC LIMIT 1) ORDER BY v.published_at DESC,v.created_at DESC,v.rowid DESC LIMIT 1`);
ORDER BY l.name`).all() as { id: string; name: string; game: GameLibraryGame; version: string; sizeBytes: number; manifestSha256: string }[]; return GAME_FILE_GAMES.flatMap(game => {
const version = latest.get(game.id) as { id: string; game: GameLibraryGame; version: string; sizeBytes: number; manifestSha256: string; publishedAt: string | null } | undefined;
return version ? [{ ...version, name: game.name }] : [];
});
} }
export function listLibraries(): GameLibrary[] { export function listLibraries(): GameLibrary[] {
reconcileLibraryOperations(); reconcileLibraryOperations();
return (database().prepare('SELECT * FROM game_file_libraries ORDER BY name').all() as Array<{ id: string; name: string; game: GameLibraryGame; description: string; created_at: string }>).map(row => ({ const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id]));
id: row.id, name: row.name, game: row.game, description: row.description, createdAt: row.created_at, return GAME_FILE_GAMES.map(game => {
versions: (database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.id=? ORDER BY v.created_at DESC, v.rowid DESC').all(row.id) as VersionRow[]).map(versionFromRow) const versions = (database().prepare(`SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
})); WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(versionFromRow);
const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined;
return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions };
});
} }
export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null { export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null {
const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined; const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined;
@@ -70,27 +77,29 @@ function textField(input: unknown, label: string, max: number, required = true):
return input.trim(); return input.trim();
} }
/** Reuse a game's original storage parent without rewriting immutable historical versions. */
function libraryForGame(game: GameLibraryGame, now: string): string {
const handle = database();
const existing = handle.prepare('SELECT id FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game) as { id: string } | undefined;
if (existing) return existing.id;
const id = randomUUID();
// The legacy name column is globally unique, so its private value must not collide with an old user label.
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(id, `game-files:${game}:${id}`, game, '', now);
return id;
}
/** Register a folder on the controller; no host or Docker volume is needed. */ /** Register a folder on the controller; no host or Docker volume is needed. */
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion { export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game; const game = input.game;
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
const name = textField(input.name, 'library name', 160);
const description = textField(input.description ?? '', 'description', 4000, false);
const version = textField(input.version, 'version label', 120);
const files = validateUploadPlan(input.files); const files = validateUploadPlan(input.files);
const handle = database(); const handle = database();
const id = randomUUID(); const now = new Date().toISOString(); const id = randomUUID(); const now = new Date().toISOString();
handle.transaction(() => { handle.transaction(() => {
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; const libraryId = libraryForGame(game, now);
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
if (!library) {
library = { id: randomUUID(), game };
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
}
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
// Keep legacy non-null columns for existing databases and published versions. // Keep legacy non-null columns for existing databases and published versions.
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume) handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume)
VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, library.id, version, description, files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id)); VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, libraryId, id, '', files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id));
const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)'); const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)');
for (const file of files) insert.run(id, file.path, file.size, file.lastModified); for (const file of files) insert.run(id, file.path, file.size, file.lastModified);
})(); })();
@@ -181,22 +190,13 @@ export function createLibraryImport(input: Record<string, unknown>): GameLibrary
const hostId = textField(input.hostId, 'source host', 128); const hostId = textField(input.hostId, 'source host', 128);
const host = getHost(hostId); const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.'); if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
const name = textField(input.name, 'library name', 160);
const description = textField(input.description ?? '', 'description', 4000, false);
const version = textField(input.version, 'version label', 120);
const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files'; const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files';
const handle = database(); const handle = database();
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
const id = randomUUID(); const now = new Date().toISOString(); const id = randomUUID(); const now = new Date().toISOString();
handle.transaction(() => { handle.transaction(() => {
if (!library) { const libraryId = libraryForGame(game, now);
library = { id: randomUUID(), game };
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
}
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume) handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume); VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, libraryId, id, '', '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
})(); })();
try { try {
const operation = queueHostOperation(hostId, 'library.import', { const operation = queueHostOperation(hostId, 'library.import', {
@@ -318,7 +318,7 @@ export function updateHostLibraryCaches(hostId: string, input: unknown, options:
})(); })();
} }
export function queueLibraryDownload(hostId: string, versionId: string) { export function queueLibraryDownload(hostId: string, versionId: string, options: { automatic?: boolean } = {}) {
const version = getLibraryVersion(versionId); const version = getLibraryVersion(versionId);
if (!version || version.state !== 'ready') throw new Error('Choose a published library version.'); if (!version || version.state !== 'ready') throw new Error('Choose a published library version.');
const host = getHost(hostId); const host = getHost(hostId);
@@ -333,7 +333,8 @@ export function queueLibraryDownload(hostId: string, versionId: string) {
const operation = queueHostOperation(hostId, 'library.download', { const operation = queueHostOperation(hostId, 'library.download', {
libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`, libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`,
filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`, filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`,
manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes,
...(options.automatic ? { automatic: true } : {})
}); });
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256 }]); updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256 }]);
database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId); database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId);
@@ -358,8 +359,7 @@ export function libraryOverview() {
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({ return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
...version, caches: hosts.map(host => { ...version, caches: hosts.map(host => {
const cached = getHostLibraryCache(host.id, version.id); const cached = getHostLibraryCache(host.id, version.id);
const latest = library.versions.find(item => item.state === 'ready'); if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status };
if (cached) return { ...cached, status: cached.status === 'ready' && latest?.id !== version.id ? 'outdated' as const : cached.status };
return { return {
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0, hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null
+25 -5
View File
@@ -5,6 +5,7 @@ import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } f
import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js'; import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js';
const activeStates = ['pending', 'accepted', 'running', 'unknown']; const activeStates = ['pending', 'accepted', 'running', 'unknown'];
const supersededDownload = 'Superseded by newer game files before delivery to the host.';
type PreparationOperation = { id: string; state: HostOperationState; updated_at: string; error: string | null }; type PreparationOperation = { id: string; state: HostOperationState; updated_at: string; error: string | null };
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation }; type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation };
@@ -53,8 +54,8 @@ function targets(host: Host): Target[] {
const ready = matches && ['ready', 'outdated'].includes(cache!.status) && cache!.bytesDownloaded === version.sizeBytes; const ready = matches && ['ready', 'outdated'].includes(cache!.status) && cache!.bytesDownloaded === version.sizeBytes;
result.push({ versionId: version.id, requiredBytes: version.sizeBytes - (matches ? cache!.bytesDownloaded : 0) + host.minimumFreeBytes, result.push({ versionId: version.id, requiredBytes: version.sizeBytes - (matches ? cache!.bytesDownloaded : 0) + host.minimumFreeBytes,
operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id), operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id),
item: { kind: 'library', label: `${version.name} · ${version.version}`, state: ready ? 'ready' : 'waiting', item: { kind: 'library', label: version.name, state: ready ? 'ready' : 'waiting',
detail: ready ? 'Game files are downloaded and verified.' : 'Game files will be downloaded and verified automatically.', detail: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.',
bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes } }); bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes } });
} }
const blocker = hostBlocker(host); const blocker = hostBlocker(host);
@@ -82,7 +83,7 @@ function targets(host: Host): Target[] {
export function hostPreparationStatus(host: Host): HostPreparation { export function hostPreparationStatus(host: Host): HostPreparation {
const items = targets(host).map(target => target.item); const items = targets(host).map(target => target.item);
if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting', if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting',
detail: 'Upload and publish a game-file library once; hosts download it automatically.' }); detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' });
const state = items.some(item => item.state === 'blocked') ? 'blocked' const state = items.some(item => item.state === 'blocked') ? 'blocked'
: items.some(item => item.state === 'preparing') ? 'preparing' : items.some(item => item.state === 'preparing') ? 'preparing'
: items.some(item => item.state === 'waiting') ? 'waiting' : 'ready'; : items.some(item => item.state === 'waiting') ? 'waiting' : 'ready';
@@ -97,25 +98,44 @@ export function presentHostPreparation(host: Host): Host {
return { ...host, preparation }; return { ...host, preparation };
} }
function supersedeUndeliveredDownloads(hostId: string): void {
const latest = new Set(latestPublishedLibraries().map(version => version.id));
const pending = db().prepare(`SELECT id,json_extract(payload,'$.libraryVersionId') AS versionId
FROM ec_host_operations WHERE host_id=? AND kind='library.download' AND state='pending'
AND last_delivered_at IS NULL AND json_extract(payload,'$.automatic')=1`).all(hostId) as { id: string; versionId: string }[];
const now = new Date().toISOString();
for (const operation of pending) {
if (latest.has(operation.versionId)) continue;
// Delivered operations may already be writing files. Only unsubmitted
// automatic work can be replaced without consulting the agent journal.
db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(supersededDownload, now, operation.id);
db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)").run(operation.id, now, supersededDownload);
db().prepare("UPDATE host_library_caches SET status='missing',error=NULL,updated_at=? WHERE host_id=? AND version_id=? AND operation_id=? AND status NOT IN ('ready','outdated')")
.run(now, hostId, operation.versionId, operation.id);
}
}
/** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */ /** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */
export function reconcileHostPreparation(hostId: string): HostPreparation { export function reconcileHostPreparation(hostId: string): HostPreparation {
return db().transaction(() => { return db().transaction(() => {
const host = getHost(hostId); const host = getHost(hostId);
if (!host) throw new Error('Host was not found.'); if (!host) throw new Error('Host was not found.');
if (!hostBlocker(host)) { if (!hostBlocker(host)) {
supersedeUndeliveredDownloads(hostId);
// Queue only one setup mutation at a time so each capacity check uses // Queue only one setup mutation at a time so each capacity check uses
// fresh disk inventory. This also respects manually queued downloads. // fresh disk inventory. This also respects manually queued downloads.
const active = db().prepare(`SELECT 1 FROM ec_host_operations WHERE host_id=? const active = db().prepare(`SELECT 1 FROM ec_host_operations WHERE host_id=?
AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId); AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId);
const finished = db().prepare(`SELECT updated_at FROM ec_host_operations WHERE host_id=? const finished = db().prepare(`SELECT updated_at FROM ec_host_operations WHERE host_id=?
AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('completed','failed') AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('completed','failed')
ORDER BY updated_at DESC LIMIT 1`).get(hostId) as { updated_at: string } | undefined; AND (error IS NULL OR error != ?)
ORDER BY updated_at DESC LIMIT 1`).get(hostId, supersededDownload) as { updated_at: string } | undefined;
if (!active && (!finished || Date.parse(host.lastHeartbeat!) > Date.parse(finished.updated_at))) { if (!active && (!finished || Date.parse(host.lastHeartbeat!) > Date.parse(finished.updated_at))) {
// A failed or oversized library must not prevent other libraries // A failed or oversized library must not prevent other libraries
// from preparing. The runtime still comes first when eligible. // from preparing. The runtime still comes first when eligible.
const target = targets(host).find(target => target.item.state === 'waiting'); const target = targets(host).find(target => target.item.state === 'waiting');
if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image }); if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image });
else if (target?.versionId) queueLibraryDownload(host.id, target.versionId); else if (target?.versionId) queueLibraryDownload(host.id, target.versionId, { automatic: true });
} }
} }
return hostPreparationStatus(host); return hostPreparationStatus(host);
+7 -5
View File
@@ -4,7 +4,7 @@ import { db } from '../db/index.js';
import { config, relDestForKind, serverDir } from '../config.js'; import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js'; import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js'; import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js';
import { getLibraryVersion, requireReadyLibrary } from '../game-files/index.js'; import { newestReadyLibrary, requireReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js'; import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { getServerKey, saveServerKey } from '../fleet/server-keys.js'; import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js'; import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
@@ -29,9 +29,11 @@ interface FilePlan { id: number; path: string; contentBase64: string | null; sha
export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection { export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection {
if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.'); if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.');
if (typeof input.libraryId !== 'string' || !input.libraryId) throw new Error('Select a published game-file version.'); const version = newestReadyLibrary(game);
const version = getLibraryVersion(input.libraryId); if (!version) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`);
if (!version || version.game !== game || version.state !== 'ready') throw new Error('Choose a published game-file version for the selected game.'); if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== version.id) {
throw new Error(`The current ${game.toUpperCase()} game files changed. Refresh the launch preview.`);
}
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image; const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.'); if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.');
const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 }); const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 });
@@ -68,7 +70,7 @@ export function previewNativeCreation(input: Record<string, unknown>): LaunchPre
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName); .run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
const checks: LaunchPreview['checks'] = [ const checks: LaunchPreview['checks'] = [
{ label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` }, { label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` },
{ label: 'Game files', state: 'pass', detail: `Immutable library version ${selection.libraryId} is ready on the selected host.` }, { label: 'Game files', state: 'pass', detail: `The latest ${recipe.game.toUpperCase()} game files are verified on the selected host.` },
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' }, { label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' },
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' }, { label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
{ label: 'Configuration', state: 'pass', detail: recipe.configSource } { label: 'Configuration', state: 'pass', detail: recipe.configSource }
@@ -31,7 +31,7 @@
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0); const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0) let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); && (!preview.servers.length || hostReady) && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => { $effect(() => {
if (!dialog) return; if (!dialog) return;
@@ -54,7 +54,7 @@
} }
async function review() { async function review() {
if (!hostReady) { error = 'Choose a ready host and cached game files for each game in this collection.'; return; } if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
const current = ++session; const current = ++session;
const previous = preview; const previous = preview;
busy = true; error = ''; keyNotice = ''; confirmRemovals = false; busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
@@ -102,10 +102,10 @@
<option value="exact">Match collection exactly</option> <option value="exact">Match collection exactly</option>
</select> </select>
</label> </label>
{#if games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if} {#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p> <p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if} {#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Choose a host. Its latest game files sync automatically before you review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
{:else} {:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if} {#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if} {#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
@@ -145,7 +145,7 @@
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div> <div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
</form> </form>
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if} {:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, immutable game-file version, and prepared runtime. Host readiness, cached files, runtime availability, and local port reservations are checked again before deployment.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details> <details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, latest game files, and prepared runtime. Host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if} {/if}
</div> </div>
</dialog> </dialog>
@@ -36,6 +36,7 @@
let wasOpen = false; let wasOpen = false;
let session = 0; let session = 0;
let keyNotice = $state(''); let keyNotice = $state('');
let selectionNotice = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
let heading: HTMLHeadingElement; let heading: HTMLHeadingElement;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now); const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
@@ -49,7 +50,7 @@
async function initialize() { async function initialize() {
const current = ++session; const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId; blueprintId = initialBlueprintId;
game = initialGame; game = initialGame;
@@ -74,15 +75,20 @@
} catch (err) { if (current === session) error = (err as Error).message; } } catch (err) { if (current === session) error = (err as Error).message; }
} }
function selectionChanged() {
if (preview) selectionNotice = 'The host or game files changed. Review the launch again with the latest files.';
preview = null; plutoKey = ''; keyNotice = '';
}
async function review() { async function review() {
if (busy) return; if (busy) return;
if (!sourceDeploymentId && selectedBlueprint) { if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return; open = false; collectionOpen = true; return;
} }
if (!hostReady) { error = 'Choose a ready host, cached game-file version, and prepared runtime.'; return; } if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
const current = session; const current = session;
const previousName = preview?.name; const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = ''; busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = '';
try { try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }), body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
@@ -101,7 +107,7 @@
} }
async function launch() { async function launch() {
if (!preview || !preview.ready || expired || busy) return; if (!preview || !preview.ready || !hostReady || expired || busy) return;
busy = true; error = ''; busy = true; error = '';
try { try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -135,6 +141,8 @@
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header> <header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol> <ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
{#if error}<div class="error" role="alert">{error}</div>{/if} {#if error}<div class="error" role="alert">{error}</div>{/if}
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
{#if !loaded} {#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p> <p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if} {#if error}<Button onclick={initialize}>Try again</Button>{/if}
@@ -150,7 +158,7 @@
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p> {#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if} {:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p> <p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if} {#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p> <p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
</form> </form>
@@ -160,13 +168,12 @@
{/if} {/if}
</details> </details>
{:else if sourceDeploymentId} {:else if sourceDeploymentId}
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network /><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button> <p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}> <form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label> <label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section> {#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if} {:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
{#if !selectedBlueprint}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network />{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div> <div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
</form> </form>
+2 -2
View File
@@ -5,7 +5,7 @@
</script> </script>
<div class="preparation" aria-live="polite"> <div class="preparation" aria-live="polite">
<p>The default runtime and the latest published version of each game library are prepared automatically on active hosts. New versions leave running servers on their selected files.</p> <p>The default runtime and the latest available T5 and T6 game files are prepared automatically on active hosts. Running servers keep their current files.</p>
{#if host.preparation} {#if host.preparation}
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p> <p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
<ul> <ul>
@@ -21,7 +21,7 @@
{/each} {/each}
</ul> </ul>
{:else}<p>Setup progress will appear after the host connects.</p>{/if} {:else}<p>Setup progress will appear after the host connects.</p>{/if}
<p><a href="/game-files">Upload and view game-file libraries →</a></p> <p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
</div> </div>
<style> <style>
@@ -5,38 +5,33 @@
import type { GameLibrary } from '$lib/game-files.js'; import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js'; import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles } from '$ui/native-game-files.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }: let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props(); { games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state(''); let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
let defaultRuntimeImage = $state(''); let defaultRuntimeImage = $state('');
let runtimeSelection: NativeDeploymentSelection | null = null; let runtimeSelection: NativeDeploymentSelection | null = null;
const host = $derived(hosts.find(item => item.id === selection.hostId)); const host = $derived(hosts.find(item => item.id === selection.hostId));
// Keep each library's server ordering, including its tie breaker for versions const latest = $derived(latestGameFiles(libraries));
// created together. Publication completion time does not choose auto setup.
const versions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').map(version => ({ ...version, name: library.name }))).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
const latestVersions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').slice(0, 1)).sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image)); const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image));
function cacheReady(versionId: string | undefined) { return !!versionId && versions.some(version => version.id === versionId && version.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status))); } function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status)); }
$effect(() => { ready = !loading && !error && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => cacheReady(selection.libraryIds[game])); }); $effect(() => { ready = !loading && !error && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => selection.libraryIds[game] === latest[game]?.id && cacheReady(game)); });
$effect(() => { $effect(() => {
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return; if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
if (!selection.image) selection.image = defaultRuntimeImage; if (!selection.image) selection.image = defaultRuntimeImage;
runtimeSelection = selection; runtimeSelection = selection;
}); });
$effect(() => { $effect(() => {
if (!host || disabled) return; // Keep an in-flight preview/launch request stable. Reconcile as soon as
let changed = false; // it finishes, so a newly uploaded folder invalidates its old preview.
for (const game of games) { if (loading || error || disabled) return;
if (selection.libraryIds[game]) continue; if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
const latest = latestVersions.find(version => version.game === game);
if (latest) { selection.libraryIds[game] = latest.id; changed = true; }
}
if (changed) onchange?.();
}); });
async function load() { async function load() {
try { try {
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]); const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game libraries.'); if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = ''; const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? ''; defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
} catch (cause) { error = (cause as Error).message; } finally { loading = false; } } catch (cause) { error = (cause as Error).message; } finally { loading = false; }
@@ -47,18 +42,29 @@
<section class="selection" aria-label="Deployment host and game files"> <section class="selection" aria-label="Deployment host and game files">
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div> <div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and libraries…</p>{/if} {#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
<label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.mode !== 'active' || !!candidate.decommission || !candidate.credentialActive}>{candidate.name} · {candidate.connectionState} · {candidate.preparation?.state === 'preparing' ? 'Preparing automatically' : hostReadinessLabel(candidate.readiness)}</option>{/each}</select></label> <label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.mode !== 'active' || !!candidate.decommission || !candidate.credentialActive}>{candidate.name} · {candidate.connectionState} · {candidate.preparation?.state === 'preparing' ? 'Preparing automatically' : hostReadinessLabel(candidate.readiness)}</option>{/each}</select></label>
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and published game files will be prepared automatically.</p>{/if} {#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if} {#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
{#if host?.preparation && host.preparation.state !== 'ready'}<p class:error={host.preparation.state === 'blocked'}>{host.preparation.items.find(item => item.state === 'blocked' || item.state === 'preparing' || item.state === 'waiting')?.detail} <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View automatic setup</a></p>{/if} {#if host?.preparation && host.preparation.state !== 'ready'}<p class:error={host.preparation.state === 'blocked'}>{host.preparation.items.find(item => item.state === 'blocked' || item.state === 'preparing' || item.state === 'waiting')?.detail} <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View automatic setup</a></p>{/if}
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Awaiting host copy'}</option>{/each}</select></label>{#if host && selection.libraryIds[game] && !cacheReady(selection.libraryIds[game])}<p>The selected files must finish preparation before launch. The latest published versions download automatically; older versions can be prepared from <a href="/game-files" target="_blank" rel="noopener">Game Files</a>.</p>{/if}{/each} {#if !loading}{#each games as game}
<p><a href="/game-files" target="_blank" rel="noopener">Publish a game folder</a> to make it available automatically on hosts. Each launch stays pinned to its selected version. Setup progress refreshes automatically.</p> <div class="game-files">
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
{#if latest[game]}
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
<p role="status">{!host ? 'Choose a host to check its files.' : cacheReady(game) ? 'Latest files ready on this host.' : 'Waiting for this host to sync the latest files automatically.'}</p>
{:else}
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
{/if}
</div>
{/each}{/if}
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must be prepared from the host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details> <details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must be prepared from the host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
{#if host && !imageReady}<p>Waiting for runtime preparation. <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View host setup</a>.</p>{/if} {#if host && !imageReady}<p>Waiting for runtime preparation. <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View host setup</a>.</p>{/if}
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if} {#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
</section> </section>
<style> <style>
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; } .selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
</style> </style>
+26
View File
@@ -0,0 +1,26 @@
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
/** Use the controller's latest upload, even while that upload is still syncing to a host. */
export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles {
const latest: LatestGameFiles = {};
for (const library of libraries) {
const version = library.versions.find(item => item.id === library.latestVersionId && item.state === 'ready' && item.game === library.game);
if (version) latest[library.game] = version;
}
return latest;
}
/** Return whether callers need to discard a preview that targeted different files. */
export function selectLatestGameFiles(selection: Partial<Record<GameLibraryGame, string>>, latest: LatestGameFiles, games: GameLibraryGame[]): boolean {
let changed = false;
for (const game of games) {
const id = latest[game]?.id;
if (selection[game] === id) continue;
if (id) selection[game] = id;
else delete selection[game];
changed = true;
}
return changed;
}
+4 -4
View File
@@ -18,16 +18,16 @@ export async function GET({ url }: { url: URL }) {
if (hosts.length || !komodoConfigured()) { if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready'); const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected); 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 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 runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [ 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: '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: '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: '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: '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: '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: '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' } }); return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
} }
+88 -62
View File
@@ -10,7 +10,6 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.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'>[] }; type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean } 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 libraries = $state<OverviewLibrary[]>([]); let hosts = $state<Host[]>([]);
let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false); 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 showUpload = $state(false); let game = $state<'t5' | 't6'>('t5');
let name = $state(''); let version = $state(''); let description = $state(''); let targets = $state<Record<string, string>>({});
let filterGame = $state('all'); let targets = $state<Record<string, string>>({});
let folder = $state.raw<SelectedGameFolder | null>(null); let folder = $state.raw<SelectedGameFolder | null>(null);
let uploadStatus = $state.raw<UploadStatus | null>(null); let uploadStatus = $state.raw<UploadStatus | null>(null);
let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state(''); let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state('');
@@ -30,12 +28,13 @@
let activeRequest: XMLHttpRequest | null = null; let disposed = false; let activeRequest: XMLHttpRequest | null = null; let disposed = false;
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled')); 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 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 totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0);
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes)); const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading')); 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 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 { function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.'; if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.'; if (!host?.enrolled) return 'Waiting for host enrollment.';
@@ -61,7 +60,7 @@
busy = true; error = ''; notice = ''; busy = true; error = ''; notice = '';
try { try {
await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) })); 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; } } catch (cause) { error = (cause as Error).message; } finally { busy = false; }
} }
function resetUpload() { function resetUpload() {
@@ -69,10 +68,9 @@
acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false; acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false;
if (folderInput) folderInput.value = ''; if (folderInput) folderInput.value = '';
} }
async function newUpload(library?: OverviewLibrary) { async function newUpload(library: OverviewLibrary) {
resetUpload(); showUpload = true; game = library?.game ?? 't5'; name = library?.name ?? ''; resetUpload(); showUpload = true; game = library.game;
description = library?.description ?? ''; version = ''; await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' });
} }
function chooseFolder(event: Event) { function chooseFolder(event: Event) {
uploadError = ''; folder = null; uploadError = ''; folder = null;
@@ -82,14 +80,13 @@
const selected = selectGameFolder(Array.from(input.files)); const selected = selectGameFolder(Array.from(input.files));
if (uploadStatus) checkSelection(selected, uploadStatus); if (uploadStatus) checkSelection(selected, uploadStatus);
folder = selected; 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 = ''; } } catch (cause) { uploadError = (cause as Error).message; input.value = ''; }
} }
function checkSelection(selected: SelectedGameFolder, status: UploadStatus) { function checkSelection(selected: SelectedGameFolder, status: UploadStatus) {
const originals = new Map(status.files.map(file => [file.path, file])); const originals = new Map(status.files.map(file => [file.path, file]));
if (selected.files.length !== originals.size || selected.files.some(({ 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; 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) { function applyStatus(status: UploadStatus) {
uploadStatus = status; uploadStatus = status;
@@ -109,12 +106,12 @@
} }
if (disposed) return; if (disposed) return;
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.'); 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 = ''; if (folderInput) folderInput.value = '';
await load(); await load();
} }
async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) { 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; uploadBusy = true;
try { try {
const status = await getStatus(); const status = await getStatus();
@@ -122,7 +119,7 @@
else { phase = 'paused'; uploadError = status.error ?? ''; } else { phase = 'paused'; uploadError = status.error ?? ''; }
} catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; } } catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; }
finally { uploadBusy = false; } 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(); } function pauseUpload() { pauseRequested = true; activeRequest?.abort(); }
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> { 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'; uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing';
try { try {
if (!uploadId) { 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; uploadId = result.version.id;
} }
const status = await getStatus(); const status = await getStatus();
@@ -200,90 +197,119 @@
<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="Publish your T5 or T6 game folder once. Hosts download and verify the latest versions automatically."> <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." />
{#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 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 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} {#if canManage && showUpload}
<section class="card" id="library-upload"> <section class="card" id="game-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> <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 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> <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} {#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}> <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'} {#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> <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> <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}
{#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 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"> {#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> <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> <p>{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring</p>
{#if currentPath}<p class="current-file">{currentPath}</p>{/if} {#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>{/if}
<div class="upload-actions"> <div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button> {#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} {#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
</div> </div>
</form> </form>
</section> </section>
{/if} {/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> {#if !loaded}<p role="status">Loading game files…</p>{/if}
{#each matching as library (library.id)} {#each libraries as library (library.game)}
{@const latestPublished = library.versions.find(item => item.state === 'ready')} {@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"> <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> <div class="heading">
{#each library.versions as item (item.id)} <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} {@const browserUpload = !item.sourceHostId}
<article class="version"> <article class="upload-entry">
<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> <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>
<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} {#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 browserUpload}
{#if item.state === 'ready'} <p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
{#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 canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if}
{#if !hosts.length}<p class="muted">Files are published. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if} {:else if item.state === 'importing'}<p class="muted">The existing host import continues independently. It is reconciled when the host reconnects.</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> </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>
{/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} {#each item.caches as cache}
{@const cacheHost = hosts.find(host => host.id === cache.hostId)} {@const cacheHost = hosts.find(host => host.id === cache.hostId)}
{@const awaitingAutomaticDownload = item.id === latestPublished?.id && cache.status === 'missing'} {@const awaitingAutomaticDownload = latest && cache.status === 'missing'}
<tr> <tr>
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td> <td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</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>{#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> <td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
</tr> </tr>
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each} {:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
</tbody></table></div> </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} {#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}
{: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} {/snippet}
{: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}
</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}
</div>
<style> <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); } .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); } .uploads, .upload-entry { 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; } .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; } .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; } label { display:grid; gap:6px; font-size:var(--text-sm); }
.wide { grid-column:1/-1; } 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, 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 { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
input:disabled, select:disabled, textarea: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,.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); } .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; } .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; } 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; } 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); } .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> </style>
+1 -1
View File
@@ -41,7 +41,7 @@
{#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 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} {#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> <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> </section>
+46 -9
View File
@@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { import {
browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache, browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache,
getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload, getLibraryVersion, latestPublishedLibraries, listLibraries, newestReadyLibrary, publishBrowserUpload, queueLibraryDownload,
requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile
} from '../../src/lib/server/game-files/index.js'; } from '../../src/lib/server/game-files/index.js';
import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js'; import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js';
@@ -24,8 +24,7 @@ const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('he
const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified }); const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified });
const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) => const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) =>
uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body); uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body);
const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') => const begin = (game: 't5' | 't6' = 't6', files = [file()]) => createLibraryUpload({ game, files });
createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files });
beforeEach(async () => { beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-')); directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-'));
@@ -37,6 +36,7 @@ afterEach(async () => {
state.handle?.close(); state.handle = null; state.handle?.close(); state.handle = null;
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.useRealTimers();
}); });
function host() { function host() {
@@ -65,7 +65,7 @@ describe('browser folder uploads', () => {
expect(ready.manifestSha256).toBe(manifestChecksum(ready.files)); expect(ready.manifestSha256).toBe(manifestChecksum(ready.files));
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' }); expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' });
expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc'); expect(listLibraries().find(library => library.game === game)?.versions[0]).not.toHaveProperty('dlc');
expect(listHosts()).toEqual([]); expect(listHosts()).toEqual([]);
}); });
@@ -122,7 +122,7 @@ describe('browser folder uploads', () => {
const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize; const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize;
expect(chunkSize).toBeGreaterThan(0); expect(chunkSize).toBeGreaterThan(0);
const large = Buffer.alloc(chunkSize + 17, 0x41); const large = Buffer.alloc(chunkSize + 17, 0x41);
const initial = begin('t6', [file('main/archive.iwd', large)], 'large'); const initial = begin('t6', [file('main/archive.iwd', large)]);
const first = large.subarray(0, chunkSize); const first = large.subarray(0, chunkSize);
await upload(initial.id, 'main/archive.iwd', first); await upload(initial.id, 'main/archive.iwd', first);
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false }); expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false });
@@ -146,17 +146,54 @@ describe('browser folder uploads', () => {
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0); expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0);
}); });
it('keeps ready bytes and version metadata immutable and requires a new version label', async () => { it('keeps published files immutable while accepting repeated uploads without labels', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes); const initial = begin(); await upload(initial.id, file().path, bytes);
const ready = await publishBrowserUpload(initial.id); const ready = await publishBrowserUpload(initial.id);
await expect(upload(initial.id, file().path, bytes)).rejects.toThrow(); await expect(upload(initial.id, file().path, bytes)).rejects.toThrow();
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256); expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256);
expect(begin('t6', [file()], '2').id).not.toBe(ready.id); const next = begin();
expect(next.id).not.toBe(ready.id);
expect(next.version).not.toBe(ready.version);
expect(next.libraryId).toBe(ready.libraryId);
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
}); });
it('ignores legacy names, descriptions, and labels when accepting new game files', () => {
const legacyFields = { name: 'same name', version: 'same version', description: 'a library description' };
const first = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
const next = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
const otherGame = createLibraryUpload({ game: 't6', files: [file()], ...legacyFields });
expect(first.description).toBe('');
expect(first.version).not.toBe(legacyFields.version);
expect(next.version).not.toBe(first.version);
expect(next.libraryId).toBe(first.libraryId);
expect(otherGame.libraryId).not.toBe(first.libraryId);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']);
});
it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const slow = begin();
vi.setSystemTime(new Date('2026-01-02T00:00:00Z'));
const fast = begin();
await upload(fast.id, file().path, bytes);
vi.setSystemTime(new Date('2026-01-03T00:00:00Z'));
await publishBrowserUpload(fast.id);
expect(newestReadyLibrary('t6')?.id).toBe(fast.id);
await upload(slow.id, file().path, bytes);
vi.setSystemTime(new Date('2026-01-04T00:00:00Z'));
await publishBrowserUpload(slow.id);
const failed = begin();
await requestBrowserPublication(failed.id);
await vi.waitFor(() => expect(getLibraryVersion(failed.id)?.state).toBe('failed'));
begin();
expect(newestReadyLibrary('t6')?.id).toBe(slow.id);
expect(latestPublishedLibraries().map(version => version.id)).toEqual([slow.id]);
expect(listLibraries().find(library => library.game === 't6')?.latestVersionId).toBe(slow.id);
});
it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => { it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes); const initial = begin(); await upload(initial.id, file().path, bytes);
const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }]; const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }];
@@ -220,7 +257,7 @@ describe('browser folder uploads', () => {
expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed'); expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed');
updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]); updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]);
expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id); expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id);
const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes); const replacement = begin('t5'); await upload(replacement.id, file().path, bytes);
await publishBrowserUpload(replacement.id); await publishBrowserUpload(replacement.id);
expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256); expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256);
}); });
+82 -5
View File
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ handle: null as Database.Database | null })); const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js'; import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js'; import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
import { serveGameFile } from '../../src/lib/server/game-files/http.js'; import { serveGameFile } from '../../src/lib/server/game-files/http.js';
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js'; import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
@@ -22,12 +22,87 @@ beforeEach(async () => {
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential; credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
}); });
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); }); afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); } function begin() { return createLibraryImport({ hostId, game: 't6' }); }
async function published() { async function published() {
const version = begin(); setImportManifest(hostId, version.id, [file]); const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body); await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
return publishImport(hostId, version.id); return publishImport(hostId, version.id);
} }
function legacyLibrary(id: string, game: 't5' | 't6') {
state.handle!.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)')
.run(id, `User named ${id}`, game, 'Original description', '2025-01-01T00:00:00.000Z');
}
function legacyVersion(id: string, libraryId: string, createdAt: string, publishedAt: string) {
state.handle!.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume,manifest_sha256)
VALUES(?,?,?,'Legacy version','[]','ready',?,?,?,'','','checksum')`)
.run(id, libraryId, `User label ${id}`, createdAt, publishedAt, join(directory, id));
}
describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
]);
expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull();
expect(newestReadyLibrary('t6')).toBeNull();
});
it('unifies old named libraries by game without rewriting published versions or their host references', () => {
listLibraries();
legacyLibrary('first-t6-library', 't6');
legacyLibrary('other-t6-library', 't6');
legacyLibrary('t5-library', 't5');
legacyVersion('old-t6', 'first-t6-library', '2025-01-03', '2025-01-04');
legacyVersion('new-t6', 'other-t6-library', '2025-01-01', '2025-01-05');
legacyVersion('t5-files', 't5-library', '2025-01-01', '2025-01-02');
updateHostLibraryCaches(hostId, [
{ libraryVersionId: 'old-t6', status: 'ready', manifestSha256: 'checksum' },
{ libraryVersionId: 'new-t6', status: 'ready', manifestSha256: 'checksum' }
]);
const before = getLibraryVersion('old-t6');
const slots = listLibraries();
expect(slots.map(library => library.id)).toEqual(['t5', 't6']);
expect(slots[1].versions.map(version => version.id)).toEqual(['old-t6', 'new-t6']);
expect(slots.map(library => library.latestVersionId)).toEqual(['t5-files', 'new-t6']);
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['t5-files', 'new-t6']);
expect(newestReadyLibrary('t6')?.id).toBe('new-t6');
const overview = libraryOverview().libraries[1];
expect(overview.versions.find(version => version.id === 'old-t6')?.caches[0].status).toBe('outdated');
expect(overview.versions.find(version => version.id === 'new-t6')?.caches[0].status).toBe('ready');
expect(getLibraryVersion('old-t6')).toEqual(before);
expect(requireReadyLibrary(hostId, 'old-t6')).toMatchObject({
libraryId: 'first-t6-library', version: 'User label old-t6', storageLocation: join(directory, 'old-t6')
});
expect(begin().libraryId).toBe('first-t6-library');
expect(listLibraries()[1].latestVersionId).toBe('new-t6');
});
it('breaks publication-time ties by creation time and then insertion order across old libraries', () => {
listLibraries();
legacyLibrary('first-library', 't6');
legacyLibrary('second-library', 't6');
legacyVersion('first', 'first-library', '2025-01-01', '2025-01-05');
legacyVersion('second', 'second-library', '2025-01-02', '2025-01-05');
legacyVersion('third', 'first-library', '2025-01-02', '2025-01-05');
legacyVersion('later-created', 'second-library', '2025-01-04', '2025-01-04');
expect(newestReadyLibrary('t6')?.id).toBe('third');
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['third']);
expect(listLibraries()[1].latestVersionId).toBe('third');
});
it('ignores user-supplied library metadata when importing from hosts', () => {
const first = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
const next = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
expect(first.description).toBe('');
expect(first.version).not.toBe('1');
expect(next.version).not.toBe(first.version);
expect(first.libraryId).toBe(next.libraryId);
});
});
describe('game-file import lifecycle', () => { describe('game-file import lifecycle', () => {
it('publishes verified filesystem content and permanently pins manifest metadata', async () => { it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
const initial = begin(); const initial = begin();
@@ -42,8 +117,10 @@ describe('game-file import lifecycle', () => {
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id); expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/); await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/); const next = begin();
expect(begin('2').id).not.toBe(ready.id); expect(next.id).not.toBe(ready.id);
expect(next.version).not.toBe(ready.version);
expect(next.libraryId).toBe(ready.libraryId);
}); });
it('restricts an import to its source host and rejects manifest mutation', () => { it('restricts an import to its source host and rejects manifest mutation', () => {
const version = begin(); const version = begin();
@@ -55,7 +132,7 @@ describe('game-file import lifecycle', () => {
it('surfaces durable agent failures while preserving historical versions', () => { it('surfaces durable agent failures while preserving historical versions', () => {
const version = begin(); const version = begin();
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' }); reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' }); expect(listLibraries().find(library => library.game === 't6')?.versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
}); });
it('returns a pollable publication status while large files are verified asynchronously', async () => { it('returns a pollable publication status while large files are verified asynchronously', async () => {
const version = begin(); setImportManifest(hostId, version.id, [file]); const version = begin(); setImportManifest(hostId, version.id, [file]);
+49 -15
View File
@@ -50,10 +50,10 @@ function operations(id: string) {
return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind)); return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind));
} }
async function publish(name = 'T5 files', version = '1', game: 't5' | 't6' = 't5', bytes = content) { async function publish(game: 't5' | 't6' = 't5', bytes = content) {
vi.setSystemTime(Date.now() + 1); vi.setSystemTime(Date.now() + 1);
const upload = libraries.createLibraryUpload({ const upload = libraries.createLibraryUpload({
name, version, game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }] game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
}); });
await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', { await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', {
offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex')
@@ -177,17 +177,16 @@ describe('automatic host preparation', () => {
expect(operations(id)).toHaveLength(2); expect(operations(id)).toHaveLength(2);
}); });
it('downloads the newest published version of every library, including separate libraries for the same game', async () => { it('always targets the latest verified T5 and T6 upload while newer uploads remain unfinished', async () => {
const old = await publish('T5 files', 'old'); const old = await publish();
const latest = await publish('T5 files', 'new'); const latest = await publish();
const alternate = await publish('T5 alternate'); const t6 = await publish('t6');
const t6 = await publish('T6 files', '1', 't6');
libraries.createLibraryUpload({ libraries.createLibraryUpload({
name: 'T5 files', version: 'unfinished', game: 't5', game: 't5',
files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }] files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }]
}); });
const id = enroll({ runtimeImages: [runtime] }); const id = enroll({ runtimeImages: [runtime] });
const expected = new Map([latest, alternate, t6].map(version => [version.id, version])); const expected = new Map([latest, t6].map(version => [version.id, version]));
const downloaded: string[] = []; const downloaded: string[] = [];
for (let index = 0; index < expected.size; index++) { for (let index = 0; index < expected.size; index++) {
preparation.reconcileHostPreparation(id); preparation.reconcileHostPreparation(id);
@@ -218,7 +217,7 @@ describe('automatic host preparation', () => {
verified(id, old); verified(id, old);
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] }); heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
expect(preparation.reconcileHostPreparation(id).state).toBe('ready'); expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
const latest = await publish('T5 files', '2'); const latest = await publish();
preparation.reconcileHostPreparation(id); preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1); expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id); expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id);
@@ -226,6 +225,41 @@ describe('automatic host preparation', () => {
expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready'); expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready');
}); });
it('replaces an undelivered automatic download when newer files finish uploading', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const first = operations(id)[0];
expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true });
// The next heartbeat may discover a completed cache while this queued
// retry has still never been delivered. Keep that verified evidence.
verified(id, old);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(hosts.getHostOperation(first.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('Superseded') });
expect(operations(id).filter(operation => operation.state === 'pending')).toMatchObject([
{ payload: { libraryVersionId: latest.id, automatic: true } }
]);
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it('finishes a delivered download before following a newer upload under a new operation ID', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const [first] = hosts.pendingHostOperations(id);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
expect(hosts.getHostOperation(first.id)?.state).toBe('pending');
verified(id, old);
hosts.reportHostOperation(id, first.id, { state: 'completed' });
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({ state: 'pending', payload: { libraryVersionId: latest.id } });
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => { it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => {
const id = enroll(); const id = enroll();
hosts.updateHost(id, { mode }); hosts.updateHost(id, { mode });
@@ -305,15 +339,15 @@ describe('automatic host preparation', () => {
expect(operations(id)).toHaveLength(1); expect(operations(id)).toHaveLength(1);
}); });
it('lets other libraries prepare when an earlier library cannot fit', async () => { it('prepares T6 when the latest T5 upload cannot fit', async () => {
const oversized = await publish('A oversized'); const oversized = await publish();
const small = await publish('B small', '1', 't6', 'small'); const small = await publish('t6', 'small');
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 }); const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 });
hosts.updateHost(id, { minimumFreeBytes: 1_000 }); hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id); const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([ expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'A oversized · 1', state: 'blocked' }), expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
expect.objectContaining({ label: 'B small · 1', state: 'preparing' }) expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
])); ]));
expect(operations(id)).toHaveLength(1); expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id); expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../../src/lib/game-files.js';
import { latestGameFiles, selectLatestGameFiles } from '../../src/lib/ui/native-game-files.js';
function upload(id: string, game: GameLibraryGame = 't5', state: GameLibraryVersion['state'] = 'ready'): GameLibraryVersion {
return {
id, libraryId: game, game, version: id, description: '', state,
sizeBytes: 10, fileCount: 1, manifestSha256: 'manifest', files: [],
createdAt: '2026-10-04T12:00:00Z', publishedAt: state === 'ready' ? '2026-10-04T12:05:00Z' : null,
storageLocation: '', sourceHostId: null, sourceVolume: null, error: null, operationId: null, caches: []
};
}
function slot(game: GameLibraryGame, latestVersionId: string | null, versions: GameLibraryVersion[]): GameLibrary {
return { id: game, name: game.toUpperCase(), game, description: '', createdAt: '', latestVersionId, versions };
}
describe('automatic game file selection for new launches', () => {
it('follows the controller latest upload even when older files are already cached', () => {
const old = upload('old');
old.caches = [{ hostId: 'host', libraryVersionId: old.id, status: 'ready', bytesDownloaded: 10,
bytesTotal: 10, manifestSha256: 'manifest', error: null, updatedAt: '', operationId: null }];
const current = upload('latest');
// The API can retain older uploads and unfinished uploads in its history.
const libraries = [slot('t5', current.id, [upload('unfinished', 't5', 'importing'), old, current])];
const selected = { t5: old.id };
const latest = latestGameFiles(libraries);
expect(latest.t5).toBe(current);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
expect(selected.t5).toBe(current.id);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(false);
});
it('updates both games and removes a stale selection when a game has no available upload', () => {
const latest = latestGameFiles([slot('t5', null, [upload('unavailable')]), slot('t6', 'new-t6', [upload('new-t6', 't6')])]);
const selected: Partial<Record<GameLibraryGame, string>> = { t5: 'old-t5', t6: 'old-t6' };
expect(selectLatestGameFiles(selected, latest, ['t5', 't6'])).toBe(true);
expect(selected).toEqual({ t6: 'new-t6' });
});
it('never selects an unfinished or missing latest upload from history', () => {
expect(latestGameFiles([slot('t5', 'pending', [upload('old'), upload('pending', 't5', 'importing')])])).toEqual({});
expect(latestGameFiles([slot('t6', 'missing', [upload('old', 't6')])])).toEqual({});
});
it('only changes games needed by the current launch', () => {
const selected = { t5: 'old-t5', t6: 'keep-t6' };
const latest = latestGameFiles([slot('t5', 'new-t5', [upload('new-t5')])]);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
expect(selected).toEqual({ t5: 'new-t5', t6: 'keep-t6' });
});
});
+34 -1
View File
@@ -33,7 +33,7 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
async function prepareLibrary(game: 't5' | 't6' = 't5') { async function prepareLibrary(game: 't5' | 't6' = 't5') {
const libs = await import('../../src/lib/server/game-files/index.js'); const libs = await import('../../src/lib/server/game-files/index.js');
const version = libs.createLibraryImport({ hostId, game, name: game, version: '1' }); const version = libs.createLibraryImport({ hostId, game });
const bytes = Buffer.from('verified base game'); const bytes = Buffer.from('verified base game');
const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
libs.setImportManifest(hostId, version.id, [file]); libs.setImportManifest(hostId, version.id, [file]);
@@ -73,6 +73,39 @@ async function detail(id: string) {
} }
describe('native instance provisioning', () => { describe('native instance provisioning', () => {
it('selects the latest game files automatically and rejects stale explicit selections', async () => {
const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
expect(() => nativeSelection({ hostId, image }, 't5')).toThrow('Upload T5 game files');
const old = await prepareLibrary('t5');
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(old.id);
const latest = await prepareLibrary('t5');
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(latest.id);
expect(() => nativeSelection({ hostId, image, libraryId: old.id }, 't5')).toThrow('Refresh the launch preview');
});
it('rejects a launch preview made before newer game files were published without reserving a server', async () => {
await prepareLibrary('t5');
const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
const preview = previewNativeCreation({ hostId, image, game: 't5', mapSlug: 'theater' });
await prepareLibrary('t5');
const result = queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', previewId: preview.id, plutoKey: 'new-key' });
expect(result).toMatchObject({ ok: false, message: expect.stringContaining('Refresh the launch preview') });
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 0 });
expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 0 });
});
it('keeps a queued server pinned to its accepted files when newer game files arrive', async () => {
const job = await queue();
const acceptedId = libraryId;
const latest = await prepareLibrary('t5');
expect(latest.id).not.toBe(acceptedId);
await advance();
expect((await operationFor(job.deploymentId))?.payload.libraryId).toBe(acceptedId);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.library_id).toBe(acceptedId);
});
it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => { it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => {
const job = await queue(game); await advance(); const job = await queue(game); await advance();
const operation = await operationFor(job.deploymentId); const operation = await operationFor(job.deploymentId);