Add deployment blueprints and reviewed launch recipes

This commit is contained in:
2026-09-17 23:43:12 -04:00
parent edd74f09de
commit 782c7c0bda
17 changed files with 975 additions and 319 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { ZombieGame } from './map-aliases.js';
export interface LaunchFileSummary {
name: string;
kind: 'script' | 'plugin' | 'config';
path: string;
versionId: number | null;
version: number | null;
sha256: string;
size: number;
}
export interface LaunchRecipeSummary {
game: ZombieGame;
mapSlug: string;
mapName: string;
expectedMap: string;
capturedAt: string;
image: string;
configFile: string;
configSource: string;
files: LaunchFileSummary[];
fingerprint: string;
}
export interface BlueprintSummary {
id: string;
name: string;
description: string;
createdAt: string;
recipe: LaunchRecipeSummary;
}
export interface LaunchPreview {
id: string;
expiresAt: string;
name: string | null;
port: number | null;
hasSavedKey: boolean;
recipe: LaunchRecipeSummary;
blueprintName: string | null;
checks: { label: string; state: 'pass' | 'blocked' | 'info'; detail: string }[];
ready: boolean;
}
+26
View File
@@ -335,6 +335,32 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs (
expected_map TEXT NOT NULL
);
-- Launch recipes copy managed files, not credentials from the key/integration stores.
-- Independent of the artifact library so history survives version deletion.
CREATE TABLE IF NOT EXISTS server_blueprints (
id TEXT PRIMARY KEY,
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
recipe TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_launch_previews (
id TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
server TEXT,
port INTEGER,
recipe TEXT NOT NULL,
blueprint_name TEXT,
deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS server_launch_recipes (
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE,
recipe TEXT NOT NULL,
blueprint_name TEXT
);
-- Persist state transitions atomically, including changes made by background workers.
-- Polls with unchanged status/error do not produce duplicate activity entries.
CREATE TABLE IF NOT EXISTS beta_events (
+91 -32
View File
@@ -1,5 +1,8 @@
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
import { randomUUID } from 'node:crypto';
import type { LaunchPreview } from '$lib/deployment-plan.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
@@ -19,10 +22,9 @@ import {
type DeploymentEnvVar
} from '../integrations/komodo.js';
import { kick } from '../pipeline/worker.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { MAP_CONFIG_FILE } from './server-config.js';
import { recordScript } from './inventory.js';
import { sha256Buffer } from './files.js';
import { promotedArtifacts } from './promoted-artifacts.js';
import { getVersion } from './scripts.js';
export type CreateServerFailure =
| 'bad-request'
@@ -48,6 +50,7 @@ export interface CreateServerInput {
mapSlug: string; // 'cosmodrome'
plutoKey?: string; // saved for this server ID when supplied
expectedName?: string; // optional preview guard; never selects a lobby
previewId?: string;
createdBy?: string; // 'ui' by default
}
@@ -121,11 +124,12 @@ function buildSpec(args: {
port: number;
hostDir: string;
plutoKey: string;
recipe: LaunchRecipe;
}): CreateDeploymentSpec {
return {
name: args.name,
server: config.komodo.targetServer,
image: config.komodo.defaultImage,
image: args.recipe.image,
network: config.komodo.defaultNetwork,
// Container listens on PLUTAINER_PORT (== args.port), so host and
// container ports match — no NAT inside the bridge network.
@@ -146,11 +150,74 @@ interface PreparedCreation {
ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry;
}
interface PreviewRow {
id: string; expires_at: string; server: string | null; port: number | null;
recipe: string; blueprint_name: string | null; deployment_id: string | null;
}
/** Read-only checks; a preview never reserves a slot or writes credentials. */
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
let recipe: LaunchRecipe;
let blueprintName: string | null = null;
if (input.blueprintId !== undefined) {
if (typeof input.blueprintId !== 'string') throw new Error('Invalid blueprint.');
({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
} else if (input.deploymentId !== undefined) {
if (typeof input.deploymentId !== 'string') throw new Error('Invalid deployment.');
const source = recipeForDeployment(input.deploymentId);
if (!source) throw new Error('This deployment has no recorded launch recipe.');
({ recipe, blueprintName } = source);
} else {
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
recipe = captureRecipe(input.game, input.mapSlug);
}
const lobby = nextLobbyNumber(`${recipe.game}_zombie_${recipe.mapSlug}`);
const name = lobby === null ? null : `${recipe.game}_zombie_${recipe.mapSlug}_${lobby}`;
const port = nextAvailablePort();
const configured = !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret && config.komodo.targetServer);
const compatible = recipe.configFile === configFileForGame(recipe.game);
const checks: LaunchPreview['checks'] = [
{ label: 'Komodo settings', state: configured ? 'pass' : 'blocked', detail: configured ? 'Connection settings and target host are configured.' : 'Configure Komodo credentials and a target host before launching.' },
{ label: 'Health verification', state: config.rcon.password ? 'pass' : 'blocked', detail: config.rcon.password ? 'RCON is configured. Container, RCON, and map checks run after launch.' : 'Configure RCON_PASSWORD so the new server can be verified.' },
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' },
{ label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` },
{ label: 'Configuration layout', state: compatible ? 'pass' : 'blocked', detail: compatible ? recipe.configSource : 'The configured filename has changed since this recipe was saved. Create a fresh recipe.' },
{ label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' }
];
const id = randomUUID();
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
db().prepare('DELETE FROM server_launch_previews WHERE expires_at < ? AND deployment_id IS NULL').run(new Date().toISOString());
db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName,
checks, ready: checks.every(check => check.state !== 'blocked') };
}
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
return db().transaction((): CreateServerResult => {
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
const recipe: LaunchRecipe | undefined = preview ? JSON.parse(preview.recipe) : undefined;
if (recipe && (recipe.game !== (input.game ?? 't5') || recipe.mapSlug !== input.mapSlug || input.gameMode !== 'zombie')) {
return { ok: false, kind: 'bad-request', message: 'The selection does not match the reviewed launch recipe.' };
}
// A repeated submit after a lost response returns the same operation.
if (preview?.deployment_id) return { ok: true, name: preview.server!, port: preview.port!, deploymentId: preview.deployment_id };
if (preview && preview.expires_at <= new Date().toISOString()) return { ok: false, kind: 'bad-request', message: 'Launch preview expired. Review a fresh preview.' };
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) {
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
}
if (recipe && (recipe.configFile !== configFileForGame(recipe.game) || !config.rcon.password)) {
return { ok: false, kind: 'unconfigured', message: 'Configuration or health-check settings changed. Refresh the preview.' };
}
const prepared = prepareCreation(input);
if (!prepared.ok) return prepared;
const frozenRecipe = recipe ?? captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
const id = beginDeployment(prepared.name, 'create', input.createdBy);
db().prepare('INSERT INTO server_launch_recipes (deployment_id, recipe, blueprint_name) VALUES (?, ?, ?)')
.run(id, JSON.stringify(frozenRecipe), preview?.blueprint_name ?? null);
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id = ? WHERE id = ?').run(id, preview.id);
db().prepare("UPDATE server_deployments SET state = 'queued' WHERE id = ?").run(id);
db().prepare('INSERT INTO server_creation_jobs (deployment_id, server, port, game_mode, map_slug, expected_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, prepared.name, prepared.port, input.gameMode, prepared.mapEntry.slug, prepared.mapEntry.canonical);
@@ -159,11 +226,19 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
})();
}
function nextName(input: CreateServerInput): string | null {
const base = `${input.game ?? 't5'}_${input.gameMode}_${input.mapSlug}`;
const lobby = nextLobbyNumber(base);
return lobby === null ? null : `${base}_${lobby}`;
}
// Await provisioning for internal callers; completion still requires worker health verification.
export async function createServer(input: CreateServerInput): Promise<CreateServerResult> {
if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' };
const queued = queueCreateServer(input);
if (!queued.ok) return queued;
const existing = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(queued.deploymentId) as { state: string };
if (existing.state !== 'queued') return queued;
return await provisionCreation(queued.deploymentId);
}
@@ -218,49 +293,36 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
const { name, port, plutoKey, mapEntry } = prepared;
const game = gameOfServer(name);
const configFile = configFileForGame(game);
const recipe = recipeForDeployment(operation)?.recipe ?? captureRecipe(game, mapEntry.slug);
const configFile = recipe.configFile;
if (configFile !== configFileForGame(game)) return { ok: false, kind: 'bad-request', message: 'Server config filename changed after this launch was queued.' };
deploymentStage(operation, 'Preparing server files');
const hostDir = serverDir(name);
const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
const initialArtifacts = promotedArtifacts(game, mapEntry.canonical);
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
// after — plutainer expects to be able to write to /home/plutainer/app.
try {
await mkdir(hostDir, { recursive: true, mode: 0o777 });
await chmod(hostDir, 0o777);
for (const { kind, script, version } of initialArtifacts) {
const dest = destOfKind(name, kind);
const target = join(dest, script.target_rel_path);
for (const file of recipe.files) {
const dest = destOfKind(name, file.kind);
const target = join(dest, file.path);
await mkdir(dirname(target), { recursive: true, mode: 0o777 });
// Make every newly created ancestor writable by Plutainer, even
// when the manager's umask removed write permissions at mkdir.
for (let dir = dirname(target); dir !== join(hostDir) && dir !== dirname(dir); dir = dirname(dir)) {
await chmod(dir, 0o777);
}
await writeFile(target, version.content);
await writeFile(target, Buffer.from(file.content, 'base64'));
await chmod(target, 0o666);
}
const mapConfigPath = join(destOfKind(name, 'config'), MAP_CONFIG_FILE);
await mkdir(dirname(mapConfigPath), { recursive: true, mode: 0o777 });
await chmod(dirname(mapConfigPath), 0o777);
await writeFile(mapConfigPath, mapConfig);
await chmod(mapConfigPath, 0o666);
if (initialConfig) {
const configPath = join(destOfKind(name, 'config'), configFile);
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
await chmod(dirname(configPath), 0o777);
await writeFile(configPath, initialConfig);
await chmod(configPath, 0o666);
}
} catch (err) {
return { ok: false, kind: 'fs', message: `failed to create ${hostDir}: ${(err as Error).message}` };
}
// Step 2: Komodo CreateDeployment.
deploymentStage(operation, 'Creating Komodo deployment');
const spec = buildSpec({ game, name, port, hostDir, plutoKey });
const spec = buildSpec({ game, name, port, hostDir, plutoKey, recipe });
const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) {
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
@@ -283,15 +345,12 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
).run(
name, name, game, mapEntry.canonical,
input.gameMode, mapEntry.slug, hostDir,
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage,
configFile, game === 't6' ? 't6zm' : 't5sp', recipe.image,
port, input.createdBy ?? 'ui'
);
if (initialConfig) {
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
}
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
for (const { script, version } of initialArtifacts) {
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, 'create');
for (const file of recipe.files) {
const versionId = file.versionId && getVersion(file.versionId) ? file.versionId : null;
recordScript(name, file.path, versionId, file.sha256, file.size, file.path === MAP_CONFIG_FILE ? 'create-map' : 'create');
}
})();
} catch (err) {
+24 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { recipeForDeployment, summarizeRecipe } from './launch-recipes.js';
export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string {
const id = randomUUID();
@@ -25,7 +26,29 @@ export function getDeployment(id: string) {
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id);
if (!deployment) return null;
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
return { deployment, events };
const launch = recipeForDeployment(id);
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
canResumeVerification: canResumeVerification(id) };
}
export function canResumeVerification(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d
JOIN server_creation_jobs j ON j.deployment_id = d.id
JOIN servers s ON s.name = j.server AND s.game_port = j.port
WHERE d.id = ? AND d.action = 'create' AND d.state IN ('failed', 'interrupted')
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
.get(id);
}
/** Resume observation only. Never repeat a container creation or restart. */
export function resumeVerification(id: string): boolean {
return db().transaction(() => {
if (!canResumeVerification(id)) return false;
db().prepare("UPDATE server_deployments SET state = 'awaiting-health', finished_at = NULL, error = NULL WHERE id = ?").run(id);
recordDeploymentEvent(id, 'Resumed verification of the existing server');
deploymentStage(id, 'Waiting for Komodo health');
return true;
})();
}
export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void {
+95
View File
@@ -0,0 +1,95 @@
import { randomUUID } from 'node:crypto';
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js';
import { config, configFileForGame } from '../config.js';
import { db } from '../db/index.js';
import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
import { promotedArtifacts } from './promoted-artifacts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { sha256Buffer } from './files.js';
export interface LaunchRecipe {
format: 1;
game: ZombieGame;
mapSlug: string;
mapName: string;
expectedMap: string;
capturedAt: string;
image: string;
configFile: string;
configSource: string;
files: (LaunchFileSummary & { content: string })[];
}
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
const map = zombieMapBySlug(game, mapSlug);
if (!map) throw new Error('Choose a supported game and map.');
const files: LaunchRecipe['files'] = promotedArtifacts(game, map.canonical).map(({ kind, script, version }) => ({
name: script.name, kind, path: script.target_rel_path, versionId: version.id,
version: version.version, sha256: version.sha256, size: version.size, content: version.content.toString('base64')
}));
const addConfig = (name: string, path: string, content: Buffer) => files.push({
name, path, kind: 'config', versionId: null, version: null,
sha256: sha256Buffer(content), size: content.length, content: content.toString('base64')
});
const composed = getPromotedServerConfig(game, map.canonical, map.slug);
let configSource = 'Plutainer defaults, seeded on first boot';
if (composed) {
const baseline = getBaselineForScope('game', game)!;
const layers = [baseline, ...listPromotedOverlaysForScope('game', game), ...listPromotedOverlaysForScope('map', map.canonical)];
configSource = layers.map(layer => `${layer.name} v${getVersion(layer.current_version_id!)!.version}`).join(' + ');
addConfig('Composed server configuration', configFileForGame(game), composed);
}
addConfig('Selected map', MAP_CONFIG_FILE, getServerMapConfig(game, map.canonical, map.slug)!);
return { format: 1, game, mapSlug, mapName: map.friendly, expectedMap: map.canonical,
capturedAt: new Date().toISOString(), image: config.komodo.defaultImage,
configFile: configFileForGame(game), configSource, files };
}
export function summarizeRecipe(recipe: LaunchRecipe): LaunchRecipeSummary {
const { format: _format, files, ...summary } = recipe;
return { ...summary, files: files.map(({ content: _content, ...file }) => file),
fingerprint: sha256Buffer(Buffer.from(JSON.stringify(recipe))) };
}
export function recipeForDeployment(id: string): { recipe: LaunchRecipe; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
.get(id) as { recipe: string; blueprint_name: string | null } | undefined;
return row ? { recipe: JSON.parse(row.recipe), blueprintName: row.blueprint_name } : null;
}
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
const row = db().prepare('SELECT recipe, name FROM server_blueprints WHERE id = ?').get(id) as { recipe: string; name: string } | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
return { recipe: JSON.parse(row.recipe), blueprintName: row.name };
}
export function listBlueprints(): BlueprintSummary[] {
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as {
id: string; name: string; description: string; created_at: string; recipe: string;
}[]).map(row => ({ id: row.id, name: row.name, description: row.description,
createdAt: row.created_at, recipe: summarizeRecipe(JSON.parse(row.recipe)) }));
}
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary {
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.');
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.');
if ((typeof input.previewId === 'string') === (typeof input.deploymentId === 'string')) throw new Error('Choose exactly one launch preview or recorded deployment.');
let recipe: LaunchRecipe | undefined;
if (typeof input.previewId === 'string') {
const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
.get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined;
if (row) recipe = JSON.parse(row.recipe);
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE').get(input.name.trim())) throw new Error('A blueprint with this name already exists. Choose a new name.');
const id = randomUUID();
const createdAt = new Date().toISOString();
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
.run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe));
return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) };
}
export function deleteBlueprint(id: string): boolean {
return db().prepare('DELETE FROM server_blueprints WHERE id = ?').run(id).changes > 0;
}
+125 -273
View File
@@ -1,302 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
import Select from './primitives/Select.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false) }: { open?: boolean } = $props();
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; onblueprint?: () => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintId = $state('');
let maps = $derived(catalogs[game]);
let mapsError = $state<string | null>(null);
let mapsLoaded = $state(false);
let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>('');
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null);
let plutoKey = $state('');
let savedIds = $state<string[]>([]);
let deployedIds = $state<string[]>([]);
let keysLoaded = $state(false);
let deploying = $state(false);
let flash = $state<string | null>(null);
let busy = $state(false);
let loaded = $state(false);
let error = $state('');
let blueprintName = $state('');
let description = $state('');
let savedName = $state('');
let saving = $state(false);
let wasOpen = false;
let session = 0;
onMount(async () => {
try {
const res = await fetch('/api/maps');
if (!res.ok) throw new Error('Could not load map catalog');
const body = (await res.json()) as { t5Zombie: ZombieMapEntry[]; t6Zombie: ZombieMapEntry[] };
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (maps.length > 0) mapSlug = maps[0].slug;
} catch (err) {
mapsError = (err as Error).message;
} finally {
mapsLoaded = true;
}
});
let wasOpen = $state(false);
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) {
reset();
dialog.showModal();
} else if (!open && wasOpen) {
if (dialog.open) dialog.close();
}
wasOpen = open;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
function reset(): void {
plutoKey = '';
void loadKeys();
flash = null;
deploying = false;
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
}
async function loadKeys() {
keysLoaded = false;
async function initialize() {
const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId;
try {
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]);
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.');
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id);
const fleet = await serversRes.json();
deployedIds = [...fleet.servers.map((s: { name: string }) => s.name), ...(fleet.reservedNames ?? [])];
keysLoaded = true;
} catch (err) { flash = (err as Error).message; }
}
function close(): void { open = false; }
function onDialogClick(e: MouseEvent): void {
if (e.target === dialog) close();
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.');
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
if (current !== session) return;
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
blueprints = blueprintBody.blueprints;
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This blueprint is no longer available. Close this dialog and choose another starting point.');
loaded = true;
if (sourceDeploymentId) await review();
} catch (err) { if (current === session) error = (err as Error).message; }
}
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
.find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null);
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : '');
let hasSavedKey = $derived(savedIds.includes(previewName));
$effect(() => { game; mapSlug; lobby; plutoKey = ''; });
$effect(() => {
if (maps.length && !maps.some((entry) => entry.slug === mapSlug)) mapSlug = maps[0].slug;
});
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault();
if (deploying || !keysLoaded || lobby === null) return;
if (!selectedMap) { flash = 'Pick a map'; return; }
if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; }
deploying = true;
flash = null;
async function review() {
if (busy) return;
const current = session;
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
try {
const res = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
});
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
if (!res.ok || !body.ok || !body.deploymentId) {
flash = body.error ?? 'Deploy failed';
if (body.kind === 'no-slot') await loadKeys();
deploying = false;
return;
}
close();
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) preview = body;
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
async function launch() {
if (!preview || !preview.ready || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
expectedName: preview.name, previewId: preview.id, plutoKey: plutoKey.trim() || undefined }) });
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
open = false;
await goto(`/deployments/${encodeURIComponent(body.deploymentId)}`);
} catch (err) {
flash = (err as Error).message;
deploying = false;
}
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function save() {
if (!preview || saving) return;
const current = session;
saving = true; error = '';
try {
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.');
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) saving = false; }
}
</script>
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}>
<form class="dlg" onsubmit={submit}>
<header class="dlg-head">
<h3>Deploy new server</h3>
<button type="button" class="close" onclick={close} aria-label="Close">×</button>
</header>
{#if mapsError}
<div class="alert alert-danger">Failed to load map catalog: {mapsError}</div>
{:else if !mapsLoaded}
<div class="hint">Loading map catalog…</div>
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer">
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</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>
{#if error}<div class="error" role="alert">{error}</div>{/if}
{#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {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>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
<p class="muted">{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
</form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save blueprint</Button></form>
{/if}
</details>
{:else if sourceDeploymentId}
<p class="muted">{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
{:else}
<div class="fields">
<Field label="Game">
<Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} />
</Field>
<Field label="Mode">
<Select bind:value={gameMode} options={modeOptions} disabled={deploying} />
</Field>
<Field label="Map" hint="Loads automatically when the server starts. No map overlay needed.">
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}>
<Input
type="password"
bind:value={plutoKey}
placeholder={hasSavedKey ? "Saved key ready" : "paste key"}
autocomplete="off"
disabled={deploying}
/>
</Field>
</div>
<div class="preview">
<span class="preview-k">Will create</span>
<span class="preview-v">{previewName || '—'}</span>
<span class="preview-sub">{!keysLoaded ? "Loading available lobby..." : lobby === null ? "All lobby slots for this map are in use" : "Lowest available lobby; port auto-assigned"}</span>
</div>
<a href="/server-keys" onclick={close}>Manage saved keys</a>
<div class="actions">
<Button variant="ghost" onclick={close} disabled={deploying}>Cancel</Button>
<Button
type="submit"
variant="primary"
disabled={deploying || !selectedMap || !keysLoaded || lobby === null}
loading={deploying}
>
Deploy server
</Button>
</div>
<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.recipe.mapName}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><LaunchRecipe recipe={selectedBlueprint.recipe} />{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.</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}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the exact managed files, 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)}>Preview launch →</Button></div>
</form>
{/if}
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
</form>
</div>
</dialog>
<style>
dialog {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: 0;
max-width: 540px;
width: calc(100vw - 40px);
box-shadow: var(--shadow-raised);
}
dialog[open] { animation: dlg-in var(--dur-base) var(--ease-standard); }
@keyframes dlg-in {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dlg {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dlg h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.close {
appearance: none;
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: var(--text-xl);
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
}
.close:hover { color: var(--text-primary); background: var(--surface-raised); }
.close:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(4) {
grid-column: 1 / -1;
}
.preview {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-left: 2px solid var(--accent-primary);
border-radius: var(--radius-md);
}
.preview-k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.preview-v {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
.preview-sub {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.alert {
margin: 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: var(--text-sm);
}
.alert-danger {
background: rgba(255, 86, 112, 0.08);
border-color: rgba(255, 86, 112, 0.28);
color: var(--state-danger);
}
.hint {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
@media (max-width: 480px) {
.fields { grid-template-columns: 1fr; }
}
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.eyebrow { font-size: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 8px; padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: 11px; }
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: 8px; font-size: 13px; }.success { color: var(--accent-primary-hover); }
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: 10px; } h2 { font-size: 22px; } }
</style>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe }: { recipe: LaunchRecipeSummary } = $props();
</script>
<div class="recipe">
<div class="recipe-heading"><div><span class="eyebrow">Launch recipe</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
</dl>
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>Captured {new Date(recipe.capturedAt).toLocaleString()}. Managed file contents are saved with this recipe. Image tags and Plutainer defaults can change; use an image digest and a promoted baseline for more reproducible launches.</p>
<p>Future fleet promotions and drift repair still follow the server’s game and map.</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details>
</div>
<style>
.recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
.mono, code, .fingerprint { font-family: var(--font-mono); }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: 11px; } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
</style>