Pool Plutonium keys by game for launches and autoscaling
This commit is contained in:
@@ -19,7 +19,7 @@ export interface ScalingMember {
|
||||
emptyStartedAt?: number | null;
|
||||
}
|
||||
export interface ScalingSnapshot {
|
||||
members: ScalingMember[]; reservedNames: string[]; keyedNames: string[]; blockers: string[];
|
||||
members: ScalingMember[]; reservedNames: string[]; keyPool: { total: number; available: number }; blockers: string[];
|
||||
}
|
||||
export interface ScalingDecision {
|
||||
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingPreview, type ScalingReport, type ScalingView } from '../../autoscaling.js';
|
||||
import { emptyObservation, planScaling } from './planner.js';
|
||||
import { zombieMaps, parseServerName } from '../../map-aliases.js';
|
||||
import { parseServerName } from '../../map-aliases.js';
|
||||
import { scalingSnapshot } from './snapshot.js';
|
||||
import { nativeLaunchSelection, queueNativeCreationIntent } from '../provisioning/native.js';
|
||||
import { cancelCreation } from '../provisioning/cancel-creation.js';
|
||||
@@ -30,13 +30,6 @@ export function previewScaling(input: unknown, id?: string, revision?: number):
|
||||
nextActionAt: existing?.nextActionAt ?? 0, pausedReason: null, updatedAt: existing?.updatedAt ?? '' };
|
||||
const snapshot = scalingSnapshot(policy);
|
||||
const blockers: string[] = [];
|
||||
// Creating any policy requires full coverage; existing policies must always be pausable.
|
||||
if (!existing || policy.mode !== 'off') {
|
||||
for (const map of zombieMaps(policy.game).filter(m => policy.mapSlug === '*' || m.slug === policy.mapSlug)) {
|
||||
const count = snapshot.keyedNames.filter(name => parseServerName(name)?.mapSlug === map.slug).length;
|
||||
if (count < policy.maximum) blockers.push(`Save keys for at least ${policy.maximum} lobby identities for ${map.friendly} before saving this policy (${count} saved; ${policy.maximum - count} more required).`);
|
||||
}
|
||||
}
|
||||
if (liveScaling(policy.mode)) {
|
||||
const owner = scalingOwner(policy.game, policy.mapSlug);
|
||||
if (owner && owner !== policy.id) blockers.push(`An overlapping policy owns this lineup. Pause it and settle pending work first: /autoscaling#${owner}`);
|
||||
@@ -51,7 +44,7 @@ export function previewScaling(input: unknown, id?: string, revision?: number):
|
||||
}
|
||||
const decision = planScaling(policy, snapshot, Date.now());
|
||||
const token = createHash('sha256').update(JSON.stringify({ settings, revision: policy.revision, globalPaused: scalingGloballyPaused(),
|
||||
members: snapshot.members.map(m => [m.name, m.identity, m.state]), keys: snapshot.keyedNames.sort(), reservations: snapshot.reservedNames.sort(), blockers: snapshot.blockers, decision })).digest('hex');
|
||||
members: snapshot.members.map(m => [m.name, m.identity, m.state]), keyPool: snapshot.keyPool, reservations: snapshot.reservedNames.sort(), blockers: snapshot.blockers, decision })).digest('hex');
|
||||
return { policy, snapshot, decision, token, blockers };
|
||||
}
|
||||
|
||||
@@ -154,7 +147,7 @@ export async function runAutoscaling(): Promise<boolean> {
|
||||
ON CONFLICT(policy_id,server) DO UPDATE SET identity=excluded.identity,observation=excluded.observation`).run(policy.id, member.name, member.identity, JSON.stringify(observation));
|
||||
}
|
||||
// Evidence includes provenance and timestamps but never keys or player rosters.
|
||||
const evidence = JSON.stringify({ members: snapshot.members, reservations: snapshot.reservedNames, keyCount: snapshot.keyedNames.length, blockers: snapshot.blockers });
|
||||
const evidence = JSON.stringify({ members: snapshot.members, reservations: snapshot.reservedNames, keyPool: snapshot.keyPool, blockers: snapshot.blockers });
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify([policy.revision, decision, snapshot.members.map(m => [m.name, m.identity, m.state])])).digest('hex');
|
||||
const last = db().prepare('SELECT id,fingerprint FROM autoscaling_decisions WHERE policy_id=? ORDER BY evaluated_at DESC,rowid DESC LIMIT 1').get(policy.id) as { id: string; fingerprint: string } | undefined;
|
||||
const execute = liveScaling(policy.mode) && (decision.action === 'create' || policy.mode === 'create-destroy' && decision.action === 'destroy');
|
||||
|
||||
@@ -21,7 +21,7 @@ export function planScaling(policy: ScalingPolicy, snapshot: ScalingSnapshot, no
|
||||
const matches = (name: string) => (snapshot.members.find(m => m.name === name)?.mapSlug ?? parseServerName(name)?.mapSlug) === map.slug;
|
||||
return planScaling({ ...policy, mapSlug: map.slug }, {
|
||||
...snapshot, members: snapshot.members.filter(m => matches(m.name)),
|
||||
reservedNames: snapshot.reservedNames.filter(matches), keyedNames: snapshot.keyedNames.filter(matches)
|
||||
reservedNames: snapshot.reservedNames.filter(matches)
|
||||
}, now);
|
||||
});
|
||||
// Serialize game-wide work and prefer replenishing any map before trimming another.
|
||||
@@ -50,8 +50,12 @@ export function planScaling(policy: ScalingPolicy, snapshot: ScalingSnapshot, no
|
||||
if (policy.nextActionAt > now) return result('wait', 'Action cooldown is active.');
|
||||
if (occupied + empty < desired) {
|
||||
if (snapshot.reservedNames.length >= policy.maximum) return result('wait', 'Maximum lobby reservations reached.');
|
||||
const available = snapshot.keyedNames.filter(name => !snapshot.reservedNames.includes(name)).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
||||
return available.length ? result('create', 'Replenish empty lobby capacity.', available[0]) : result('wait', 'No unused lobby identity has a saved key.');
|
||||
if (!snapshot.keyPool.available) return result('wait', snapshot.keyPool.total
|
||||
? `All ${policy.game.toUpperCase()} Plutonium keys are in use. Waiting for a free key.`
|
||||
: `No Plutonium keys are saved for ${policy.game.toUpperCase()}.`);
|
||||
const available = Array.from({ length: 20 }, (_, index) => `${policy.game}_zombie_${policy.mapSlug}_${index + 1}`)
|
||||
.find(name => !snapshot.reservedNames.includes(name));
|
||||
return available ? result('create', 'Replenish empty lobby capacity.', available) : result('wait', 'No unused lobby identity is available.');
|
||||
}
|
||||
if (occupied + empty > desired && empty > policy.spares && occupied + empty > policy.minimum) {
|
||||
const candidates = members.filter(m => m.state === 'empty' && m.emptySince !== null && m.observedAt! - m.emptySince >= policy.emptyMinutes * 60_000)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { validServerId } from '../fleet/server-keys.js';
|
||||
import { serverKeyPool } from '../fleet/server-keys.js';
|
||||
import { listServers, type ServerRow } from '../fleet/servers.js';
|
||||
import { observationReader } from '../fleet/status.js';
|
||||
import { parseServerName, zombieMaps } from '../../map-aliases.js';
|
||||
@@ -73,6 +73,8 @@ export function scalingSnapshot(policy: ScalingPolicy, now = Date.now(), exceptO
|
||||
reservedNames.add(row.name);
|
||||
if (!members.some(m => m.name === row.name)) blockers.push(`Unresolved native instance ${row.name}.`);
|
||||
}
|
||||
const keyedNames = (handle.prepare("SELECT server_id AS name FROM server_keys WHERE length(trim(pluto_key))>0").all() as { name: string }[]).filter(row => validServerId(row.name) && matches(row)).map(r => r.name);
|
||||
return { members, reservedNames: [...reservedNames], keyedNames, blockers };
|
||||
for (const row of handle.prepare('SELECT server_id AS name FROM server_key_leases').all() as { name: string }[]) if (matches(row)) reservedNames.add(row.name);
|
||||
const ownServer = exceptOperation ? (handle.prepare('SELECT server FROM server_deployments WHERE id=?').get(exceptOperation) as { server: string } | undefined)?.server : undefined;
|
||||
const { total, available } = serverKeyPool(policy.game, ownServer);
|
||||
return { members, reservedNames: [...reservedNames], keyPool: { total, available }, blockers };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
|
||||
import { initializeFleetSettings } from './fleet-settings.js';
|
||||
import { migrateServerHistory } from './server-history.js';
|
||||
import { migrateNativeInstances } from '../backends/schema.js';
|
||||
import { migrateServerKeys } from './server-keys.js';
|
||||
import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
|
||||
import { installPerformanceIndexes } from './performance-indexes.js';
|
||||
import { migrateFileBlobs } from './file-blobs.js';
|
||||
@@ -397,6 +398,7 @@ export function db(): Database.Database {
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
migrationStep('native instance schema and file plans', () => migrateNativeInstances(handle));
|
||||
migrationStep('per-game server key pools', () => migrateServerKeys(handle));
|
||||
migrationStep('autoscaling schema', () => ensureAutoscalingSchema(handle));
|
||||
// File-blob migration removes the legacy content column altogether.
|
||||
// Reordering it first would unnecessarily copy every artifact twice.
|
||||
|
||||
@@ -433,11 +433,15 @@ CREATE TABLE IF NOT EXISTS deployment_scripts (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_dep ON deployment_scripts(deployment_id);
|
||||
|
||||
-- Deliberately independent of servers: credentials survive server destruction.
|
||||
-- Credentials belong to a game and survive server destruction. Reservations
|
||||
-- are installed by the key-pool migration after native instance schema exists.
|
||||
CREATE TABLE IF NOT EXISTS server_keys (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
id TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL CHECK (game IN ('t5','t6')),
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
pluto_key TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(game, pluto_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const keySchema = `CREATE TABLE IF NOT EXISTS server_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
game TEXT NOT NULL CHECK (game IN ('t5','t6')),
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
pluto_key TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(game, pluto_key)
|
||||
);`;
|
||||
|
||||
const leaseSchema = `CREATE TABLE IF NOT EXISTS server_key_leases (
|
||||
server_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL REFERENCES server_keys(id) ON DELETE RESTRICT,
|
||||
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0,1)),
|
||||
reserved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY(server_id,key_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_key_leases_key ON server_key_leases(key_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_server_key_leases_current ON server_key_leases(server_id) WHERE is_current=1;`;
|
||||
|
||||
/** Preserve every occupied legacy assignment, even duplicated credentials. */
|
||||
export function migrateServerKeys(handle: Database.Database): void {
|
||||
handle.transaction(() => {
|
||||
const legacy = (handle.prepare('PRAGMA table_info(server_keys)').all() as { name: string }[])
|
||||
.some(column => column.name === 'server_id');
|
||||
if (legacy) handle.exec('ALTER TABLE server_keys RENAME TO legacy_server_keys');
|
||||
handle.exec(keySchema + leaseSchema);
|
||||
if (!legacy) return;
|
||||
const rows = handle.prepare(`SELECT server_id, pluto_key, updated_at FROM legacy_server_keys ORDER BY server_id`).all() as
|
||||
{ server_id: string; pluto_key: string; updated_at: string }[];
|
||||
const occupied = handle.prepare(`SELECT 1 WHERE
|
||||
EXISTS (SELECT 1 FROM servers WHERE name=@server AND destroyed_at IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM native_instances WHERE server=@server AND retired_at IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM server_deployments WHERE server=@server AND server_deleted_at IS NULL
|
||||
AND action IN ('create','rebuild') AND state NOT IN ('done','warning','cancelled'))`);
|
||||
const find = handle.prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?');
|
||||
const serverGame = handle.prepare("SELECT game FROM servers WHERE name=? AND game IN ('t5','t6')");
|
||||
const insert = handle.prepare('INSERT INTO server_keys(id,game,label,pluto_key,updated_at) VALUES(?,?,?,?,?)');
|
||||
const lease = handle.prepare('INSERT INTO server_key_leases(server_id,key_id,is_current) VALUES(?,?,?)');
|
||||
const assignments = new Map<string, string[]>();
|
||||
function save(game: string, credential: string, label: string, updatedAt: string): string {
|
||||
const existing = (find.get(game, credential) as { id: string } | undefined)?.id;
|
||||
if (existing) return existing;
|
||||
const id = randomUUID();
|
||||
insert.run(id, game, label, credential, updatedAt);
|
||||
return id;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const game = /^(t5|t6)_/.exec(row.server_id)?.[1]
|
||||
?? (serverGame.get(row.server_id) as { game: string } | undefined)?.game;
|
||||
if (!game) throw new Error(`Cannot determine the game for saved server key ${row.server_id}`);
|
||||
const id = save(game, row.pluto_key, row.server_id, row.updated_at);
|
||||
if (occupied.get({ server: row.server_id })) assignments.set(row.server_id, [id]);
|
||||
}
|
||||
// The old form could overwrite a saved key without changing the running
|
||||
// container. Durable create/rebuild payloads are stronger evidence of
|
||||
// its actual credential. Keep ambiguous delivered attempts as additional
|
||||
// holds until the instance is destroyed; never free a possibly live key.
|
||||
const hasTable = (name: string) => !!handle.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
||||
if (hasTable('ec_host_operations')) {
|
||||
const payload = hasTable('ec_host_operation_payloads')
|
||||
? 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)' : 'o.payload';
|
||||
const command = `CASE WHEN json_valid(${payload}) THEN ${payload} ELSE '{}' END`;
|
||||
const launched = handle.prepare(`SELECT n.server,o.state,o.updated_at,
|
||||
json_extract(${command},'$.env.PLUTO_SERVER_KEY') AS credential,
|
||||
json_extract(${command},'$.game') AS game,
|
||||
json_extract(${command},'$.env.PLUTAINER_GAME') AS game_id
|
||||
FROM native_instances n JOIN ec_host_operations o ON o.instance_id=n.id AND o.host_id=n.host_id
|
||||
WHERE n.retired_at IS NULL AND o.kind IN ('instance.create','instance.rebuild') AND o.state!='failed'
|
||||
AND (o.generation=n.generation OR json_extract(${command},'$.nextGeneration')=n.generation)
|
||||
ORDER BY o.rowid DESC`).all() as
|
||||
{ server: string; state: string; updated_at: string; credential: unknown; game: unknown; game_id: unknown }[];
|
||||
const recovered = new Map<string, string[]>();
|
||||
const complete = new Set<string>();
|
||||
for (const row of launched) {
|
||||
if (complete.has(row.server) || typeof row.credential !== 'string' || !row.credential) continue;
|
||||
const game = row.game === 't5' || row.game === 't6' ? row.game
|
||||
: row.game_id === 't5sp' ? 't5' : row.game_id === 't6zm' ? 't6'
|
||||
: /^(t5|t6)_/.exec(row.server)?.[1]
|
||||
?? (serverGame.get(row.server) as { game: string } | undefined)?.game;
|
||||
if (!game) throw new Error(`Cannot determine the game for launched server ${row.server}`);
|
||||
const ids = recovered.get(row.server) ?? [];
|
||||
const id = save(game, row.credential, row.server, row.updated_at);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
recovered.set(row.server, ids);
|
||||
if (row.state === 'completed') complete.add(row.server);
|
||||
}
|
||||
for (const [server, ids] of recovered) {
|
||||
// If no completed launch receipt survives, its saved assignment
|
||||
// remains a possible prior credential while the attempt resolves.
|
||||
if (!complete.has(server)) for (const id of assignments.get(server) ?? []) if (!ids.includes(id)) ids.push(id);
|
||||
assignments.set(server, ids);
|
||||
}
|
||||
}
|
||||
for (const [server, ids] of assignments) ids.forEach((id, index) => lease.run(server, id, index === 0 ? 1 : 0));
|
||||
handle.exec('DROP TABLE legacy_server_keys');
|
||||
}).immediate();
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
import { nativeLaunchSelection, queueNativeCreationIntent } from '../provisioning/native.js';
|
||||
import { nativeDeployment, nativeInstance } from '../backends/native.js';
|
||||
import { launchAvailability } from '../hosts/availability.js';
|
||||
import { availableServerKeyCount } from './server-keys.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[], includeHealth = true) {
|
||||
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
|
||||
@@ -72,6 +73,8 @@ function collectionPlan(entries: BlueprintEntry[], mode: CollectionMode, include
|
||||
|
||||
function allocate(entries: BlueprintEntry[], counts = coverage(entries)): CollectionPreview['servers'] {
|
||||
const occupied = db().prepare(`SELECT name FROM servers UNION
|
||||
SELECT server AS name FROM native_instances WHERE retired_at IS NULL UNION
|
||||
SELECT server_id AS name FROM server_key_leases UNION
|
||||
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id=j.deployment_id
|
||||
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued','running','awaiting-health')
|
||||
OR (d.action='rebuild' AND (d.state='waiting' OR (d.state IN ('failed','interrupted') AND d.stage='Removing server files and registry'))))`)
|
||||
@@ -83,7 +86,7 @@ function allocate(entries: BlueprintEntry[], counts = coverage(entries)): Collec
|
||||
const taken = lobbies.get(match[1]) ?? new Set<number>();
|
||||
taken.add(Number(match[2])); lobbies.set(match[1], taken);
|
||||
}
|
||||
const savedKeys = new Set((db().prepare("SELECT server_id FROM server_keys WHERE length(trim(pluto_key))>0").all() as { server_id: string }[]).map(row => row.server_id));
|
||||
const availableKeys = { t5: availableServerKeyCount('t5'), t6: availableServerKeyCount('t6') };
|
||||
return entries.flatMap((entry, entryIndex) => Array.from({ length: counts[entryIndex].missing }, () => {
|
||||
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
|
||||
const taken = lobbies.get(base) ?? new Set<number>();
|
||||
@@ -92,7 +95,9 @@ function allocate(entries: BlueprintEntry[], counts = coverage(entries)): Collec
|
||||
const native = entry.recipe.native;
|
||||
const port = null;
|
||||
if (lobby !== null) { taken.add(lobby); lobbies.set(base, taken); }
|
||||
return { entryIndex, name, port, ...(native ? { native } : {}), hasSavedKey: !!name && savedKeys.has(name) };
|
||||
const hasSavedKey = !!name && availableKeys[entry.recipe.game] > 0;
|
||||
if (hasSavedKey) availableKeys[entry.recipe.game]--;
|
||||
return { entryIndex, name, port, ...(native ? { native } : {}), hasSavedKey };
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -185,7 +190,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
|
||||
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
|
||||
if (blockers.length) throw new Error(blockers.join(' '));
|
||||
if (entries.some((_entry, index) => counts[index].missing !== reviewed.filter(server => server.entryIndex === index).length)) {
|
||||
throw new Error('Fleet counts changed. Nothing was queued. Refresh the preview and review each server key.');
|
||||
throw new Error('Fleet counts changed. Nothing was queued. Refresh the preview and review the launches.');
|
||||
}
|
||||
const expected = new Set(servers.map(server => server.name));
|
||||
for (const [name, value] of Object.entries(keys)) {
|
||||
|
||||
@@ -1,35 +1,122 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { zombieMapBySlug } from './map-aliases.js';
|
||||
|
||||
export function validServerId(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
|
||||
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||
type KeyGame = 't5' | 't6';
|
||||
export interface ServerKey {
|
||||
id: string;
|
||||
game: KeyGame;
|
||||
label: string;
|
||||
updated_at: string;
|
||||
assigned_servers: string[];
|
||||
}
|
||||
|
||||
export function listServerKeys() {
|
||||
return db().prepare(`SELECT k.server_id, k.updated_at,
|
||||
EXISTS(SELECT 1 FROM servers s WHERE s.name = k.server_id) AS deployed
|
||||
FROM server_keys k ORDER BY k.server_id`).all() as Array<{
|
||||
server_id: string; updated_at: string; deployed: number;
|
||||
}>;
|
||||
function validGame(game: unknown): asserts game is KeyGame {
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 for the server key');
|
||||
}
|
||||
|
||||
export function getServerKey(serverId: string): string | undefined {
|
||||
return (db().prepare('SELECT pluto_key FROM server_keys WHERE server_id = ?')
|
||||
.get(serverId) as { pluto_key: string } | undefined)?.pluto_key;
|
||||
}
|
||||
|
||||
export function saveServerKey(serverId: unknown, key: unknown): void {
|
||||
if (!validServerId(serverId)) throw new Error('Choose a valid T5 or T6 zombie server ID with a lobby number from 1 to 20');
|
||||
function normalizedKey(key: unknown): string {
|
||||
if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) {
|
||||
throw new Error('Enter a server key without whitespace (maximum 512 characters)');
|
||||
}
|
||||
db().prepare(`INSERT INTO server_keys (server_id, pluto_key) VALUES (?, ?)
|
||||
ON CONFLICT(server_id) DO UPDATE SET pluto_key = excluded.pluto_key, updated_at = datetime('now')`)
|
||||
.run(serverId, key.trim());
|
||||
return key.trim();
|
||||
}
|
||||
|
||||
export function removeServerKey(serverId: string): boolean {
|
||||
return db().prepare('DELETE FROM server_keys WHERE server_id = ?').run(serverId).changes > 0;
|
||||
function normalizedLabel(label: unknown): string {
|
||||
if (typeof label !== 'string' || label.trim().length > 160 || /[\r\n]/.test(label)) {
|
||||
throw new Error('Enter a key label with at most 160 characters');
|
||||
}
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
export function listServerKeys(): ServerKey[] {
|
||||
const rows = db().prepare('SELECT id,game,label,updated_at FROM server_keys ORDER BY game,label,id').all() as Omit<ServerKey, 'assigned_servers'>[];
|
||||
const assignments = db().prepare(`SELECT DISTINCT k.id AS key_id,l.server_id FROM server_keys k
|
||||
JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key JOIN server_key_leases l ON l.key_id=occupied.id
|
||||
ORDER BY l.server_id`).all() as { key_id: string; server_id: string }[];
|
||||
const byKey = new Map<string, string[]>();
|
||||
for (const lease of assignments) {
|
||||
const servers = byKey.get(lease.key_id) ?? [];
|
||||
servers.push(lease.server_id);
|
||||
byKey.set(lease.key_id, servers);
|
||||
}
|
||||
return rows.map(row => ({ ...row, assigned_servers: byKey.get(row.id) ?? [] }));
|
||||
}
|
||||
|
||||
export function getServerKey(serverId: string): string | undefined {
|
||||
return (db().prepare(`SELECT k.pluto_key FROM server_key_leases l
|
||||
JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1`).get(serverId) as { pluto_key: string } | undefined)?.pluto_key;
|
||||
}
|
||||
|
||||
/** Saving a credential twice cannot increase available capacity. */
|
||||
export function saveServerKey(game: unknown, key: unknown, label?: unknown): string {
|
||||
validGame(game);
|
||||
const credential = normalizedKey(key);
|
||||
const keyLabel = label === undefined ? undefined : normalizedLabel(label);
|
||||
return db().transaction(() => {
|
||||
const existing = db().prepare('SELECT id FROM server_keys WHERE game=? AND pluto_key=?').get(game, credential) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
if (keyLabel !== undefined) db().prepare("UPDATE server_keys SET label=?,updated_at=datetime('now') WHERE id=?").run(keyLabel, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
const id = randomUUID();
|
||||
db().prepare('INSERT INTO server_keys(id,game,label,pluto_key) VALUES(?,?,?,?)').run(id, game, keyLabel ?? '', credential);
|
||||
return id;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
// Compare the credential as well as its row: a mistakenly imported copy in
|
||||
// another game's pool must not permit simultaneous use of the same key.
|
||||
const availableKey = `NOT EXISTS (SELECT 1 FROM server_key_leases l
|
||||
JOIN server_keys occupied ON occupied.id=l.key_id
|
||||
WHERE occupied.pluto_key=k.pluto_key AND (l.server_id!=@server OR l.is_current=0))`;
|
||||
|
||||
export function serverKeyPool(game: KeyGame, serverId?: string): { total: number; available: number } {
|
||||
validGame(game);
|
||||
return db().prepare(`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN ${availableKey} THEN 1 ELSE 0 END),0) AS available
|
||||
FROM server_keys k WHERE k.game=@game`).get({ game, server: serverId ?? '' }) as { total: number; available: number };
|
||||
}
|
||||
|
||||
export function availableServerKeyCount(game: KeyGame): number {
|
||||
return serverKeyPool(game).available;
|
||||
}
|
||||
|
||||
/** Called within launch admission; the write lock prevents competing launches sharing a key. */
|
||||
export function reserveServerKey(serverId: string, game: KeyGame, suppliedKey?: unknown): string {
|
||||
validGame(game);
|
||||
if (!serverId) throw new Error('Choose a server for the key reservation');
|
||||
const credential = suppliedKey === undefined || suppliedKey === null || (typeof suppliedKey === 'string' && !suppliedKey.trim())
|
||||
? undefined : normalizedKey(suppliedKey);
|
||||
return db().transaction(() => {
|
||||
const existing = db().prepare(`SELECT k.game,k.pluto_key FROM server_key_leases l
|
||||
JOIN server_keys k ON k.id=l.key_id WHERE l.server_id=? AND l.is_current=1`).get(serverId) as { game: KeyGame; pluto_key: string } | undefined;
|
||||
if (existing) {
|
||||
if (existing.game !== game) throw new Error('This server already has a key reserved for another game');
|
||||
if (credential && credential !== existing.pluto_key) throw new Error('This server already has a reserved key; destroy it before changing its key');
|
||||
return existing.pluto_key;
|
||||
}
|
||||
const requestedId = credential ? saveServerKey(game, credential) : null;
|
||||
const key = db().prepare(`SELECT k.id,k.pluto_key FROM server_keys k WHERE k.game=@game
|
||||
AND (@requestedId IS NULL OR k.id=@requestedId) AND ${availableKey}
|
||||
ORDER BY k.updated_at,k.id LIMIT 1`).get({ game, server: serverId, requestedId }) as { id: string; pluto_key: string } | undefined;
|
||||
if (!key) throw new Error(credential ? 'This server key is already reserved by another server'
|
||||
: `No available ${game.toUpperCase()} server keys. Add a key or destroy a server to release one.`);
|
||||
db().prepare('INSERT INTO server_key_leases(server_id,key_id) VALUES(?,?)').run(serverId, key.id);
|
||||
return key.pluto_key;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
/** Only after confirmed destruction or a creation cancelled before any host mutation. */
|
||||
export function releaseServerKey(serverId: string): boolean {
|
||||
return db().prepare('DELETE FROM server_key_leases WHERE server_id=?').run(serverId).changes > 0;
|
||||
}
|
||||
|
||||
export function removeServerKey(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
if (db().prepare(`SELECT 1 FROM server_keys k JOIN server_keys occupied ON occupied.pluto_key=k.pluto_key
|
||||
JOIN server_key_leases l ON l.key_id=occupied.id WHERE k.id=?`).get(id)) {
|
||||
throw new Error('This key is reserved by a server. Destroy that server before removing its key.');
|
||||
}
|
||||
return db().prepare('DELETE FROM server_keys WHERE id=?').run(id).changes > 0;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pauseScalingForOperation } from '../autoscaling/ownership.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { releaseServerKey } from '../fleet/server-keys.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { queueHostOperation, releaseHostPorts } from '../hosts/index.js';
|
||||
import { beginDeployment, cancelRemoval, deploymentStage, finishDeployment } from './history.js';
|
||||
@@ -65,10 +66,12 @@ export function cancelCreation(id: string, triggeredBy = 'ui', pausePolicy = tru
|
||||
}
|
||||
|
||||
export function finishCreationCancellation(cleanupId: string): void {
|
||||
const cancellation = db().prepare(`SELECT c.creation_id,n.operation_id FROM native_creation_cancellations c
|
||||
const cancellation = db().prepare(`SELECT c.creation_id,n.operation_id,d.server FROM native_creation_cancellations c
|
||||
JOIN server_deployments d ON d.id=c.creation_id
|
||||
LEFT JOIN native_provisioning n ON n.deployment_id=c.creation_id WHERE c.cleanup_id=?`).get(cleanupId) as
|
||||
{ creation_id: string; operation_id: string | null } | undefined;
|
||||
{ creation_id: string; operation_id: string | null; server: string } | undefined;
|
||||
if (!cancellation) return;
|
||||
releaseServerKey(cancellation.server);
|
||||
const now = new Date().toISOString();
|
||||
db().prepare('UPDATE server_deployments SET server_deleted_at=? WHERE id IN (?,?)').run(now, cancellation.creation_id, cleanupId);
|
||||
deploymentStage(cancellation.creation_id, 'Creation cancelled; cleanup completed', { blocker: null,
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface CreateServerInput {
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
plutoKey?: string; // saved for this server ID when supplied
|
||||
plutoKey?: string; // added to the game pool and reserved for this launch when supplied
|
||||
expectedName?: string; // optional preview guard; never selects a lobby
|
||||
previewId?: string;
|
||||
createdBy?: string; // 'ui' by default
|
||||
@@ -44,6 +44,8 @@ export interface CreateServerInput {
|
||||
export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null {
|
||||
const rows = db()
|
||||
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
|
||||
SELECT server AS name FROM native_instances WHERE retired_at IS NULL UNION
|
||||
SELECT server_id AS name FROM server_key_leases UNION
|
||||
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
|
||||
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.state = 'cancelled' AND d.submitted_at IS NOT NULL) OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`)
|
||||
.all(`${baseName}_%`) as Array<{ name: string }>;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getHost, listHosts, getHostOperationStatus, queueHostOperation, queueHo
|
||||
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
|
||||
import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||
import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js';
|
||||
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
|
||||
import { getServerKey, reserveServerKey, releaseServerKey, availableServerKeyCount } from '../fleet/server-keys.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER, cancelRemoval } from './history.js';
|
||||
@@ -118,7 +118,7 @@ export function previewNativeCreation(input: Record<string, unknown>): LaunchPre
|
||||
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? selection.automatic ? 'UDP/TCP capacity available; port assigned at placement.' : `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
|
||||
{ label: 'Configuration', state: 'pass', detail: recipe.configSource }
|
||||
];
|
||||
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName, checks,
|
||||
return { id, expiresAt, name, port, hasSavedKey: !!name && availableServerKeyCount(recipe.game) > 0, recipe: summarizeRecipe(recipe), blueprintName, checks,
|
||||
ready: checks.every(check => check.state !== 'blocked') };
|
||||
}
|
||||
|
||||
@@ -163,9 +163,7 @@ export function queueNativeCreationIntent(input: { server: string; game: 't5' |
|
||||
const reserved = db().prepare(`SELECT 1 FROM server_creation_jobs j JOIN server_deployments d ON d.id=j.deployment_id WHERE j.server=?
|
||||
AND d.server_deleted_at IS NULL AND (d.state IN ('queued','waiting','running','awaiting-health','interrupted') OR (d.state='cancelled' AND d.submitted_at IS NOT NULL))`).get(input.server);
|
||||
if ((!input.autoscalingDecisionId && input.server !== expected) || nativeInstance(input.server) || getServer(input.server) || reserved) throw new Error('Available lobby changed. Review a fresh preview.');
|
||||
const key = input.plutoKey?.trim() || getServerKey(input.server);
|
||||
if (!key) throw new Error(`Save a Plutonium server key for ${input.server}.`);
|
||||
saveServerKey(input.server, key);
|
||||
reserveServerKey(input.server, input.game, input.plutoKey);
|
||||
const recipe: NativeRecipe = { ...(input.recipe ?? captureRecipePlan(input.game, input.mapSlug)),
|
||||
native: input.selection, image: input.selection.image! };
|
||||
const id = beginDeployment(input.server, 'create', input.createdBy ?? 'ui');
|
||||
@@ -292,6 +290,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(removal.updatedAt, original.id);
|
||||
releaseHostPorts(original.id);
|
||||
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
||||
releaseServerKey(job.server);
|
||||
db().prepare('UPDATE ec_host_migrations SET removed_at=? WHERE deployment_id=?').run(removal.updatedAt, id);
|
||||
recordProgress(id, { event: { kind: 'cleanup', message: 'Cancelled migration: source removal completed; replacement creation was not started.' } });
|
||||
}
|
||||
@@ -314,6 +313,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(operation.updatedAt, instance.id);
|
||||
releaseHostPorts(instance.id);
|
||||
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
||||
releaseServerKey(job.server);
|
||||
finishCreationCancellation(job.id);
|
||||
return;
|
||||
}
|
||||
@@ -539,6 +539,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance!.id);
|
||||
releaseHostPorts(instance!.id);
|
||||
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
||||
releaseServerKey(job.server);
|
||||
recordProgress(job.id, { checkpoint: { key: 'cleanup', label: 'Instance retired and port reservations released' } });
|
||||
complete(job);
|
||||
finishCreationCancellation(job.id);
|
||||
|
||||
@@ -65,9 +65,17 @@
|
||||
if (current === session) {
|
||||
const next = body as CollectionPreview;
|
||||
const names = new Set(next.servers.map(server => server.name));
|
||||
keys = Object.fromEntries(next.servers.filter(server => server.name && !server.hasSavedKey).map(server => [server.name!, keys[server.name!] ?? '']));
|
||||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
|
||||
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
|
||||
const enteredByGame = new Map<ZombieGame, string[]>();
|
||||
for (const server of previous?.servers ?? []) {
|
||||
const value = server.name ? keys[server.name]?.trim() : '';
|
||||
if (!value) continue;
|
||||
const game = previous!.entries[server.entryIndex].recipe.game;
|
||||
enteredByGame.set(game, [...(enteredByGame.get(game) ?? []), value]);
|
||||
}
|
||||
keys = Object.fromEntries(next.servers.filter(server => server.name && !server.hasSavedKey)
|
||||
.map(server => [server.name!, enteredByGame.get(next.entries[server.entryIndex].recipe.game)?.shift() ?? '']));
|
||||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Entered keys were retained for launches in the same game that still need a key.';
|
||||
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for games that still need them.';
|
||||
preview = next; now = Date.now();
|
||||
}
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
@@ -108,7 +116,7 @@
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and server keys. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{:else}
|
||||
{#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; entered keys stay with their game.</p>{/if}
|
||||
<div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection already satisfied'}</strong>
|
||||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
|
||||
@@ -133,7 +141,8 @@
|
||||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
||||
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
|
||||
{#if preview.servers.length || preview.removals.length}
|
||||
{#if preview.servers.length}<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
||||
{#if preview.servers.length}<p class="muted">Available keys are assigned from each game’s pool. Add an unused key for each remaining launch below. Manage pools in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
||||
{#if preview.removals.length && preview.servers.some(server => !server.hasSavedKey)}<p class="muted">Keys held by servers awaiting removal are still in use. To reuse those keys, remove the servers first and refresh this preview after cleanup finishes.</p>{/if}
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
{#if preview.removals.length}
|
||||
<section class="removals" aria-label="Servers to remove">
|
||||
@@ -150,14 +159,14 @@
|
||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||||
{#if server.name}
|
||||
{#if server.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||||
{:else}<label><span>Server key required</span><input aria-label={`Key for ${server.name}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste this server’s key" /></label>{/if}
|
||||
{#if server.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
||||
{:else}<label><span>{preview.entries[server.entryIndex].recipe.game.toUpperCase()} pool key required</span><input aria-label={`Unused ${preview.entries[server.entryIndex].recipe.game.toUpperCase()} key for launch ${index + 1}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste an unused game key" /></label>{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
<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">Pool availability is checked again when you apply. 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>
|
||||
{: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 is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, 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>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||
if (current === session) {
|
||||
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
|
||||
if (previousName && previousName !== body.name) keyNotice = 'The available lobby changed. Any entered key can still be used for this game.';
|
||||
if (body.hasSavedKey) plutoKey = '';
|
||||
preview = body; now = Date.now();
|
||||
await tick(); heading?.focus();
|
||||
@@ -148,18 +148,18 @@
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else if preview}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Entered keys stay with this game when you refresh.</p>{/if}
|
||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
|
||||
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
||||
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
||||
</section>
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>Fresh identity, with its own server key.</p></div>
|
||||
{#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}
|
||||
<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="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>An available key from this game’s pool is assigned when you launch.</p></div>
|
||||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
||||
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste an unused key for this game" /></label>{/if}
|
||||
<p class="muted">{preview.hasSavedKey ? 'An unused key from this game’s pool will be assigned automatically. Availability is checked again at launch.' : 'Add an unused key for this game. Launch saves it in the game pool and assigns it to this server until removal is confirmed.'} <a href={`/server-keys?game=${preview.recipe.game}`} 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 || !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 an unused key for this game to enable launch.</p>{/if}
|
||||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity is checked again at launch. The host and port are assigned automatically.</p>
|
||||
</form>
|
||||
<details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listServerKeys, saveServerKey, removeServerKey, validServerId } from '$lib/server/fleet/server-keys.js';
|
||||
import { listServerKeys, saveServerKey, removeServerKey } from '$lib/server/fleet/server-keys.js';
|
||||
|
||||
export function GET() {
|
||||
return json({ keys: listServerKeys() }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
@@ -8,8 +8,8 @@ export function GET() {
|
||||
export async function PUT({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
try {
|
||||
saveServerKey(body?.serverId, body?.plutoKey);
|
||||
return json({ ok: true });
|
||||
const id = saveServerKey(body?.game, body?.plutoKey, body?.label);
|
||||
return json({ ok: true, id });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export async function PUT({ request }: { request: Request }) {
|
||||
|
||||
export async function DELETE({ request }: { request: Request }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validServerId(body?.serverId)) return json({ error: 'Invalid server ID' }, { status: 400 });
|
||||
return json({ ok: removeServerKey(body.serverId) });
|
||||
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) return json({ error: 'Invalid key ID' }, { status: 400 });
|
||||
try {
|
||||
return json({ ok: removeServerKey(body.id) });
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,15 +94,15 @@
|
||||
<label>Empty duration in minutes<input type="number" min="1" max="1440" required bind:value={settings.emptyMinutes} /></label>
|
||||
<label>Action cooldown in minutes<input type="number" min="1" max="1440" required bind:value={settings.cooldownMinutes} /></label>
|
||||
</div>
|
||||
{#if settings.mapSlug === '*'}<p>Targets apply separately to every map, including all variants. Each map needs its own keys. Actions share one cooldown and run one at a time across the game.</p>{/if}
|
||||
<p>Saved lobby keys must cover the maximum for each selected map before you can save a new policy, including Dry run.</p>
|
||||
{#if settings.mapSlug === '*'}<p>Targets apply separately to every map, including all variants. All maps share this game’s key pool. Actions share one cooldown and run one at a time across the game.</p>{/if}
|
||||
<p>Each new lobby needs an available key from the game pool. Creation waits when the pool is exhausted; keys return after confirmed removal.</p>
|
||||
<div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{#if preview}
|
||||
<div class="preview" aria-live="polite">
|
||||
<h3>Preview · {modeLabel(preview.policy.mode)}</h3>
|
||||
<p>Target: {preview.decision.desired} running lobbies{preview.policy.mapSlug === '*' ? ' across all maps' : ''}. {preview.snapshot.keyedNames.length} saved lobby keys. {preview.snapshot.members.length} existing or pending lobbies.</p>
|
||||
<p>Target: {preview.decision.desired} running lobbies{preview.policy.mapSlug === '*' ? ' across all maps' : ''}. {preview.snapshot.keyPool.available} of {preview.snapshot.keyPool.total} game keys available. {preview.snapshot.members.length} existing or pending lobbies.</p>
|
||||
<p>{preview.decision.action === 'wait' ? 'Wait' : `${preview.decision.action === 'create' ? 'Create' : 'Remove'} ${preview.decision.server}`}: {preview.decision.reason}</p>
|
||||
{#if preview.policy.mode === 'create-only' || preview.policy.mode === 'create-destroy'}<p>Saving gives this policy exclusive control of the selected maps' lobby counts. Pause it before launching a competing collection or manual lifecycle action.</p>{/if}
|
||||
{#if preview.snapshot.members.length}<ul>{#each preview.snapshot.members as member}<li>{member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}</li>{/each}</ul>{/if}
|
||||
@@ -118,7 +118,7 @@
|
||||
<div class="heading"><h2 id={`title-${view.policy.id}`}>{view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}</h2><span>{modeLabel(view.policy.mode)}</span>{#if canOperate}<Button disabled={busy} onclick={() => edit(view.policy)}>Edit or pause</Button><Button variant="danger" disabled={busy || view.unsettled.length > 0} title={view.unsettled.length ? 'Pause the policy and settle outstanding work before deleting it.' : undefined} onclick={() => remove(view.policy)}>Delete policy</Button>{/if}</div>
|
||||
<p class="counts">{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped</p>
|
||||
{#if view.unsettled.length}<div class="banner"><strong>Work is still settling.</strong><p>Pause this policy and settle its outstanding work before deleting it.</p>{#each view.unsettled as operation}<p><a href={`/provisioning/${operation.id}`}>{operation.action} · {operation.state}</a> — {operation.stage}</p>{/each}{#if view.ownsMap}<p>This policy retains ownership of its scope until its outstanding work settles.</p>{/if}</div>{/if}
|
||||
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyedNames.length} saved keys</p>
|
||||
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available</p>
|
||||
<p><strong>{view.policy.mode === 'dry-run' ? 'Hypothetical decision: ' : 'Current decision: '}</strong>{view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}</p>
|
||||
{#if view.policy.mode === 'create-only' && view.decision.action === 'destroy'}<p>Removal is advisory in Create only mode.</p>{/if}
|
||||
<p class="muted">Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}{view.policy.nextActionAt > Date.now() ? ` · Next action after ${$timeFormat.dateTime(new Date(view.policy.nextActionAt).toISOString())}` : ''}</p>
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
{#if detail.canResumeRebuild}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Rebuild cleanup requested. Fresh creation waits for local cleanup to finish.')}>Resume rebuild cleanup</Button>{/if}
|
||||
{#if detail.canResumeCleanup}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Local cleanup requested. Remote container deletion will not be repeated.')}>Resume cleanup</Button>{/if}
|
||||
{#if detail.canCancel}<div><Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Operation cancelled. Already-submitted commands may finish; no further steps will be started.')}>Cancel operation</Button><p class="action-help">Stops remaining work and health checks. Changes already made are not undone.</p></div>{/if}
|
||||
{#if detail.canCancelCreation}<div><Button variant="danger" disabled={!!view.busy} loading={view.busy === 'cancel-creation'} onclick={() => controller.act('cancel-creation', 'Creation cancelled. Follow cleanup below until removal is confirmed.')}>Cancel creation and remove server</Button><p class="action-help">Stops this launch and deletes its container and instance files, even if player status is unknown or players have joined. Shared game files and the saved server key are kept.</p></div>{/if}
|
||||
{#if detail.canCancelCreation}<div><Button variant="danger" disabled={!!view.busy} loading={view.busy === 'cancel-creation'} onclick={() => controller.act('cancel-creation', 'Creation cancelled. Follow cleanup below until removal is confirmed.')}>Cancel creation and remove server</Button><p class="action-help">Stops this launch and deletes its container and instance files, even if player status is unknown or players have joined. Shared game files are kept. The key returns to its game pool after removal is confirmed.</p></div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if detail.cancellation}<div class="recovery-note"><strong>{detail.cancellation.state === 'done' ? 'Cancellation cleanup completed' : 'Cancellation cleanup'}</strong><p>{detail.cancellation.error ?? detail.cancellation.stage}</p><a href={`/provisioning/${encodeURIComponent(detail.cancellation.cleanupId)}`}>View cleanup progress →</a></div>{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { zombieMaps, zombieMapBySlug, type ZombieGame } from '$lib/map-aliases.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
@@ -15,31 +15,34 @@
|
||||
import Field from '$lib/ui/components/primitives/Field.svelte';
|
||||
import Input from '$lib/ui/components/primitives/Input.svelte';
|
||||
|
||||
interface SavedKey { server_id: string; deployed: number; updated_at: string }
|
||||
interface SavedKey { id: string; game: ZombieGame; label: string; assigned_servers: string[]; updated_at: string }
|
||||
const games: ZombieGame[] = ['t5', 't6'];
|
||||
let open = $state(false);
|
||||
let search = $state('');
|
||||
let gameFilter = $state('');
|
||||
let limit = $state(25);
|
||||
let keys = $state<SavedKey[]>([]);
|
||||
let serverId = $state('');
|
||||
let editing = $state(false);
|
||||
let rawMode = $state(false);
|
||||
let game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('theater');
|
||||
let lobby = $state(1);
|
||||
const chosenId = $derived(editing || rawMode ? serverId.trim() : `${game}_zombie_${mapSlug}_${lobby}`);
|
||||
const validId = $derived.by(() => {
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(chosenId);
|
||||
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||
});
|
||||
$effect(() => { search = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||
function saveSearch() { const url = new URL($page.url); if (search) url.searchParams.set('server', search); else url.searchParams.delete('server'); if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); void goto(url, { replaceState: true, keepFocus: true, noScroll: true }); }
|
||||
let label = $state('');
|
||||
let plutoKey = $state('');
|
||||
let busy = $state(false);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
const filtered = $derived(keys.filter(key => (key.server_id + ' ' + serverTitle(key.server_id)).toLowerCase().includes(search.toLowerCase())));
|
||||
|
||||
$effect(() => {
|
||||
search = $page.url.searchParams.get('search') ?? $page.url.searchParams.get('server') ?? '';
|
||||
gameFilter = $page.url.searchParams.get('game') ?? '';
|
||||
limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25);
|
||||
});
|
||||
const filtered = $derived(keys.filter(key => (!gameFilter || key.game === gameFilter)
|
||||
&& [key.game, key.label, ...key.assigned_servers.flatMap(name => [name, serverTitle(name)])].join(' ').toLowerCase().includes(search.toLowerCase())));
|
||||
function saveSearch() {
|
||||
const url = new URL($page.url); url.searchParams.delete('server');
|
||||
if (search) url.searchParams.set('search', search); else url.searchParams.delete('search');
|
||||
if (gameFilter) url.searchParams.set('game', gameFilter); else url.searchParams.delete('game');
|
||||
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
||||
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
async function load() {
|
||||
const res = await fetch('/api/server-keys');
|
||||
if (!res.ok) throw new Error('Could not load server keys');
|
||||
@@ -47,39 +50,36 @@
|
||||
}
|
||||
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
||||
onMount(() => { void reload(); });
|
||||
function reset() { serverId = ''; plutoKey = ''; editing = false; rawMode = false; }
|
||||
function addKey() {
|
||||
reset(); error = '';
|
||||
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec($page.url.searchParams.get('server') ?? '');
|
||||
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
|
||||
plutoKey = ''; label = ''; error = '';
|
||||
if (gameFilter === 't5' || gameFilter === 't6') game = gameFilter;
|
||||
open = true;
|
||||
}
|
||||
function savedAt(value: string) { return $timeFormat.dateTime(value); }
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!validId || !plutoKey.trim() || busy) return;
|
||||
if (!plutoKey.trim() || busy) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const res = await fetch('/api/server-keys', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverId: chosenId, plutoKey })
|
||||
body: JSON.stringify({ game, label, plutoKey })
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
||||
reset(); open = false; notice = 'Server key saved.'; await load();
|
||||
plutoKey = ''; label = ''; open = false; notice = 'Key saved to the game pool.'; await load();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function remove(id: string) {
|
||||
if (!await confirmAction({title:'Remove saved key?',message:`Remove the key for ${serverTitle(id)} (${id})? Running servers are unaffected.`,confirmLabel:'Remove key',danger:true})) return;
|
||||
async function remove(key: SavedKey) {
|
||||
if (key.assigned_servers.length) return;
|
||||
if (!await confirmAction({ title: 'Remove saved key?', message: `Remove ${key.label || 'this key'} from the ${key.game.toUpperCase()} pool?`, confirmLabel: 'Remove key', danger: true })) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const res = await fetch('/api/server-keys', {
|
||||
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverId: id })
|
||||
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: key.id })
|
||||
});
|
||||
if (!res.ok) throw new Error('Could not remove key');
|
||||
if (serverId === id) reset();
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not remove key');
|
||||
notice = 'Saved key removed.'; await load();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
@@ -88,72 +88,70 @@
|
||||
|
||||
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
||||
<SectionHeader title="Keys" subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<p class="key-help">Saved keys are reused when a server is created or rebuilt. Electric Cherry does not check whether Plutonium accepts them. Updating or removing a saved key does not change an existing server.</p>
|
||||
<div class="ui-toolbar"><input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div>
|
||||
|
||||
<p class="key-help">A key stays assigned while its server exists, including when stopped, and becomes available after removal is confirmed. Rebuilds keep the same key. Use the optional label to record its Plutonium hostname; saving a label does not change that hostname.</p>
|
||||
{#if !loading}<div class="pools" aria-label="Game key pools">{#each games as poolGame}
|
||||
{@const pool = keys.filter(key => key.game === poolGame)}
|
||||
<div><strong>{poolGame === 't5' ? 'Black Ops I' : 'Black Ops II'}</strong><span>{pool.filter(key => !key.assigned_servers.length).length} available · {pool.filter(key => key.assigned_servers.length).length} assigned · {pool.length} total</span></div>
|
||||
{/each}</div>{/if}
|
||||
<div class="ui-toolbar">
|
||||
<input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search labels or assigned servers…" />
|
||||
<select aria-label="Filter keys by game" bind:value={gameFilter} onchange={() => { limit = 25; saveSearch(); }}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
|
||||
</div>
|
||||
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
<h2>Saved keys</h2>
|
||||
{#if loading}<p>Loading keys…</p>
|
||||
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
|
||||
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key to a game pool here or when launching a server." />
|
||||
{:else}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Server ID</th><th>Server</th><th>Last saved</th><th>Actions</th></tr></thead>
|
||||
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
|
||||
<thead><tr><th>Game / label</th><th>Availability</th><th>Last saved</th><th>Actions</th></tr></thead>
|
||||
<tbody>{#each filtered.slice(0, limit) as key (key.id)}
|
||||
<tr>
|
||||
<td>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td>
|
||||
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
|
||||
<td>{savedAt(key.updated_at)}</td>
|
||||
<td><div class="actions">
|
||||
<Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
|
||||
<Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
|
||||
</div></td>
|
||||
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td>
|
||||
<td>{#if key.assigned_servers.length}<span>Assigned</span>{#each key.assigned_servers as name}<div class="assignment"><ServerIdentity {name} /><code>{name}</code></div>{/each}{:else}<span class="available">Available</span>{/if}</td>
|
||||
<td>{$timeFormat.dateTime(key.updated_at)}</td>
|
||||
<td><Button ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} disabled={busy || !!key.assigned_servers.length} onclick={() => remove(key)}>Remove</Button>{#if key.assigned_servers.length}<small>In use</small>{/if}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{/if}
|
||||
|
||||
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
|
||||
</div>
|
||||
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
|
||||
<Dialog bind:open title="Add key" busy={busy}>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<form onsubmit={save}>
|
||||
{#if !editing}<label class="raw-toggle"><input type="checkbox" bind:checked={rawMode} disabled={busy} onchange={() => { if (!rawMode) serverId = `${game}_zombie_${mapSlug}_${lobby}`; }} /> Enter a raw server ID</label>{/if}
|
||||
{#if editing || rawMode}<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1." error={!validId && serverId ? 'Choose a recognized map and lobby number 1–20.' : null}>
|
||||
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
||||
</Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if}
|
||||
{#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if}
|
||||
<Field label="Plutonium server key" hint="Used when this server ID is created or rebuilt. Existing servers keep their current key.">
|
||||
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
||||
</Field>
|
||||
<div class="actions">
|
||||
<Button type="submit" variant="primary" disabled={busy || loading || !validId || !plutoKey.trim()} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
||||
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
|
||||
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
|
||||
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
|
||||
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
|
||||
</Field>
|
||||
<Field label="Plutonium server key" hint="Available to any new lobby in this game. Existing credentials are never displayed.">
|
||||
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
||||
</Field>
|
||||
<div class="actions"><Button type="submit" variant="primary" disabled={busy || loading || !plutoKey.trim()} loading={busy}>Save key</Button><Button onclick={() => open = false} disabled={busy}>Cancel</Button></div>
|
||||
</form>
|
||||
</Dialog>
|
||||
<style>
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.page { display: grid; gap: 24px; }
|
||||
.key-help { margin: 0; font-size: var(--text-sm); line-height: 1.65; }
|
||||
.id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; }
|
||||
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
|
||||
|
||||
.pools { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||
.pools > div { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.pools span, .key-label, small { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.key-label, small { display: block; margin-top: 6px; }
|
||||
.assignment { margin-top: 8px; }
|
||||
.available { color: var(--state-online); }
|
||||
h2 { font-size: var(--text-md); }
|
||||
p { color: var(--text-secondary); }
|
||||
form { display: grid; gap: var(--space-4); max-width: 620px; padding: var(--space-6); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); margin: var(--space-6) 0; }
|
||||
|
||||
form, label { display: grid; gap: var(--space-4); }
|
||||
label { gap: 8px; font-size: 14px; }
|
||||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }
|
||||
.actions { display: flex; gap: var(--space-2); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
th { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
code { font-family: var(--font-mono); }
|
||||
code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||||
.error { color: var(--state-danger); }
|
||||
.page { display:grid; gap:24px; }
|
||||
form { margin:0; padding:0; border:0; }
|
||||
code { display:block; font-size:12px; color:var(--text-secondary); margin-top:6px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user