From 3adb45886ab32f8b5ccde35e456e6d1df6266a37 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Sat, 5 Sep 2026 13:10:36 -0700 Subject: [PATCH] overhaul --- .env.example | 21 +- README.md | 31 +- apps/api/package.json | 2 + apps/api/src/app.ts | 17 +- apps/api/src/env.ts | 11 +- apps/api/src/index.ts | 115 +- apps/api/src/lib/errors.ts | 3 + .../modules/config/config-editor-service.ts | 188 ++ .../src/modules/config/config-file-gateway.ts | 155 +- apps/api/src/modules/config/config-sync.ts | 38 +- .../src/modules/config/config-tree.test.ts | 97 + apps/api/src/modules/config/config-tree.ts | 146 ++ apps/api/src/modules/config/mods-service.ts | 309 ++- .../src/modules/config/performance-service.ts | 161 +- .../config/reforger-config-file.test.ts | 6 +- .../api/src/modules/config/startup-mirrors.ts | 80 + .../modules/pterodactyl/console-hub.test.ts | 129 ++ .../src/modules/pterodactyl/console-hub.ts | 148 ++ .../modules/pterodactyl/mock-console-hub.ts | 106 + .../src/modules/pterodactyl/mock-provider.ts | 57 +- .../pterodactyl/pterodactyl-provider.ts | 27 +- apps/api/src/modules/pterodactyl/types.ts | 12 + .../src/modules/pterodactyl/wings-socket.ts | 240 +++ .../reforger-logs/missions-catalog.test.ts | 184 +- .../modules/reforger-logs/missions-catalog.ts | 304 ++- .../src/modules/servers/metrics-service.ts | 68 + .../src/modules/servers/resource-history.ts | 14 +- apps/api/src/modules/servers/server-routes.ts | 609 +++--- .../modules/workshop/workshop-cache.test.ts | 149 ++ .../src/modules/workshop/workshop-cache.ts | 339 +++ .../modules/workshop/workshop-client.test.ts | 271 ++- .../src/modules/workshop/workshop-client.ts | 563 +++-- .../src/modules/workshop/workshop-routes.ts | 87 +- apps/api/test/auth-routes.test.ts | 39 +- apps/api/test/mods-service.test.ts | 268 ++- apps/api/test/performance-service.test.ts | 113 +- apps/web/src/App.tsx | 51 +- apps/web/src/api/hooks.ts | 530 +++-- apps/web/src/components/charts.tsx | 170 +- apps/web/src/components/config/key-editor.tsx | 293 +++ .../web/src/components/config/mirror-hints.ts | 33 + apps/web/src/components/config/raw-editor.tsx | 107 + apps/web/src/components/icons.tsx | 127 ++ apps/web/src/components/layout.tsx | 180 +- apps/web/src/components/mission-card.tsx | 295 ++- apps/web/src/components/mods/browse-panel.tsx | 239 +++ .../web/src/components/mods/changeset-bar.tsx | 115 ++ apps/web/src/components/mods/changeset.ts | 137 ++ .../components/mods/import-server-dialog.tsx | 236 +++ .../src/components/mods/installed-panel.tsx | 263 +++ .../src/components/mods/mod-detail-dialog.tsx | 168 ++ .../src/components/mods/version-dialog.tsx | 160 ++ apps/web/src/components/performance-form.tsx | 295 +-- apps/web/src/components/startup-vars-card.tsx | 45 +- apps/web/src/components/ui.tsx | 753 ++++++- apps/web/src/components/widgets.tsx | 169 +- apps/web/src/index.css | 330 ++- apps/web/src/pages/configuration.tsx | 84 + apps/web/src/pages/console.tsx | 226 ++ apps/web/src/pages/logs.tsx | 127 -- apps/web/src/pages/mission.tsx | 18 + apps/web/src/pages/mods.tsx | 1820 +++-------------- apps/web/src/pages/overview.tsx | 210 +- apps/web/src/pages/simple-pages.tsx | 273 ++- package-lock.json | 33 + packages/shared/src/types.ts | 312 ++- 66 files changed, 9227 insertions(+), 3679 deletions(-) create mode 100644 apps/api/src/modules/config/config-editor-service.ts create mode 100644 apps/api/src/modules/config/config-tree.test.ts create mode 100644 apps/api/src/modules/config/config-tree.ts create mode 100644 apps/api/src/modules/config/startup-mirrors.ts create mode 100644 apps/api/src/modules/pterodactyl/console-hub.test.ts create mode 100644 apps/api/src/modules/pterodactyl/console-hub.ts create mode 100644 apps/api/src/modules/pterodactyl/mock-console-hub.ts create mode 100644 apps/api/src/modules/pterodactyl/wings-socket.ts create mode 100644 apps/api/src/modules/servers/metrics-service.ts create mode 100644 apps/api/src/modules/workshop/workshop-cache.test.ts create mode 100644 apps/api/src/modules/workshop/workshop-cache.ts create mode 100644 apps/web/src/components/config/key-editor.tsx create mode 100644 apps/web/src/components/config/mirror-hints.ts create mode 100644 apps/web/src/components/config/raw-editor.tsx create mode 100644 apps/web/src/components/icons.tsx create mode 100644 apps/web/src/components/mods/browse-panel.tsx create mode 100644 apps/web/src/components/mods/changeset-bar.tsx create mode 100644 apps/web/src/components/mods/changeset.ts create mode 100644 apps/web/src/components/mods/import-server-dialog.tsx create mode 100644 apps/web/src/components/mods/installed-panel.tsx create mode 100644 apps/web/src/components/mods/mod-detail-dialog.tsx create mode 100644 apps/web/src/components/mods/version-dialog.tsx create mode 100644 apps/web/src/pages/configuration.tsx create mode 100644 apps/web/src/pages/console.tsx delete mode 100644 apps/web/src/pages/logs.tsx create mode 100644 apps/web/src/pages/mission.tsx diff --git a/.env.example b/.env.example index 16c15d8..f235d87 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,12 @@ OWNER_DISCORD_ID= DEV_AUTH_BYPASS=false # --- Reforger Workshop API (backend-only, never called from the browser) --- +# The panel uses the v2 API. Responses are cached in-process with +# stale-while-revalidate, so the Mods page does not wait on the network. REFORGER_WORKSHOP_API_BASE_URL=https://api.reforgermods.net +# Optional. The free public tier needs no credentials; set a key only if you +# have a paid reforgermods.net plan and want its higher rate limits. +REFORGER_WORKSHOP_API_KEY= # --- Pterodactyl (Client API, not Application API) --- # Leave USE_MOCK_PTERODACTYL=true to run everything locally with mock data. @@ -33,14 +38,22 @@ PTERODACTYL_CLIENT_API_KEY= # The short server identifier from the Pterodactyl server URL, e.g. "1a2b3c4d". PTERODACTYL_SERVER_ID= USE_MOCK_PTERODACTYL=true +# Proxy Pterodactyl's Wings websocket for the live console and real-time +# resource metrics. This is what makes install, update and mod-download output +# visible in the panel; without it you only see the game's own log file, which +# does not exist until the game has already started. Set to false to fall back +# to REST polling. +PTERODACTYL_WEBSOCKET_ENABLED=true -# --- Reforger config import --- -# Path of the server's config.json in the Pterodactyl file manager. Imported -# read-only to populate the Configuration pages, server name, and max players. +# --- Reforger config --- +# Path of the server's config.json in the Pterodactyl file manager. Read live +# on every request and written in place by the Configuration and Mods pages +# (the previous file is always kept as config.json.bak). REFORGER_CONFIG_PATH=/config.json -REFORGER_CONFIG_SYNC_INTERVAL_SECONDS=300 # --- Reforger log ingestion --- +# Powers player sessions and the killfeed, which are parsed out of the game's +# own log file. The live console does not depend on this. # Recommended: set REFORGER_LOG_DIRECTORY (e.g. /profile/logs) and the panel # follows the newest logs_* dated subfolder automatically on every sync. # REFORGER_ADMIN_LOG_PATH pins one exact file and overrides discovery. diff --git a/README.md b/README.md index c6c9c76..8f6cb8e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,27 @@ # Reforger Panel -Reforger Panel is a private web control panel for an Arma Reforger server hosted through Pterodactyl. It provides a focused interface for trusted server staff to monitor the server, manage access, review player activity, inspect configuration, search Workshop mods, and run limited power actions without exposing Pterodactyl credentials to the browser. +Reforger Panel is a private web control panel for an Arma Reforger server hosted through Pterodactyl. It gives trusted server staff a focused interface to watch the server live, manage the mod list, edit `config.json` safely, pick a mission, review player activity, and run power actions — without exposing Pterodactyl credentials to the browser. The panel is intended for one private community server. It is not a replacement for Pterodactyl and does not provide billing, public signup, raw console access, arbitrary file management, or multi-tenant hosting. +## What it does + +- **Live console.** The panel proxies Pterodactyl's Wings websocket, so the Console page shows + output from the moment you press Start — container pull, SteamCMD update, mod downloads, then + the game itself. The same feed drives the CPU/memory/network/disk numbers, so they match what + Pterodactyl reports rather than lagging a poll behind. +- **Mod manager.** Backed by the reforgermods.net **v2** API. Add and remove mods, pin a version + from the published version list (or type one in), resolve dependencies, update one mod or all of + them, clear the list, or copy a modlist off any live server in the public server browser. Edits + are staged as a reviewable diff and written to `config.json` once. +- **Configuration.** Typed, range-validated cards for the settings the panel understands, a + searchable view of _every_ key the file actually contains, and a raw JSON editor. Only the fields + you touch are written, writes are serialised and verified by reading the file back, and a write + based on a stale copy is rejected instead of silently reverting someone else's change. Keys that + a Reforger egg re-templates from a startup variable are flagged, with an option to write both. +- **Mission select.** One list of the vanilla scenarios plus one group per installed mod that ships + missions, with a warning when the configured scenario is no longer provided by anything. + ## Project Structure ```text @@ -19,6 +37,7 @@ packages/shared Shared roles, DTOs, and Reforger configuration types - Docker, for the local Postgres database - Discord application credentials, only when using real Discord login - Pterodactyl Client API credentials, only when connecting to a real server +- Outbound access to `api.reforgermods.net` for Workshop metadata (backend only) ## Local Setup @@ -111,6 +130,11 @@ REFORGER_LOG_FILE_PATTERN=console.log The panel follows the newest `logs_*` folder during sync. If you need to pin a single file instead, set `REFORGER_ADMIN_LOG_PATH`. +Log ingestion feeds player sessions and the killfeed only. The Console page does not depend on it — +it reads Pterodactyl's websocket directly, which is why it can show the install and mod-download +phases that never reach the game's own log file. That relay needs `PTERODACTYL_WEBSOCKET_ENABLED=true` +(the default) and a client API key with websocket access to the server. + ## Common Commands ```bash @@ -125,6 +149,11 @@ npm run db:seed # Seed initial server data ## Notes +- `config.json` is read live on every request and written in place; the previous contents are always + kept as `config.json.bak`. +- Workshop metadata is cached in the API process with stale-while-revalidate and request pacing, so + the Mods page is fast and the upstream rate limit is never approached. There is no background + polling of reforgermods.net — it is queried on demand only. - Roles are stored in the panel database. - New Discord users start as viewers. - The Discord account matching `OWNER_DISCORD_ID` becomes the owner. diff --git a/apps/api/package.json b/apps/api/package.json index f0efc81..08958a4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "express": "^5.1.0", "pg": "^8.16.0", "pino": "^9.7.0", + "ws": "^8.21.3", "zod": "^3.25.0" }, "devDependencies": { @@ -27,6 +28,7 @@ "@types/node": "^24.0.0", "@types/pg": "^8.15.0", "@types/supertest": "^6.0.0", + "@types/ws": "^8.18.1", "drizzle-kit": "^0.31.0", "pino-pretty": "^13.0.0", "supertest": "^7.1.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 22120ce..d4c216b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,11 +11,14 @@ import type { Logger } from './lib/logger.js'; import { createAuthRouter } from './modules/auth/auth-routes.js'; import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js'; import type { SessionService } from './modules/auth/session-service.js'; +import type { ConfigEditorService } from './modules/config/config-editor-service.js'; import type { ConfigSyncService } from './modules/config/config-sync.js'; import type { ServerModsService } from './modules/config/mods-service.js'; import type { PerformanceSettingsService } from './modules/config/performance-service.js'; +import type { ServerMetricsService } from './modules/servers/metrics-service.js'; import type { ResourceHistoryService } from './modules/servers/resource-history.js'; -import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.js'; +import type { MissionsService } from './modules/reforger-logs/missions-catalog.js'; +import type { ConsoleHub } from './modules/pterodactyl/console-hub.js'; import type { GameServerProvider } from './modules/pterodactyl/types.js'; import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js'; import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js'; @@ -24,7 +27,7 @@ import type { ServerService } from './modules/servers/server-service.js'; import { createInviteRouter } from './modules/invites/invite-routes.js'; import { createUserRouter } from './modules/users/user-routes.js'; import { createWorkshopRouter } from './modules/workshop/workshop-routes.js'; -import type { WorkshopClient } from './modules/workshop/workshop-client.js'; +import type { WorkshopCache } from './modules/workshop/workshop-cache.js'; export type AppDeps = { env: Env; @@ -33,14 +36,17 @@ export type AppDeps = { sessions: SessionService; servers: ServerService; provider: GameServerProvider; - workshop: WorkshopClient; + metrics: ServerMetricsService; + consoleHub: ConsoleHub | null; + workshop: WorkshopCache; scheduler: IngestionScheduler | null; resolveLogPath: LogPathResolver | null; configSync: ConfigSyncService | null; + configEditor: ConfigEditorService | null; mods: ServerModsService | null; performance: PerformanceSettingsService | null; resourceHistory: ResourceHistoryService | null; - missions: MissionCatalog | null; + missions: MissionsService; }; export function createApp(deps: AppDeps) { @@ -108,9 +114,12 @@ export function createApp(deps: AppDeps) { createServerRouter({ service: deps.servers, provider: deps.provider, + metrics: deps.metrics, + consoleHub: deps.consoleHub, scheduler: deps.scheduler, resolveLogPath: deps.resolveLogPath, configSync: deps.configSync, + configEditor: deps.configEditor, mods: deps.mods, performance: deps.performance, resourceHistory: deps.resourceHistory, diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 2f807c1..ef4baf7 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -23,14 +23,23 @@ const envSchema = z DEV_AUTH_BYPASS: booleanString, REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'), + /** Optional paid-tier key; the free public tier needs no credentials. */ + REFORGER_WORKSHOP_API_KEY: z.string().default(''), PTERODACTYL_BASE_URL: z.string().default(''), PTERODACTYL_CLIENT_API_KEY: z.string().default(''), PTERODACTYL_SERVER_ID: z.string().default(''), USE_MOCK_PTERODACTYL: booleanString, + /** + * Proxy Pterodactyl's Wings websocket for the live console and real-time + * resource metrics. Turn off to fall back to REST polling only. + */ + PTERODACTYL_WEBSOCKET_ENABLED: z + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), REFORGER_CONFIG_PATH: z.string().default('/config.json'), - REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300), REFORGER_ADMIN_LOG_PATH: z.string().default(''), REFORGER_LOG_DIRECTORY: z.string().default(''), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a2e3042..438d715 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,14 +3,19 @@ import { createDb } from './db/client.js'; import { isPterodactylConfigured, loadEnv } from './env.js'; import { createLogger } from './lib/logger.js'; import { SessionService } from './modules/auth/session-service.js'; +import { ConfigEditorService } from './modules/config/config-editor-service.js'; import { ConfigFileGateway } from './modules/config/config-file-gateway.js'; import { ConfigSyncService } from './modules/config/config-sync.js'; import { ServerModsService } from './modules/config/mods-service.js'; import { PerformanceSettingsService } from './modules/config/performance-service.js'; +import { ServerMetricsService } from './modules/servers/metrics-service.js'; import { ResourceHistoryService } from './modules/servers/resource-history.js'; -import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js'; +import { MissionCatalog, MissionsService } from './modules/reforger-logs/missions-catalog.js'; +import type { ConsoleHub } from './modules/pterodactyl/console-hub.js'; +import { MockConsoleHub } from './modules/pterodactyl/mock-console-hub.js'; import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js'; import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js'; +import { WingsConsoleHub } from './modules/pterodactyl/wings-socket.js'; import type { GameServerProvider } from './modules/pterodactyl/types.js'; import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js'; import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js'; @@ -18,6 +23,7 @@ import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-pat import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js'; import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js'; import { ServerService } from './modules/servers/server-service.js'; +import { WorkshopCache } from './modules/workshop/workshop-cache.js'; import { WorkshopClient } from './modules/workshop/workshop-client.js'; const logger = createLogger(); @@ -26,34 +32,72 @@ const env = loadEnv(); const { db, pool } = createDb(env.DATABASE_URL); const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log'; -const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL +const mockProvider = env.USE_MOCK_PTERODACTYL ? new MockGameServerProvider({ logPath: mockLogPath }) - : new PterodactylProvider({ - baseUrl: env.PTERODACTYL_BASE_URL, - apiKey: env.PTERODACTYL_CLIENT_API_KEY, - }); + : null; +const provider: GameServerProvider = + mockProvider ?? + new PterodactylProvider({ + baseUrl: env.PTERODACTYL_BASE_URL, + apiKey: env.PTERODACTYL_CLIENT_API_KEY, + }); const sessions = new SessionService(db, env.OWNER_DISCORD_ID); const servers = new ServerService(db); -const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL }); -const configSync = isPterodactylConfigured(env) - ? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH) - : null; -const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH); -const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null; -const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null; -const resourceHistory = new ResourceHistoryService(provider, logger); -// Log ingestion runs when a backend is configured and we know where logs live. -const logsConfigured = - isPterodactylConfigured(env) && - Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL); +const workshop = new WorkshopCache( + new WorkshopClient({ + baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL, + apiKey: env.REFORGER_WORKSHOP_API_KEY, + }), + logger, +); const primaryServer = (await servers.listServers())[0] ?? null; const providerServerId = primaryServer ? (primaryServer.pterodactylServerId ?? primaryServer.slug) : ''; +/** + * The live console feed. Under mock mode it is simulated; against a real + * Pterodactyl it is the Wings websocket, which is the only source that shows + * install/update/mod-download output as it happens. + */ +let consoleHub: ConsoleHub | null = null; +if (mockProvider) { + consoleHub = new MockConsoleHub(mockProvider); +} else if (isPterodactylConfigured(env) && env.PTERODACTYL_WEBSOCKET_ENABLED && providerServerId) { + consoleHub = new WingsConsoleHub({ + baseUrl: env.PTERODACTYL_BASE_URL, + apiKey: env.PTERODACTYL_CLIENT_API_KEY, + serverId: providerServerId, + logger, + }); +} else { + logger.info('live console disabled (websocket off or backend not configured)'); +} + +const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH); +const configured = isPterodactylConfigured(env); +const configSync = configured ? new ConfigSyncService(gateway, servers, logger) : null; +const configEditor = configSync + ? new ConfigEditorService(gateway, provider, configSync, logger) + : null; +const mods = configured ? new ServerModsService(gateway, workshop, logger) : null; +const performance = configEditor + ? new PerformanceSettingsService(gateway, configEditor, logger) + : null; + +const metrics = new ServerMetricsService(provider, consoleHub); +const resourceHistory = new ResourceHistoryService(metrics, logger); + +// Log ingestion runs when a backend is configured and we know where logs live. +// It no longer powers the console view — only player sessions and killfeed, +// which are parsed out of the game's own log file. +const logsConfigured = + configured && + Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL); + const resolveLogPath = logsConfigured && primaryServer ? createLogPathResolver({ @@ -65,10 +109,11 @@ const resolveLogPath = }) : null; -const missions = +const missionCatalog = resolveLogPath && primaryServer ? new MissionCatalog(provider, resolveLogPath, providerServerId) : null; +const missions = new MissionsService(workshop, missionCatalog); let scheduler: IngestionScheduler | null = null; if (resolveLogPath) { @@ -94,10 +139,13 @@ const app = createApp({ sessions, servers, provider, + metrics, + consoleHub, workshop, scheduler, resolveLogPath, configSync, + configEditor, mods, performance, resourceHistory, @@ -111,6 +159,8 @@ const httpServer = app.listen(env.PORT, () => { ); }); +consoleHub?.start(); + if (scheduler && resolveLogPath && primaryServer) { scheduler.start([ { @@ -121,20 +171,21 @@ if (scheduler && resolveLogPath && primaryServer) { ]); } -if (primaryServer && isPterodactylConfigured(env)) { +if (primaryServer && configured) { resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]); } -// Import the real config.json at startup and on an interval so the panel -// always reflects what the server actually runs. -let configSyncTimer: ReturnType | null = null; -if (configSync) { - void configSync.syncAllQuietly(); - configSyncTimer = setInterval( - () => void configSync.syncAllQuietly(), - env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000, - ); - configSyncTimer.unref(); +// Import the real config.json once at startup so the panel reflects what the +// server actually runs. Writes trigger their own sync, and every read is live, +// so there is deliberately no background polling loop here. +if (configSync && primaryServer) { + void configSync + .syncAllQuietly() + .then(() => mods?.getMods(primaryServer)) + // Prime the Workshop cache with the installed mod list so the first visit + // to the Mods page renders without waiting on the network. + .then((installed) => workshop.warm((installed?.mods ?? []).map((mod) => mod.modId))) + .catch(() => undefined); } // Hourly cleanup of expired sessions. @@ -151,10 +202,10 @@ async function shutdown(signal: string) { logger.info({ signal }, 'shutting down'); httpServer.close(); clearInterval(sessionCleanup); - if (configSyncTimer) clearInterval(configSyncTimer); resourceHistory.stop(); + await consoleHub?.stop(); if (scheduler) await scheduler.stop(); - if (provider instanceof MockGameServerProvider) provider.dispose(); + if (mockProvider) mockProvider.dispose(); await pool.end(); process.exit(0); } diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index d63a8c0..458209e 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -38,6 +38,9 @@ export class ApiError extends Error { static rateLimited(message = 'Too many requests. Try again shortly.') { return new ApiError('RATE_LIMITED', message); } + static conflict(message = 'This resource changed since you loaded it.') { + return new ApiError('CONFLICT', message); + } static upstream(message = 'An upstream service is unavailable.') { return new ApiError('UPSTREAM_UNAVAILABLE', message); } diff --git a/apps/api/src/modules/config/config-editor-service.ts b/apps/api/src/modules/config/config-editor-service.ts new file mode 100644 index 0000000..a098a8c --- /dev/null +++ b/apps/api/src/modules/config/config-editor-service.ts @@ -0,0 +1,188 @@ +import type { + ConfigPatchOp, + ConfigPatchResult, + ConfigRawResponse, + ConfigTreeResponse, + StartupMirror, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import type { Logger } from '../../lib/logger.js'; +import type { GameServerProvider } from '../pterodactyl/types.js'; +import type { ServerRecord } from '../servers/server-service.js'; +import type { ConfigFileGateway } from './config-file-gateway.js'; +import { applyConfigOps, flattenConfig, verifyConfigOps } from './config-tree.js'; +import type { ConfigSyncService } from './config-sync.js'; +import { detectStartupMirrors, mirrorsForPath } from './startup-mirrors.js'; + +function providerId(server: ServerRecord): string { + return server.pterodactylServerId ?? server.slug; +} + +/** + * The general-purpose config.json editor: every key the file actually + * contains, addressed by path, patched one field at a time. + * + * The old editor could only reach eleven hardcoded keys and submitted all of + * them on every save, so a stale form quietly reverted whatever anyone else + * had changed. Here only the paths the user touched are sent, and the write is + * refused outright if the file moved since it was loaded. + */ +export class ConfigEditorService { + constructor( + private readonly gateway: ConfigFileGateway, + private readonly provider: GameServerProvider, + private readonly configSync: ConfigSyncService, + private readonly logger: Logger, + ) {} + + private async loadMirrors( + server: ServerRecord, + root: Record, + ): Promise { + const variables = await this.provider + .listStartupVariables(providerId(server)) + .then((list) => + list.map((variable) => ({ + name: variable.name, + description: variable.description, + envVariable: variable.envVariable, + value: variable.serverValue, + defaultValue: variable.defaultValue, + isEditable: variable.isEditable, + })), + ) + .catch(() => []); + return detectStartupMirrors(root, variables); + } + + async getTree(server: ServerRecord): Promise { + const document = await this.gateway.download(providerId(server)); + return { + entries: flattenConfig(document.root), + mirrors: await this.loadMirrors(server, document.root), + revision: document.revision, + fetchedAt: new Date().toISOString(), + }; + } + + async getRaw(server: ServerRecord): Promise { + const document = await this.gateway.download(providerId(server)); + return { + content: document.raw, + revision: document.revision, + fetchedAt: new Date().toISOString(), + }; + } + + async putRaw( + server: ServerRecord, + content: string, + expectedRevision?: string, + ): Promise { + const document = await this.gateway.replace(providerId(server), content, expectedRevision); + await this.afterWrite(server); + return { + content: document.raw, + revision: document.revision, + fetchedAt: new Date().toISOString(), + }; + } + + /** + * Applies field-level edits. When `writeStartupVars` is set, any changed path + * that the egg also templates from a startup variable is written to both + * places, so the change survives the next restart. + */ + async patch( + server: ServerRecord, + ops: readonly ConfigPatchOp[], + options: { expectedRevision?: string; writeStartupVars?: boolean } = {}, + ): Promise { + if (ops.length === 0) { + const current = await this.gateway.download(providerId(server)); + return { + changedPaths: [], + startupVarsWritten: [], + revision: current.revision, + fetchedAt: new Date().toISOString(), + requiresRestart: true, + }; + } + + let changedPaths: string[] = []; + const document = await this.gateway.mutate(providerId(server), { + expectedRevision: options.expectedRevision, + apply: (root) => { + changedPaths = applyConfigOps(root, ops); + }, + verify: (readBack) => { + const failed = verifyConfigOps(readBack, ops); + if (failed) { + throw ApiError.upstream( + `Config write verification failed for "${failed}" — the file on the server does not match. Check config.json.bak.`, + ); + } + }, + }); + + const startupVarsWritten = options.writeStartupVars + ? await this.mirrorChangedPaths(server, document.root, ops, changedPaths) + : []; + + await this.afterWrite(server); + + this.logger.info( + { serverId: server.id, changedPaths, startupVarsWritten }, + 'config.json updated', + ); + + return { + changedPaths, + startupVarsWritten, + revision: document.revision, + fetchedAt: new Date().toISOString(), + requiresRestart: true, + }; + } + + private async mirrorChangedPaths( + server: ServerRecord, + root: Record, + ops: readonly ConfigPatchOp[], + changedPaths: readonly string[], + ): Promise { + const mirrors = await this.loadMirrors(server, root); + const written: string[] = []; + for (const path of changedPaths) { + const op = ops.find((candidate) => candidate.path === path); + if (!op || op.value === null) continue; + for (const mirror of mirrorsForPath(path, mirrors)) { + try { + await this.provider.updateStartupVariable( + providerId(server), + mirror.envVariable, + String(op.value), + ); + written.push(mirror.envVariable); + } catch (error) { + // Some eggs mark variables read-only; that is not fatal for the + // config write that already succeeded. + this.logger.warn( + { serverId: server.id, envVariable: mirror.envVariable, err: String(error) }, + 'startup variable mirror write failed', + ); + } + } + } + return written; + } + + private async afterWrite(server: ServerRecord): Promise { + await this.configSync.sync(server).catch((error) => { + this.logger.warn( + { serverId: server.id, err: String(error) }, + 'post-write config sync failed', + ); + }); + } +} diff --git a/apps/api/src/modules/config/config-file-gateway.ts b/apps/api/src/modules/config/config-file-gateway.ts index 4729745..f8c82db 100644 --- a/apps/api/src/modules/config/config-file-gateway.ts +++ b/apps/api/src/modules/config/config-file-gateway.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { ApiError } from '../../lib/errors.js'; import type { GameServerProvider } from '../pterodactyl/types.js'; @@ -9,22 +10,64 @@ export function asRecord(value: unknown): Record | null { : null; } +export type ConfigDocument = { + raw: string; + root: Record; + /** Content hash of `raw`. Callers echo it back to prove what they edited. */ + revision: string; + /** Indentation detected in the source file, reused when writing. */ + indent: string; +}; + +function revisionOf(raw: string): string { + return createHash('sha256').update(raw).digest('hex').slice(0, 16); +} + +/** Keeps the file looking the way its author left it instead of reformatting. */ +function detectIndent(raw: string): string { + const match = /\n([ \t]+)"/.exec(raw); + return match?.[1] ?? ' '; +} + /** - * Shared read-modify-write access to the server's config.json: size-guarded - * download + parse, and a write path that backs the previous content up to - * `.bak` and verifies the upload by downloading it again. Callers - * mutate only their own keys on the parsed document so everything else in the - * file passes through untouched. + * Shared read-modify-write access to the server's config.json. + * + * Three properties matter here, and all three were missing before: + * + * 1. **Serialisation.** Every mutation runs under a per-server lock, so a mod + * list write and a settings write can no longer interleave, lose each + * other's changes, and overwrite the same `.bak`. + * 2. **Optimistic concurrency.** Callers pass the revision their edits were + * based on; if the file moved underneath them the write is rejected as a + * conflict instead of silently reverting somebody else's change. + * 3. **Read-back verification.** The upload is downloaded again and checked + * before the call is reported as successful. + * + * Callers mutate only their own keys on the parsed document, so everything + * else in the file passes through untouched. */ export class ConfigFileGateway { + /** Tail of the pending operation chain, per provider server id. */ + private readonly locks = new Map>(); + constructor( private readonly provider: GameServerProvider, readonly configPath: string, ) {} - async download( - providerServerId: string, - ): Promise<{ raw: string; root: Record }> { + private withLock(key: string, task: () => Promise): Promise { + const previous = this.locks.get(key) ?? Promise.resolve(); + const next = previous.then(task, task); + // Swallow rejections on the chain itself so one failure cannot poison + // every subsequent operation. + this.locks.set( + key, + next.catch(() => undefined), + ); + return next; + } + + private async read(providerServerId: string): Promise { const file = await this.provider.downloadTextFile( providerServerId, this.configPath, @@ -40,27 +83,93 @@ export class ConfigFileGateway { throw ApiError.upstream('Server config.json is not valid JSON.'); } const root = asRecord(parsed); - if (!root || !asRecord(root.game)) { + if (!root) { + throw ApiError.upstream('Server config.json is not a JSON object.'); + } + if (!asRecord(root.game)) { throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.'); } - return { raw: file.content, root }; + return { + raw: file.content, + root, + revision: revisionOf(file.content), + indent: detectIndent(file.content), + }; + } + + /** Reads the current document. Waits for any in-flight write to finish. */ + async download(providerServerId: string): Promise { + return this.withLock(providerServerId, () => this.read(providerServerId)); } /** - * Backs up `previousRaw`, writes the mutated document, downloads it again - * and hands the verified parsed result to `verify` (throw there to fail). + * Applies `apply` to a freshly-read document and writes it back, keeping a + * `.bak` of the previous content. `verify` receives the re-downloaded + * document and should throw if the change did not land. + * + * Pass `expectedRevision` to reject the write when the file changed since the + * caller loaded it. */ - async write( + async mutate( providerServerId: string, - root: Record, - previousRaw: string, - verify: (readBack: Record) => void, - ): Promise> { - await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw); - const serialized = `${JSON.stringify(root, null, 4)}\n`; - await this.provider.writeTextFile(providerServerId, this.configPath, serialized); - const readBack = await this.download(providerServerId); - verify(readBack.root); - return readBack.root; + options: { + expectedRevision?: string | undefined; + apply: (root: Record) => void; + verify: (readBack: Record) => void; + }, + ): Promise { + return this.withLock(providerServerId, async () => { + const current = await this.read(providerServerId); + if (options.expectedRevision && options.expectedRevision !== current.revision) { + throw ApiError.conflict( + 'config.json changed on the server since you loaded it. Refresh and re-apply your changes.', + ); + } + + options.apply(current.root); + + const serialized = `${JSON.stringify(current.root, null, current.indent)}\n`; + if (serialized === current.raw) { + return current; // nothing to do; skip the write and the backup churn + } + + await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw); + await this.provider.writeTextFile(providerServerId, this.configPath, serialized); + + const readBack = await this.read(providerServerId); + options.verify(readBack.root); + return readBack; + }); + } + + /** Replaces the whole file. Used by the raw JSON editor. */ + async replace( + providerServerId: string, + content: string, + expectedRevision?: string, + ): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(content.replace(/^\uFEFF/, '')); + } catch { + throw ApiError.validation('That is not valid JSON.'); + } + const root = asRecord(parsed); + if (!root || !asRecord(root.game)) { + throw ApiError.validation('config.json must be an object with a "game" section.'); + } + return this.withLock(providerServerId, async () => { + const current = await this.read(providerServerId); + if (expectedRevision && expectedRevision !== current.revision) { + throw ApiError.conflict( + 'config.json changed on the server since you loaded it. Refresh and re-apply your changes.', + ); + } + const normalized = content.endsWith('\n') ? content : `${content}\n`; + if (normalized === current.raw) return current; + await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw); + await this.provider.writeTextFile(providerServerId, this.configPath, normalized); + return this.read(providerServerId); + }); } } diff --git a/apps/api/src/modules/config/config-sync.ts b/apps/api/src/modules/config/config-sync.ts index 0b726c1..eed1abc 100644 --- a/apps/api/src/modules/config/config-sync.ts +++ b/apps/api/src/modules/config/config-sync.ts @@ -1,10 +1,13 @@ import type { ReforgerServerConfig } from '@reforger-panel/shared'; import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js'; -import type { GameServerProvider } from '../pterodactyl/types.js'; import type { ServerRecord, ServerService } from '../servers/server-service.js'; -import { parseReforgerConfigJson } from './reforger-config-file.js'; +import type { ConfigFileGateway } from './config-file-gateway.js'; +import { mapReforgerConfig } from './reforger-config-file.js'; -const CONFIG_MAX_BYTES = 256 * 1024; +export type LiveConfig = { + config: ReforgerServerConfig; + revision: string; +}; export type ConfigSyncResult = { serverName: string; @@ -13,30 +16,31 @@ export type ConfigSyncResult = { }; /** - * Reads the server's real config.json (via the provider, read-only) and keeps - * the server row's name/maxPlayers in line with what the server actually - * runs. Configuration is always served live; no revision history is kept. + * Reads the server's real config.json and keeps the server row's + * name/maxPlayers in line with what the server actually runs. Configuration is + * always served live; no revision history is kept. + * + * Reads go through the shared gateway so they queue behind in-flight writes + * and return the same content revision the editors use for conflict detection. */ export class ConfigSyncService { constructor( - private readonly provider: GameServerProvider, + private readonly gateway: ConfigFileGateway, private readonly servers: ServerService, private readonly logger: Logger, - private readonly configPath: string, ) {} - async getLiveConfig(server: ServerRecord): Promise { - const providerServerId = server.pterodactylServerId ?? server.slug; - const file = await this.provider.downloadTextFile( - providerServerId, - this.configPath, - CONFIG_MAX_BYTES, - ); - return parseReforgerConfigJson(file.content); + private providerId(server: ServerRecord): string { + return server.pterodactylServerId ?? server.slug; + } + + async getLiveConfig(server: ServerRecord): Promise { + const document = await this.gateway.download(this.providerId(server)); + return { config: mapReforgerConfig(document.root), revision: document.revision }; } async sync(server: ServerRecord): Promise { - const config = await this.getLiveConfig(server); + const { config } = await this.getLiveConfig(server); const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null; if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) { await this.servers.updateServerInfo(server.id, { diff --git a/apps/api/src/modules/config/config-tree.test.ts b/apps/api/src/modules/config/config-tree.test.ts new file mode 100644 index 0000000..49b09fb --- /dev/null +++ b/apps/api/src/modules/config/config-tree.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { applyConfigOps, flattenConfig, readAtPath, verifyConfigOps } from './config-tree.js'; + +function sampleConfig(): Record { + return { + bindAddress: '0.0.0.0', + bindPort: 2001, + game: { + name: 'Test Server', + maxPlayers: 16, + gameProperties: { serverMaxViewDistance: 2500, battlEye: true }, + mods: [{ modId: 'AAAA000000000001' }], + }, + operating: { aiLimit: 40 }, + }; +} + +describe('flattenConfig', () => { + it('exposes every leaf by dotted path', () => { + const paths = flattenConfig(sampleConfig()).map((entry) => entry.path); + expect(paths).toContain('bindPort'); + expect(paths).toContain('game.gameProperties.serverMaxViewDistance'); + expect(paths).toContain('operating.aiLimit'); + }); + + it('hides the mod list, which the Mods page owns', () => { + const paths = flattenConfig(sampleConfig()).map((entry) => entry.path); + expect(paths).not.toContain('game.mods'); + }); + + it('records value types so the editor can pick an input', () => { + const entries = flattenConfig(sampleConfig()); + expect(entries.find((entry) => entry.path === 'bindPort')?.type).toBe('number'); + expect(entries.find((entry) => entry.path === 'bindAddress')?.type).toBe('string'); + expect(entries.find((entry) => entry.path === 'game.gameProperties.battlEye')?.type).toBe( + 'boolean', + ); + }); +}); + +describe('applyConfigOps', () => { + it('touches only the given paths and reports what changed', () => { + const root = sampleConfig(); + const changed = applyConfigOps(root, [ + { path: 'game.maxPlayers', value: 64 }, + { path: 'bindPort', value: 2001 }, // unchanged + ]); + expect(changed).toEqual(['game.maxPlayers']); + expect(readAtPath(root, 'game.maxPlayers')).toBe(64); + expect(readAtPath(root, 'game.name')).toBe('Test Server'); + }); + + it('creates missing intermediate sections when setting a new key', () => { + const root = sampleConfig(); + applyConfigOps(root, [{ path: 'operating.playerSaveTime', value: 180 }]); + applyConfigOps(root, [{ path: 'rcon.port', value: 19999 }]); + expect(readAtPath(root, 'operating.playerSaveTime')).toBe(180); + expect(readAtPath(root, 'rcon.port')).toBe(19999); + }); + + it('removes a key on null so the game default applies', () => { + const root = sampleConfig(); + const changed = applyConfigOps(root, [{ path: 'operating.aiLimit', value: null }]); + expect(changed).toEqual(['operating.aiLimit']); + expect('aiLimit' in (root.operating as Record)).toBe(false); + }); + + it('does not create sections just to delete from them', () => { + const root = sampleConfig(); + const changed = applyConfigOps(root, [{ path: 'nothing.here', value: null }]); + expect(changed).toEqual([]); + expect('nothing' in root).toBe(false); + }); + + it('leaves the mod list untouched', () => { + const root = sampleConfig(); + applyConfigOps(root, [{ path: 'game.maxPlayers', value: 32 }]); + expect((root.game as Record).mods).toEqual([{ modId: 'AAAA000000000001' }]); + }); +}); + +describe('verifyConfigOps', () => { + it('passes when every op landed', () => { + const root = sampleConfig(); + const ops = [ + { path: 'game.maxPlayers', value: 64 }, + { path: 'operating.aiLimit', value: null }, + ]; + applyConfigOps(root, ops); + expect(verifyConfigOps(root, ops)).toBeNull(); + }); + + it('names the first path that did not land', () => { + const root = sampleConfig(); + expect(verifyConfigOps(root, [{ path: 'game.maxPlayers', value: 64 }])).toBe('game.maxPlayers'); + }); +}); diff --git a/apps/api/src/modules/config/config-tree.ts b/apps/api/src/modules/config/config-tree.ts new file mode 100644 index 0000000..8e705d8 --- /dev/null +++ b/apps/api/src/modules/config/config-tree.ts @@ -0,0 +1,146 @@ +import type { ConfigEntry, ConfigPatchOp, ConfigValueType } from '@reforger-panel/shared'; +import { asRecord } from './config-file-gateway.js'; + +/** + * `game.mods` is owned by the Mods page, which understands versions and + * dependencies. Hand-editing a 90-entry array in a generic key editor is a + * good way to break a server, so it is hidden from the flat view (the raw JSON + * tab still shows it). + */ +const HIDDEN_PATHS = new Set(['game.mods']); + +function typeOf(value: unknown): ConfigValueType { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + switch (typeof value) { + case 'string': + return 'string'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + default: + return 'object'; + } +} + +/** + * Flattens config.json into dotted leaf paths (`game.gameProperties.battlEye`) + * so the editor can search and address every value the file actually contains, + * rather than only the handful of keys the panel happens to know about. + * + * Arrays are surfaced as single non-recursive entries carrying their JSON text; + * the flat editor renders them read-only and defers to the raw tab. + */ +export function flattenConfig(root: Record): ConfigEntry[] { + const entries: ConfigEntry[] = []; + + const walk = (node: Record, prefix: string): void => { + for (const [key, value] of Object.entries(node)) { + const path = prefix ? `${prefix}.${key}` : key; + if (HIDDEN_PATHS.has(path)) continue; + const child = asRecord(value); + if (child) { + walk(child, path); + continue; + } + if (Array.isArray(value)) { + entries.push({ path, value: null, type: 'array', raw: JSON.stringify(value) }); + continue; + } + entries.push({ + path, + value: value as string | number | boolean | null, + type: typeOf(value), + }); + } + }; + + walk(root, ''); + entries.sort((a, b) => a.path.localeCompare(b.path)); + return entries; +} + +export function readAtPath( + root: Record, + path: string, +): string | number | boolean | null { + const segments = path.split('.'); + let node: unknown = root; + for (const segment of segments) { + const record = asRecord(node); + if (!record || !(segment in record)) return null; + node = record[segment]; + } + if (node === undefined || asRecord(node) || Array.isArray(node)) return null; + return node as string | number | boolean | null; +} + +/** + * Applies patch operations in place and returns the paths that actually + * changed. `value: null` removes the key entirely so the game's own default + * applies — the same semantics the performance form has always had. + * + * Only the supplied paths are touched, which is the core fix for the old + * whole-object form submit that silently reverted concurrent edits. + */ +export function applyConfigOps( + root: Record, + ops: readonly ConfigPatchOp[], +): string[] { + const changed: string[] = []; + + for (const op of ops) { + const segments = op.path.split('.').filter(Boolean); + const leaf = segments.pop(); + if (!leaf) continue; + + if (op.value === null) { + // Removal: never create the intermediate objects on the way down. + let node: Record | null = root; + for (const segment of segments) { + node = asRecord(node[segment]); + if (!node) break; + } + if (node && leaf in node) { + delete node[leaf]; + changed.push(op.path); + } + continue; + } + + let node: Record = root; + for (const segment of segments) { + const child = asRecord(node[segment]); + if (child) { + node = child; + } else { + const created: Record = {}; + node[segment] = created; + node = created; + } + } + if (node[leaf] !== op.value) { + node[leaf] = op.value; + changed.push(op.path); + } + } + + return changed; +} + +/** Verifies that a write landed, for the gateway's read-back check. */ +export function verifyConfigOps( + readBack: Record, + ops: readonly ConfigPatchOp[], +): string | null { + for (const op of ops) { + const actual = readAtPath(readBack, op.path); + if (op.value === null) { + if (actual !== null) return op.path; + } else if (actual !== op.value) { + return op.path; + } + } + return null; +} diff --git a/apps/api/src/modules/config/mods-service.ts b/apps/api/src/modules/config/mods-service.ts index ba1be39..e68f93c 100644 --- a/apps/api/src/modules/config/mods-service.ts +++ b/apps/api/src/modules/config/mods-service.ts @@ -1,13 +1,41 @@ import type { + ModOverviewEntry, + ModResolveResponse, + ModsOverviewResponse, + ModWorkshopInfo, ReforgerConfigMod, + ResolvedMod, ServerModsResponse, UpdateModsResult, + WorkshopDependency, + WorkshopModDetail, } from '@reforger-panel/shared'; import { ApiError } from '../../lib/errors.js'; import type { Logger } from '../../lib/logger.js'; +import { OFFICIAL_SCENARIO_IDS } from '../reforger-logs/missions-catalog.js'; +import type { WorkshopCache } from '../workshop/workshop-cache.js'; import type { ServerRecord } from '../servers/server-service.js'; import { asRecord, type ConfigFileGateway } from './config-file-gateway.js'; -import type { ConfigSyncService } from './config-sync.js'; +import { readAtPath } from './config-tree.js'; + +/** + * How long the overview waits for cold Workshop lookups before answering with + * what it has. A large modlist on a cold cache cannot be resolved inside one + * request without either blocking for a minute or tripping the upstream rate + * limit, so the response is returned immediately with `warming: true` and the + * client refetches while the background fill completes. + */ +const WARM_WAIT_MS = 2_500; + +/** Guard against a pathological dependency graph. */ +const MAX_RESOLVED_MODS = 400; + +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} function readMods(root: Record): ReforgerConfigMod[] { const game = asRecord(root.game); @@ -28,15 +56,32 @@ function readMods(root: Record): ReforgerConfigMod[] { .filter((mod): mod is ReforgerConfigMod => mod !== null); } +function toWorkshopInfo(detail: WorkshopModDetail): ModWorkshopInfo { + return { + name: detail.name, + author: detail.author, + summary: detail.summary, + imageUrl: detail.imageUrl, + workshopUrl: detail.workshopUrl, + latestVersion: detail.version, + gameVersion: detail.gameVersion, + sizeBytes: detail.sizeBytes, + scenarioCount: detail.scenarioCount, + dependencyCount: detail.dependencyCount, + obsolete: detail.obsolete, + tags: detail.tags, + }; +} + /** - * Manages the `game.mods` array of the server's real config.json through the - * shared ConfigFileGateway (backup + read-back verification; all other config - * fields pass through untouched). Changes apply on the next server restart. + * Owns the `game.mods` array of the server's real config.json, and joins it + * with Workshop metadata so the panel can show versions, sizes, dependencies + * and available updates without the browser making one request per mod. */ export class ServerModsService { constructor( private readonly gateway: ConfigFileGateway, - private readonly configSync: ConfigSyncService, + private readonly workshop: WorkshopCache, private readonly logger: Logger, ) {} @@ -45,51 +90,247 @@ export class ServerModsService { } async getMods(server: ServerRecord): Promise { - const { root } = await this.gateway.download(this.providerId(server)); - return { mods: readMods(root), fetchedAt: new Date().toISOString() }; + const document = await this.gateway.download(this.providerId(server)); + const mods = readMods(document.root); + // Keep the cache aligned with what is actually installed. + this.workshop.warm(mods.map((mod) => mod.modId)); + return { mods, revision: document.revision, fetchedAt: new Date().toISOString() }; } - async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise { + /** + * Everything the Mods page needs in one request: installed mods joined with + * cached Workshop metadata, update availability, dependency gaps and the + * reverse "required by" edges that make removals safe. + */ + async getOverview(server: ServerRecord): Promise { + const document = await this.gateway.download(this.providerId(server)); + const installed = readMods(document.root); + const ids = installed.map((mod) => mod.modId.toUpperCase()); + const installedIds = new Set(ids); + + // Start every lookup, then answer with whatever resolved in time. + await Promise.race([ + Promise.allSettled(ids.map((id) => this.workshop.tryGetMod(id))), + delay(WARM_WAIT_MS), + ]); + + const details = new Map(); + for (const id of ids) details.set(id, this.workshop.peekMod(id)); + + // Reverse dependency edges: which installed mods need each mod. + const requiredBy = new Map(); + for (const [id, detail] of details) { + if (!detail) continue; + for (const dependency of detail.dependencies) { + const dependents = requiredBy.get(dependency.id) ?? []; + dependents.push(id); + requiredBy.set(dependency.id, dependents); + } + } + + const unresolvedIds: string[] = []; + let warming = false; + let totalSizeBytes: number | null = null; + let updatesAvailable = 0; + + const mods: ModOverviewEntry[] = installed.map((mod) => { + const id = mod.modId.toUpperCase(); + const detail = details.get(id); + if (detail === undefined) warming = true; + if (detail === null) unresolvedIds.push(id); + + const pinnedVersion = mod.version ?? null; + const workshop = detail ? toWorkshopInfo(detail) : null; + const updateAvailable = Boolean( + pinnedVersion && workshop?.latestVersion && pinnedVersion !== workshop.latestVersion, + ); + if (updateAvailable) updatesAvailable += 1; + if (workshop?.sizeBytes) totalSizeBytes = (totalSizeBytes ?? 0) + workshop.sizeBytes; + + const missingDependencies: WorkshopDependency[] = (detail?.dependencies ?? []).filter( + (dependency) => !installedIds.has(dependency.id), + ); + + return { + modId: id, + configName: mod.name ?? null, + pinnedVersion, + workshop, + updateAvailable, + missingDependencies, + requiredBy: (requiredBy.get(id) ?? []).filter((dependent) => dependent !== id), + }; + }); + + return { + mods, + revision: document.revision, + fetchedAt: new Date().toISOString(), + totalSizeBytes, + updatesAvailable, + unresolvedIds, + warming, + orphanedMission: this.detectOrphanedMission(document.root, details, warming), + }; + } + + /** + * Flags a configured scenario that nothing installed can provide — the usual + * cause of a server that boots to the wrong mission after a mod removal. + * Only reported once every mod resolved, so a warming cache never produces a + * false alarm. + */ + private detectOrphanedMission( + root: Record, + details: Map, + warming: boolean, + ): { scenarioId: string } | null { + if (warming) return null; + const scenarioId = readAtPath(root, 'game.scenarioId'); + if (typeof scenarioId !== 'string' || !scenarioId) return null; + if (OFFICIAL_SCENARIO_IDS.has(scenarioId)) return null; + for (const detail of details.values()) { + if (!detail) return null; // an unresolved mod might well provide it + if (detail.scenarios.some((scenario) => scenario.scenarioId === scenarioId)) return null; + } + return { scenarioId }; + } + + /** + * Expands a desired mod list into everything needed to make it load: the + * requested mods plus the transitive dependency closure, with sizes so the + * UI can show the download cost before anything is written. + */ + async resolve(desired: readonly ReforgerConfigMod[]): Promise { + const requested = new Map(); + for (const mod of desired) requested.set(mod.modId.toUpperCase(), mod); + + const resolved = new Map(); + const unresolvedIds: string[] = []; + const queue = [...requested.keys()]; + const seen = new Set(queue); + + for (const id of queue) { + const mod = requested.get(id); + resolved.set(id, { + modId: id, + name: mod?.name ?? null, + version: mod?.version ?? null, + sizeBytes: null, + viaDependency: false, + requiredBy: [], + }); + } + + while (queue.length > 0 && resolved.size < MAX_RESOLVED_MODS) { + // Resolve a whole level at a time so the pacer can overlap requests. + const level = queue.splice(0, queue.length); + const details = await Promise.all(level.map((id) => this.workshop.tryGetMod(id))); + + for (let index = 0; index < level.length; index += 1) { + const id = level[index]!; + const detail = details[index] ?? null; + const entry = resolved.get(id)!; + + if (!detail) { + unresolvedIds.push(id); + continue; + } + entry.name = entry.name ?? detail.name; + entry.sizeBytes = detail.sizeBytes; + + for (const dependency of detail.dependencies) { + const existing = resolved.get(dependency.id); + if (existing) { + if (!existing.requiredBy.includes(id)) existing.requiredBy.push(id); + continue; + } + resolved.set(dependency.id, { + modId: dependency.id, + name: dependency.name, + version: null, + sizeBytes: dependency.sizeBytes, + viaDependency: true, + requiredBy: [id], + }); + if (!seen.has(dependency.id)) { + seen.add(dependency.id); + queue.push(dependency.id); + } + } + } + } + + const mods = [...resolved.values()]; + const sized = mods.filter((mod) => mod.sizeBytes !== null); + return { + mods, + addedDependencies: mods.filter((mod) => mod.viaDependency), + totalSizeBytes: + sized.length > 0 ? sized.reduce((sum, mod) => sum + (mod.sizeBytes ?? 0), 0) : null, + unresolvedIds, + }; + } + + /** + * Writes the mod list. `expectedRevision` is required in practice: it is how + * a stale browser tab is stopped from reverting somebody else's change. + */ + async setMods( + server: ServerRecord, + mods: readonly ReforgerConfigMod[], + expectedRevision?: string, + ): Promise { const providerId = this.providerId(server); - const { raw, root } = await this.gateway.download(providerId); - const previous = readMods(root); + const before = await this.gateway.download(providerId); + const previous = readMods(before.root); - const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase())); - const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); - const added = [...nextIds].filter((id) => !previousIds.has(id)).length; - const removed = [...previousIds].filter((id) => !nextIds.has(id)).length; + const previousById = new Map(previous.map((mod) => [mod.modId.toUpperCase(), mod])); + const nextById = new Map(mods.map((mod) => [mod.modId.toUpperCase(), mod])); - const game = asRecord(root.game)!; - game.mods = mods.map((mod) => ({ - modId: mod.modId.toUpperCase(), + const added = [...nextById.keys()].filter((id) => !previousById.has(id)).length; + const removed = [...previousById.keys()].filter((id) => !nextById.has(id)).length; + const changed = [...nextById].filter(([id, mod]) => { + const existing = previousById.get(id); + return existing !== undefined && (existing.version ?? null) !== (mod.version ?? null); + }).length; + + const normalized = [...nextById.entries()].map(([id, mod]) => ({ + modId: id, ...(mod.name ? { name: mod.name } : {}), ...(mod.version ? { version: mod.version } : {}), })); + const expectedIds = [...nextById.keys()].sort(); - const verified = await this.gateway.write(providerId, root, raw, (readBack) => { - const verifyIds = readMods(readBack) - .map((mod) => mod.modId.toUpperCase()) - .sort(); - if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) { - throw ApiError.upstream( - 'Config write verification failed — the file on the server does not match. Check config.json.bak.', - ); - } + const document = await this.gateway.mutate(providerId, { + expectedRevision: expectedRevision ?? before.revision, + apply: (root) => { + const game = asRecord(root.game)!; + game.mods = normalized; + }, + verify: (readBack) => { + const actual = readMods(readBack) + .map((mod) => mod.modId.toUpperCase()) + .sort(); + if (JSON.stringify(actual) !== JSON.stringify(expectedIds)) { + throw ApiError.upstream( + 'Config write verification failed — the file on the server does not match. Check config.json.bak.', + ); + } + }, }); - await this.configSync.sync(server).catch((error) => { - this.logger.warn( - { serverId: server.id, err: String(error) }, - 'post-write config sync failed', - ); - }); + const result = readMods(document.root); + this.workshop.warm(result.map((mod) => mod.modId)); - this.logger.info({ serverId: server.id, added, removed }, 'server mods updated'); + this.logger.info({ serverId: server.id, added, removed, changed }, 'server mods updated'); return { - mods: readMods(verified), + mods: result, + revision: document.revision, fetchedAt: new Date().toISOString(), added, removed, + changed, requiresRestart: true, }; } diff --git a/apps/api/src/modules/config/performance-service.ts b/apps/api/src/modules/config/performance-service.ts index dc5089d..3698629 100644 --- a/apps/api/src/modules/config/performance-service.ts +++ b/apps/api/src/modules/config/performance-service.ts @@ -1,83 +1,59 @@ import type { + ConfigPatchOp, PerformanceSettings, PerformanceSettingsPatch, PerformanceSettingsResponse, } from '@reforger-panel/shared'; import type { Logger } from '../../lib/logger.js'; import type { ServerRecord } from '../servers/server-service.js'; -import { asRecord, type ConfigFileGateway } from './config-file-gateway.js'; -import type { ConfigSyncService } from './config-sync.js'; +import type { ConfigEditorService } from './config-editor-service.js'; +import type { ConfigFileGateway } from './config-file-gateway.js'; +import { readAtPath } from './config-tree.js'; -/** Where each performance field lives inside config.json. */ -const FIELD_LOCATIONS: Record< - keyof PerformanceSettings, - ['game' | 'gameProperties' | 'operating', string] -> = { - scenarioId: ['game', 'scenarioId'], - maxPlayers: ['game', 'maxPlayers'], - serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'], - networkViewDistance: ['gameProperties', 'networkViewDistance'], - serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'], - disableThirdPerson: ['gameProperties', 'disableThirdPerson'], - fastValidation: ['gameProperties', 'fastValidation'], - battlEye: ['gameProperties', 'battlEye'], - disableAI: ['operating', 'disableAI'], - aiLimit: ['operating', 'aiLimit'], - playerSaveTime: ['operating', 'playerSaveTime'], - slotReservationTimeout: ['operating', 'slotReservationTimeout'], - lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'], +/** Where each curated performance field lives inside config.json. */ +export const PERFORMANCE_FIELD_PATHS: Record = { + scenarioId: 'game.scenarioId', + maxPlayers: 'game.maxPlayers', + serverMaxViewDistance: 'game.gameProperties.serverMaxViewDistance', + networkViewDistance: 'game.gameProperties.networkViewDistance', + serverMinGrassDistance: 'game.gameProperties.serverMinGrassDistance', + disableThirdPerson: 'game.gameProperties.disableThirdPerson', + fastValidation: 'game.gameProperties.fastValidation', + battlEye: 'game.gameProperties.battlEye', + disableAI: 'operating.disableAI', + aiLimit: 'operating.aiLimit', + playerSaveTime: 'operating.playerSaveTime', + slotReservationTimeout: 'operating.slotReservationTimeout', + lobbyPlayerSynchronise: 'operating.lobbyPlayerSynchronise', }; -function sectionFor( - root: Record, - section: 'game' | 'gameProperties' | 'operating', - createMissing: boolean, -): Record | null { - const game = asRecord(root.game)!; - if (section === 'game') return game; - if (section === 'gameProperties') { - let props = asRecord(game.gameProperties); - if (!props && createMissing) { - props = {}; - game.gameProperties = props; - } - return props; - } - let operating = asRecord(root.operating); - if (!operating && createMissing) { - operating = {}; - root.operating = operating; - } - return operating; -} +const PERFORMANCE_FIELDS = Object.keys(PERFORMANCE_FIELD_PATHS) as (keyof PerformanceSettings)[]; export function readPerformanceSettings(root: Record): PerformanceSettings { - const result = {} as Record; - for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [ - keyof PerformanceSettings, - ['game' | 'gameProperties' | 'operating', string], - ][]) { - const container = sectionFor(root, section, false); - const value = container?.[key]; - const validType = - field === 'scenarioId' - ? typeof value === 'string' - : typeof value === 'number' || typeof value === 'boolean'; - result[field] = validType ? (value as number | boolean | string) : null; + const result = {} as Record; + for (const field of PERFORMANCE_FIELDS) { + const value = readAtPath(root, PERFORMANCE_FIELD_PATHS[field]); + const expected = field === 'scenarioId' ? 'string' : ['number', 'boolean']; + const matches = + typeof expected === 'string' ? typeof value === expected : expected.includes(typeof value); + result[field] = matches ? value : null; } return result as PerformanceSettings; } /** - * Edits the performance-related keys of the live config.json. A `null` value - * removes the key from the file entirely so the game's own default applies — - * network/identity fields (bind address, ports, passwords, rcon…) are never - * touched by this service. + * The curated view of config.json: typed, range-validated fields for the + * settings the panel understands well. + * + * It now shares the patch engine with the general key editor, so a save only + * ever touches the fields the caller explicitly included. Previously the form + * posted all thirteen values on every submit, which meant a form loaded before + * somebody else's change silently reverted it. */ export class PerformanceSettingsService { constructor( private readonly gateway: ConfigFileGateway, - private readonly configSync: ConfigSyncService, + private readonly editor: ConfigEditorService, private readonly logger: Logger, ) {} @@ -86,59 +62,48 @@ export class PerformanceSettingsService { } async get(server: ServerRecord): Promise { - const { root } = await this.gateway.download(this.providerId(server)); - return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() }; + const document = await this.gateway.download(this.providerId(server)); + return { + settings: readPerformanceSettings(document.root), + revision: document.revision, + fetchedAt: new Date().toISOString(), + }; } async update( server: ServerRecord, patch: PerformanceSettingsPatch, + options: { expectedRevision?: string; writeStartupVars?: boolean } = {}, ): Promise { - const providerId = this.providerId(server); - const { raw, root } = await this.gateway.download(providerId); - const before = readPerformanceSettings(root); + const before = await this.get(server); + // Only fields the caller actually sent, and only where the value differs. + const ops: ConfigPatchOp[] = []; const changedFields: string[] = []; - for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [ - keyof PerformanceSettings, - ['game' | 'gameProperties' | 'operating', string], - ][]) { - if (!(field in patch)) continue; // untouched fields stay as-is - const next = patch[field] as number | boolean | string | null; - if (before[field] === next) continue; + for (const field of PERFORMANCE_FIELDS) { + if (!(field in patch)) continue; + const next = patch[field] ?? null; + if (before.settings[field] === next) continue; + ops.push({ path: PERFORMANCE_FIELD_PATHS[field], value: next }); changedFields.push(field); - if (next === null) { - const container = sectionFor(root, section, false); - if (container) delete container[key]; - } else { - const container = sectionFor(root, section, true)!; - container[key] = next; - } } - if (changedFields.length > 0) { - await this.gateway.write(providerId, root, raw, (readBack) => { - const after = readPerformanceSettings(readBack); - for (const field of changedFields) { - if ( - after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings] - ) { - throw new Error('Config write verification failed. Check config.json.bak.'); - } - } - }); - await this.configSync.sync(server).catch((error) => { - this.logger.warn( - { serverId: server.id, err: String(error) }, - 'post-write config sync failed', - ); - }); - this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated'); + if (ops.length === 0) { + return { ...before, changedFields: [], requiresRestart: true }; } + const result = await this.editor.patch(server, ops, { + expectedRevision: options.expectedRevision ?? before.revision, + writeStartupVars: options.writeStartupVars ?? true, + }); + + this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated'); + + const after = await this.get(server); return { - settings: { ...before, ...patch } as PerformanceSettings, - fetchedAt: new Date().toISOString(), + settings: after.settings, + revision: result.revision, + fetchedAt: result.fetchedAt, changedFields, requiresRestart: true, }; diff --git a/apps/api/src/modules/config/reforger-config-file.test.ts b/apps/api/src/modules/config/reforger-config-file.test.ts index 6c4b74c..945e600 100644 --- a/apps/api/src/modules/config/reforger-config-file.test.ts +++ b/apps/api/src/modules/config/reforger-config-file.test.ts @@ -38,7 +38,7 @@ const REAL_SHAPE = { lobbyPlayerSynchronise: true, disableAI: false, aiLimit: -1, - playerSaveTime: 120 + playerSaveTime: 120, }, }; @@ -46,7 +46,7 @@ describe('parseReforgerConfigJson', () => { it('maps a real-shaped config.json into the panel model', () => { const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE)); expect(config).toEqual({ - serverName: 'DazzledCorp Training Grounds', + serverName: 'DZR Training Grounds', maxPlayers: 16, scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', disableAI: false, @@ -57,7 +57,7 @@ describe('parseReforgerConfigJson', () => { disableThirdPerson: true, mods: [ { modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' }, - { modId: '5AAF0CCE3F001FB5' }, + { modId: '5AAF0CCE3F001FB5', name: undefined, version: undefined }, ], }); }); diff --git a/apps/api/src/modules/config/startup-mirrors.ts b/apps/api/src/modules/config/startup-mirrors.ts new file mode 100644 index 0000000..0dbe2f5 --- /dev/null +++ b/apps/api/src/modules/config/startup-mirrors.ts @@ -0,0 +1,80 @@ +import type { StartupMirror, StartupVariable } from '@reforger-panel/shared'; +import { readAtPath } from './config-tree.js'; + +/** + * Reforger eggs commonly regenerate config.json from Pterodactyl startup + * variables when the container boots. When that happens, editing the file + * alone looks like it worked — the panel writes it, verifies it, and then the + * next restart silently throws the change away. + * + * This map is how the panel notices. Only pairs whose startup variable + * actually exists on the egg are reported, so eggs that do not template + * anything produce no warnings at all. + */ +export const STARTUP_MIRROR_MAP: readonly { envVariable: string; configPath: string }[] = [ + { envVariable: 'SCENARIO_ID', configPath: 'game.scenarioId' }, + { envVariable: 'MISSION_ID', configPath: 'game.scenarioId' }, + { envVariable: 'MAX_PLAYERS', configPath: 'game.maxPlayers' }, + { envVariable: 'SERVER_NAME', configPath: 'game.name' }, + { envVariable: 'HOSTNAME', configPath: 'game.name' }, + { envVariable: 'SERVER_PASSWORD', configPath: 'game.password' }, + { envVariable: 'ADMIN_PASSWORD', configPath: 'game.passwordAdmin' }, + { envVariable: 'GAME_PORT', configPath: 'bindPort' }, + { envVariable: 'SERVER_PORT', configPath: 'bindPort' }, + { envVariable: 'BIND_PORT', configPath: 'bindPort' }, + { envVariable: 'SERVER_IP', configPath: 'bindAddress' }, + { envVariable: 'BIND_ADDRESS', configPath: 'bindAddress' }, + { envVariable: 'A2S_PORT', configPath: 'a2s.port' }, + { envVariable: 'RCON_PORT', configPath: 'rcon.port' }, + { envVariable: 'RCON_PASSWORD', configPath: 'rcon.password' }, + { envVariable: 'CROSS_PLATFORM', configPath: 'game.crossPlatform' }, + { envVariable: 'CROSSPLAY', configPath: 'game.crossPlatform' }, + { envVariable: 'BATTLEYE', configPath: 'game.gameProperties.battlEye' }, + { envVariable: 'VISIBLE', configPath: 'game.visible' }, + { envVariable: 'DISABLE_THIRD_PERSON', configPath: 'game.gameProperties.disableThirdPerson' }, + { envVariable: 'VIEW_DISTANCE', configPath: 'game.gameProperties.serverMaxViewDistance' }, +]; + +/** Loose comparison — startup variables are always strings. */ +function sameValue(startupValue: string, configValue: string | number | boolean | null): boolean { + if (configValue === null) return startupValue === ''; + if (typeof configValue === 'boolean') { + const normalized = startupValue.trim().toLowerCase(); + return configValue + ? ['1', 'true', 'yes'].includes(normalized) + : ['0', 'false', 'no', ''].includes(normalized); + } + return String(configValue).trim() === startupValue.trim(); +} + +export function detectStartupMirrors( + root: Record, + variables: readonly StartupVariable[], +): StartupMirror[] { + const byEnv = new Map(variables.map((variable) => [variable.envVariable, variable])); + const mirrors: StartupMirror[] = []; + const seen = new Set(); + + for (const { envVariable, configPath } of STARTUP_MIRROR_MAP) { + const variable = byEnv.get(envVariable); + if (!variable) continue; + const key = `${envVariable}:${configPath}`; + if (seen.has(key)) continue; + seen.add(key); + const configValue = readAtPath(root, configPath); + mirrors.push({ + envVariable, + configPath, + startupValue: variable.value, + configValue, + conflict: !sameValue(variable.value, configValue), + }); + } + + return mirrors; +} + +/** Startup variables that mirror a given config path, for "write both". */ +export function mirrorsForPath(path: string, mirrors: readonly StartupMirror[]): StartupMirror[] { + return mirrors.filter((mirror) => mirror.configPath === path); +} diff --git a/apps/api/src/modules/pterodactyl/console-hub.test.ts b/apps/api/src/modules/pterodactyl/console-hub.test.ts new file mode 100644 index 0000000..6826ce7 --- /dev/null +++ b/apps/api/src/modules/pterodactyl/console-hub.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { ConsoleEvent, LiveStats } from './console-hub.js'; +import { BaseConsoleHub, mapPowerState, stripAnsi } from './console-hub.js'; + +const ESC = String.fromCharCode(27); + +class TestHub extends BaseConsoleHub { + start(): void { + this.setConnected(true); + } + async stop(): Promise { + this.setConnected(false); + } + emitOutput(stream: 'console' | 'install' | 'daemon', chunk: string): void { + this.pushOutput(stream, chunk); + } + emitStats(stats: LiveStats): void { + this.setStats(stats); + } + emitStatus(status: Parameters[0]): void { + this.setStatus(status); + } +} + +function stats(overrides: Partial = {}): LiveStats { + return { + status: 'online', + cpuPercent: 38, + memoryBytes: 1024, + diskBytes: 2048, + networkRxBytes: 10, + networkTxBytes: 20, + uptimeMs: 1000, + at: Date.now(), + ...overrides, + }; +} + +describe('mapPowerState', () => { + it('maps every Wings state, defaulting to unknown', () => { + expect(mapPowerState('running')).toBe('online'); + expect(mapPowerState('starting')).toBe('starting'); + expect(mapPowerState('stopping')).toBe('stopping'); + expect(mapPowerState('offline')).toBe('offline'); + expect(mapPowerState(undefined)).toBe('unknown'); + expect(mapPowerState('something-new')).toBe('unknown'); + }); +}); + +describe('stripAnsi', () => { + it('removes the colour escapes Wings wraps console output in', () => { + expect(stripAnsi(`${ESC}[0;32mSuccess!${ESC}[0m`)).toBe('Success!'); + }); + + it('leaves plain text alone', () => { + expect(stripAnsi('NETWORK : Server is ready')).toBe('NETWORK : Server is ready'); + }); +}); + +describe('BaseConsoleHub', () => { + it('splits chunks into lines and numbers them for de-duplication', () => { + const hub = new TestHub(); + hub.emitOutput('console', 'first\r\nsecond\n\nthird\n'); + const lines = hub.backlog().lines; + expect(lines.map((line) => line.text)).toEqual(['first', 'second', 'third']); + expect(lines.map((line) => line.seq)).toEqual([1, 2, 3]); + }); + + it('keeps install output distinguishable from game output', () => { + const hub = new TestHub(); + hub.emitOutput('install', 'Downloading mod 595F2BF2F44836FB'); + expect(hub.backlog().lines[0]!.stream).toBe('install'); + }); + + it('replays the backlog and current state to a late subscriber', () => { + const hub = new TestHub(); + hub.start(); + hub.emitOutput('console', 'boot line'); + hub.emitStatus('starting'); + const backlog = hub.backlog(); + expect(backlog.lines).toHaveLength(1); + expect(backlog.status).toBe('starting'); + expect(backlog.connected).toBe(true); + }); + + it('fans events out to subscribers until they unsubscribe', () => { + const hub = new TestHub(); + const events: ConsoleEvent[] = []; + const unsubscribe = hub.subscribe((event) => events.push(event)); + + hub.emitOutput('console', 'one'); + hub.emitStatus('online'); + unsubscribe(); + hub.emitOutput('console', 'two'); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: 'line' }); + expect(events[1]).toMatchObject({ type: 'status', status: 'online' }); + }); + + it('does not re-emit an unchanged status', () => { + const hub = new TestHub(); + const events: ConsoleEvent[] = []; + hub.subscribe((event) => events.push(event)); + hub.emitStatus('online'); + hub.emitStatus('online'); + expect(events.filter((event) => event.type === 'status')).toHaveLength(1); + }); + + it('takes the status carried by a stats frame', () => { + const hub = new TestHub(); + hub.emitStats(stats({ status: 'starting' })); + expect(hub.latestStatus()).toBe('starting'); + expect(hub.latestStats()?.cpuPercent).toBe(38); + }); + + it('a throwing subscriber cannot break the feed for others', () => { + const hub = new TestHub(); + const seen: string[] = []; + hub.subscribe(() => { + throw new Error('bad subscriber'); + }); + hub.subscribe((event) => { + if (event.type === 'line') seen.push(event.line.text); + }); + hub.emitOutput('console', 'still delivered'); + expect(seen).toEqual(['still delivered']); + }); +}); diff --git a/apps/api/src/modules/pterodactyl/console-hub.ts b/apps/api/src/modules/pterodactyl/console-hub.ts new file mode 100644 index 0000000..55ed192 --- /dev/null +++ b/apps/api/src/modules/pterodactyl/console-hub.ts @@ -0,0 +1,148 @@ +import type { + ConsoleBacklog, + ConsoleLine, + ConsoleLineStream, + ServerStatus, +} from '@reforger-panel/shared'; + +/** Maps a hosting backend's power state onto the panel's status vocabulary. */ +export function mapPowerState(state: string | undefined): ServerStatus { + switch (state) { + case 'running': + return 'online'; + case 'offline': + return 'offline'; + case 'starting': + return 'starting'; + case 'stopping': + return 'stopping'; + default: + return 'unknown'; + } +} + +/** A resource frame pushed by Wings, or synthesised by the mock provider. */ +export type LiveStats = { + status: ServerStatus; + cpuPercent: number; + memoryBytes: number; + diskBytes: number; + networkRxBytes: number; + networkTxBytes: number; + uptimeMs: number; + /** Unix ms the frame arrived. */ + at: number; +}; + +export type ConsoleEvent = + | { type: 'line'; line: ConsoleLine } + | { type: 'status'; status: ServerStatus } + | { type: 'stats'; stats: LiveStats }; + +/** + * A live feed of a game server's console, status and resource usage. + * + * The panel previously reconstructed "live" output by repeatedly downloading + * the *game's* log file, which meant nothing was visible until the game itself + * had started and created that file — the install, update and mod download + * phases were invisible. A hub instead carries whatever the hosting backend is + * emitting, in real time. + */ +export interface ConsoleHub { + start(): void; + stop(): Promise; + /** Returns an unsubscribe function. */ + subscribe(listener: (event: ConsoleEvent) => void): () => void; + /** Recent lines plus current state, so a new viewer sees context instantly. */ + backlog(): ConsoleBacklog; + latestStats(): LiveStats | null; + latestStatus(): ServerStatus; +} + +const MAX_BACKLOG_LINES = 2_000; + +/** + * Wings colourises console output with ANSI escapes; the panel styles lines + * itself, so they are stripped before they reach the browser. Built from a + * char code rather than a literal escape to keep this file plain ASCII. + */ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g'); + +export function stripAnsi(value: string): string { + return value.replace(ANSI_PATTERN, ''); +} + +/** + * Shared line buffer, subscriber fan-out and state tracking. Both the real + * Wings hub and the mock hub build on this. + */ +export abstract class BaseConsoleHub implements ConsoleHub { + private readonly listeners = new Set<(event: ConsoleEvent) => void>(); + private readonly lines: ConsoleLine[] = []; + private seq = 0; + private status: ServerStatus = 'unknown'; + private stats: LiveStats | null = null; + protected connected = false; + + abstract start(): void; + abstract stop(): Promise; + + subscribe(listener: (event: ConsoleEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + backlog(): ConsoleBacklog { + return { lines: [...this.lines], status: this.status, connected: this.connected }; + } + + latestStats(): LiveStats | null { + return this.stats; + } + + latestStatus(): ServerStatus { + return this.status; + } + + private emit(event: ConsoleEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch { + // A broken subscriber must never take the upstream feed down. + } + } + } + + /** Splits on newlines and pushes each non-empty line into the ring buffer. */ + protected pushOutput(stream: ConsoleLineStream, chunk: string): void { + const at = Date.now(); + for (const raw of stripAnsi(chunk).split(/\r?\n/)) { + const text = raw.replace(/\r/g, '').trimEnd(); + if (!text) continue; + const line: ConsoleLine = { seq: ++this.seq, stream, text, at }; + this.lines.push(line); + this.emit({ type: 'line', line }); + } + if (this.lines.length > MAX_BACKLOG_LINES) { + this.lines.splice(0, this.lines.length - MAX_BACKLOG_LINES); + } + } + + protected setStatus(status: ServerStatus): void { + if (this.status === status) return; + this.status = status; + this.emit({ type: 'status', status }); + } + + protected setStats(stats: LiveStats): void { + this.stats = stats; + // Wings reports the state alongside every stats frame; trust it. + this.setStatus(stats.status); + this.emit({ type: 'stats', stats }); + } + + protected setConnected(connected: boolean): void { + this.connected = connected; + } +} diff --git a/apps/api/src/modules/pterodactyl/mock-console-hub.ts b/apps/api/src/modules/pterodactyl/mock-console-hub.ts new file mode 100644 index 0000000..e582903 --- /dev/null +++ b/apps/api/src/modules/pterodactyl/mock-console-hub.ts @@ -0,0 +1,106 @@ +import type { ServerStatus } from '@reforger-panel/shared'; +import { BaseConsoleHub } from './console-hub.js'; +import type { MockGameServerProvider } from './mock-provider.js'; + +const STATS_INTERVAL_MS = 2_000; + +/** + * Simulates the Wings feed so the whole panel — live console, status strip and + * resource graphs — works under USE_MOCK_PTERODACTYL. The startup script + * deliberately includes the update and mod-download phase, since that is the + * part the real websocket exists to surface. + */ +const BOOT_SCRIPT: { delayMs: number; stream: 'console' | 'install' | 'daemon'; text: string }[] = [ + { + delayMs: 100, + stream: 'daemon', + text: 'Pulling Docker container image, ensuring it is up to date.', + }, + { delayMs: 600, stream: 'daemon', text: 'Finished pulling Docker container image.' }, + { delayMs: 900, stream: 'console', text: 'Redirecting stderr to stdout.' }, + { + delayMs: 1200, + stream: 'console', + text: 'Update state (0x5) verifying install, progress: 46.12 (1039 / 2253)', + }, + { delayMs: 1800, stream: 'console', text: 'Success! App "1874900" fully installed.' }, + { + delayMs: 2200, + stream: 'console', + text: 'Downloading workshop mod 591AF5BDA9F7CE8B (Mock Sample Mod)', + }, + { + delayMs: 2900, + stream: 'console', + text: 'Mod 591AF5BDA9F7CE8B downloaded (4.2 MiB), verifying.', + }, + { + delayMs: 3300, + stream: 'console', + text: 'ENGINE : Enfusion engine build: 1.3.0.42 (mock)', + }, + { delayMs: 3700, stream: 'console', text: 'DEFAULT : Loading world.' }, + { delayMs: 4100, stream: 'console', text: 'DEFAULT : Game successfully created.' }, + { delayMs: 4300, stream: 'console', text: 'NETWORK : Server is ready to accept connections' }, +]; + +export class MockConsoleHub extends BaseConsoleHub { + private statsTimer: ReturnType | null = null; + private bootTimers: ReturnType[] = []; + private unsubscribe: (() => void) | null = null; + + constructor(private readonly provider: MockGameServerProvider) { + super(); + } + + start(): void { + if (this.statsTimer) return; + this.setConnected(true); + this.setStatus(this.provider.currentStatus); + this.unsubscribe = this.provider.onStatusChange((status) => this.onStatusChange(status)); + this.statsTimer = setInterval(() => void this.pushStats(), STATS_INTERVAL_MS); + this.statsTimer.unref?.(); + void this.pushStats(); + this.pushOutput('daemon', 'Attached to mock Pterodactyl console.'); + } + + async stop(): Promise { + if (this.statsTimer) clearInterval(this.statsTimer); + this.statsTimer = null; + for (const timer of this.bootTimers) clearTimeout(timer); + this.bootTimers = []; + this.unsubscribe?.(); + this.unsubscribe = null; + this.setConnected(false); + } + + private onStatusChange(status: ServerStatus): void { + this.setStatus(status); + if (status === 'starting') this.playBootScript(); + if (status === 'stopping') this.pushOutput('daemon', 'Stopping server container.'); + if (status === 'offline') this.pushOutput('daemon', 'Server marked as offline.'); + } + + private playBootScript(): void { + for (const timer of this.bootTimers) clearTimeout(timer); + this.bootTimers = BOOT_SCRIPT.map((step) => { + const timer = setTimeout(() => this.pushOutput(step.stream, step.text), step.delayMs); + timer.unref?.(); + return timer; + }); + } + + private async pushStats(): Promise { + const resources = await this.provider.getServerResources(); + this.setStats({ + status: resources.status, + cpuPercent: resources.cpuPercent, + memoryBytes: resources.memoryBytes, + diskBytes: resources.diskBytes, + networkRxBytes: resources.networkRxBytes, + networkTxBytes: resources.networkTxBytes, + uptimeMs: resources.uptimeMs, + at: Date.now(), + }); + } +} diff --git a/apps/api/src/modules/pterodactyl/mock-provider.ts b/apps/api/src/modules/pterodactyl/mock-provider.ts index 80c0237..c3fccf0 100644 --- a/apps/api/src/modules/pterodactyl/mock-provider.ts +++ b/apps/api/src/modules/pterodactyl/mock-provider.ts @@ -7,6 +7,7 @@ import { ApiError } from '../../lib/errors.js'; import type { DownloadableFile, GameServerProvider, + ProviderServerLimits, ProviderServerResources, ServerFileEntry, } from './types.js'; @@ -138,11 +139,11 @@ export class MockGameServerProvider implements GameServerProvider { } private transition(to: ServerStatus, after: number, thenTo: ServerStatus) { - this.status = to; + this.setStatus(to); if (this.transitionTimer) clearTimeout(this.transitionTimer); this.transitionTimer = setTimeout(() => { - this.status = thenTo; if (thenTo === 'online') this.startedAt = Date.now(); + this.setStatus(thenTo); this.transitionTimer = null; }, after); this.transitionTimer.unref?.(); @@ -152,6 +153,32 @@ export class MockGameServerProvider implements GameServerProvider { return this.status; } + /** Exposed so the panel can subscribe to simulated power transitions. */ + get currentStatus(): ServerStatus { + return this.status; + } + + onStatusChange(listener: (status: ServerStatus) => void): () => void { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + + private readonly statusListeners = new Set<(status: ServerStatus) => void>(); + + private setStatus(status: ServerStatus): void { + if (this.status === status) return; + this.status = status; + for (const listener of this.statusListeners) listener(status); + } + + async getServerLimits(): Promise { + return { + cpuLimitPercent: 400, + memoryLimitBytes: 8 * 1024 ** 3, + diskLimitBytes: 40 * 1024 ** 3, + }; + } + async getServerResources(): Promise { const online = this.status === 'online'; const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread; @@ -183,8 +210,8 @@ export class MockGameServerProvider implements GameServerProvider { this.transition('stopping', STOP_DELAY_MS, 'starting'); setTimeout(() => { if (this.status === 'starting') { - this.status = 'online'; this.startedAt = Date.now(); + this.setStatus('online'); } }, STOP_DELAY_MS + START_DELAY_MS).unref?.(); } @@ -215,6 +242,30 @@ export class MockGameServerProvider implements GameServerProvider { } private startupVariables = [ + { + name: 'Server Name', + description: 'Templated into config.json at boot by the egg.', + envVariable: 'SERVER_NAME', + serverValue: 'Mock Reforger Server', + defaultValue: 'Arma Reforger Server', + isEditable: true, + }, + { + name: 'Max Players', + description: 'Templated into config.json at boot by the egg.', + envVariable: 'MAX_PLAYERS', + serverValue: '16', + defaultValue: '64', + isEditable: true, + }, + { + name: 'Scenario ID', + description: 'Templated into config.json at boot by the egg.', + envVariable: 'SCENARIO_ID', + serverValue: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf', + defaultValue: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + isEditable: true, + }, { name: 'Server Password', description: 'Password required to join the server.', diff --git a/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts b/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts index 0a27a9a..410aaf2 100644 --- a/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts +++ b/apps/api/src/modules/pterodactyl/pterodactyl-provider.ts @@ -5,9 +5,11 @@ import type { ServerStatus, } from '@reforger-panel/shared'; import { ApiError } from '../../lib/errors.js'; +import { mapPowerState } from './console-hub.js'; import type { DownloadableFile, GameServerProvider, + ProviderServerLimits, ProviderServerResources, ServerFileEntry, } from './types.js'; @@ -15,6 +17,8 @@ import type { const DEFAULT_TIMEOUT_MS = 10_000; const DOWNLOAD_TIMEOUT_MS = 30_000; const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024; +/** Plan limits change rarely, but not never; a minute keeps them honest. */ +const LIMITS_CACHE_TTL_MS = 60_000; type PterodactylOptions = { baseUrl: string; @@ -60,21 +64,6 @@ type PterodactylScheduleAttributes = { }; }; -function mapState(state: string): ServerStatus { - switch (state) { - case 'running': - return 'online'; - case 'offline': - return 'offline'; - case 'starting': - return 'starting'; - case 'stopping': - return 'stopping'; - default: - return 'unknown'; - } -} - /** * Pterodactyl Client API provider. Uses only client-scoped endpoints (status, * resources, power, read-only file access). Errors are sanitized: they carry @@ -143,9 +132,9 @@ export class PterodactylProvider implements GameServerProvider { } } - private async getLimits(serverId: string) { + async getServerLimits(serverId: string): Promise { const cached = this.limitsCache.get(serverId); - if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached; + if (cached && Date.now() - cached.fetchedAt < LIMITS_CACHE_TTL_MS) return cached; const data = await this.request<{ attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } }; }>('server details', `/servers/${encodeURIComponent(serverId)}`); @@ -182,14 +171,14 @@ export class PterodactylProvider implements GameServerProvider { const attrs = data.attributes ?? {}; const res = attrs.resources ?? {}; - const limits = await this.getLimits(serverId).catch(() => ({ + const limits = await this.getServerLimits(serverId).catch(() => ({ cpuLimitPercent: null, memoryLimitBytes: null, diskLimitBytes: null, })); return { - status: mapState(attrs.current_state ?? 'unknown'), + status: mapPowerState(attrs.current_state), cpuPercent: res.cpu_absolute ?? 0, cpuLimitPercent: limits.cpuLimitPercent, memoryBytes: res.memory_bytes ?? 0, diff --git a/apps/api/src/modules/pterodactyl/types.ts b/apps/api/src/modules/pterodactyl/types.ts index 9f63d89..67c41e4 100644 --- a/apps/api/src/modules/pterodactyl/types.ts +++ b/apps/api/src/modules/pterodactyl/types.ts @@ -4,6 +4,12 @@ import type { ServerStatus, } from '@reforger-panel/shared'; +export type ProviderServerLimits = { + cpuLimitPercent: number | null; + memoryLimitBytes: number | null; + diskLimitBytes: number | null; +}; + export type ProviderServerResources = { status: ServerStatus; cpuPercent: number; @@ -42,6 +48,12 @@ export type DownloadableFile = { export interface GameServerProvider { getServerStatus(serverId: string): Promise; getServerResources(serverId: string): Promise; + /** + * Plan limits, cached by the implementation. Split out from resources so a + * live websocket stats frame (which carries usage but not limits) can still + * be rendered with a denominator. + */ + getServerLimits(serverId: string): Promise; startServer(serverId: string): Promise; stopServer(serverId: string): Promise; diff --git a/apps/api/src/modules/pterodactyl/wings-socket.ts b/apps/api/src/modules/pterodactyl/wings-socket.ts new file mode 100644 index 0000000..6f73e54 --- /dev/null +++ b/apps/api/src/modules/pterodactyl/wings-socket.ts @@ -0,0 +1,240 @@ +import WebSocket from 'ws'; +import type { Logger } from '../../lib/logger.js'; +import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js'; + +type WebsocketCredentials = { token: string; socket: string }; + +type WingsMessage = { event?: string; args?: unknown[] }; + +const CREDENTIAL_TIMEOUT_MS = 10_000; +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +/** Wings tokens last 10 minutes; refresh well before the warning arrives. */ +const TOKEN_REFRESH_MS = 8 * 60_000; + +/** + * Keeps one authenticated Wings websocket open for the server and republishes + * everything it emits: console output (including the SteamCMD/mod-download + * phase that never reaches the game's own log file), install output, power + * state transitions, and resource frames. + * + * One upstream connection is shared by every panel viewer, and the base class + * keeps a line backlog so a browser attaching mid-session immediately sees + * context instead of an empty pane. + */ +export class WingsConsoleHub extends BaseConsoleHub { + private socket: WebSocket | null = null; + private stopped = true; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private refreshTimer: ReturnType | null = null; + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor( + private readonly options: { + baseUrl: string; + apiKey: string; + serverId: string; + logger: Logger; + fetchImpl?: typeof fetch; + }, + ) { + super(); + this.baseUrl = options.baseUrl.replace(/\/$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + start(): void { + if (!this.stopped) return; + this.stopped = false; + this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS); + this.refreshTimer.unref?.(); + void this.connect(); + } + + async stop(): Promise { + this.stopped = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.refreshTimer) clearInterval(this.refreshTimer); + this.reconnectTimer = null; + this.refreshTimer = null; + const socket = this.socket; + this.socket = null; + this.setConnected(false); + socket?.close(); + } + + /** Fetches a fresh websocket token from the Pterodactyl client API. */ + private async fetchCredentials(): Promise { + const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent( + this.options.serverId, + )}/websocket`; + const response = await this.fetchImpl(url, { + headers: { + Authorization: `Bearer ${this.options.apiKey}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`websocket credentials returned HTTP ${response.status}`); + } + const body = (await response.json()) as { data?: { token?: string; socket?: string } }; + const token = body.data?.token; + const socket = body.data?.socket; + if (!token || !socket) throw new Error('websocket credentials response was incomplete'); + return { token, socket }; + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt); + this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + private async connect(): Promise { + if (this.stopped || this.socket) return; + let credentials: WebsocketCredentials; + try { + credentials = await this.fetchCredentials(); + } catch (error) { + this.options.logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'wings websocket credentials failed', + ); + this.scheduleReconnect(); + return; + } + if (this.stopped) return; + + // Wings only upgrades connections whose Origin matches the panel URL + // (see wings router/websocket GetHandler.CheckOrigin), which is why this + // uses `ws` rather than Node's built-in WebSocket. + const socket = new WebSocket(credentials.socket, { origin: this.baseUrl }); + this.socket = socket; + + socket.on('open', () => { + this.reconnectAttempt = 0; + this.setConnected(true); + this.send(socket, 'auth', credentials.token); + }); + + socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw))); + + socket.on('error', (error: Error) => { + this.options.logger.debug({ err: error.message }, 'wings websocket error'); + }); + + socket.on('close', () => { + if (this.socket === socket) this.socket = null; + this.setConnected(false); + this.scheduleReconnect(); + }); + } + + private send(socket: WebSocket, event: string, ...args: string[]): void { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ event, args })); + } + + /** Re-auths the live socket with a fresh token before the old one expires. */ + private async reauthenticate(): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + try { + const { token } = await this.fetchCredentials(); + this.send(socket, 'auth', token); + } catch (error) { + this.options.logger.debug( + { err: error instanceof Error ? error.message : String(error) }, + 'wings token refresh failed', + ); + } + } + + private handleMessage(raw: string): void { + let message: WingsMessage; + try { + message = JSON.parse(raw) as WingsMessage; + } catch { + return; + } + const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : ''; + + switch (message.event) { + case 'auth success': + // Ask for the recent backlog and an immediate resource frame so the + // panel is populated the moment it attaches. + if (this.socket) { + this.send(this.socket, 'send logs'); + this.send(this.socket, 'send stats'); + } + break; + case 'console output': + this.pushOutput('console', arg); + break; + case 'install output': + this.pushOutput('install', arg); + break; + case 'install started': + this.pushOutput('daemon', 'Installation started.'); + break; + case 'install completed': + this.pushOutput('daemon', 'Installation completed.'); + break; + case 'daemon message': + this.pushOutput('daemon', arg); + break; + case 'daemon error': + case 'jwt error': + this.pushOutput('daemon', arg); + void this.reauthenticate(); + break; + case 'token expiring': + case 'token expired': + void this.reauthenticate(); + break; + case 'status': + this.setStatus(mapPowerState(arg)); + break; + case 'stats': + this.handleStats(arg); + break; + default: + break; + } + } + + private handleStats(payload: string): void { + let parsed: { + state?: string; + cpu_absolute?: number; + memory_bytes?: number; + disk_bytes?: number; + uptime?: number; + network?: { rx_bytes?: number; tx_bytes?: number }; + }; + try { + parsed = JSON.parse(payload) as typeof parsed; + } catch { + return; + } + const stats: LiveStats = { + status: mapPowerState(parsed.state), + cpuPercent: parsed.cpu_absolute ?? 0, + memoryBytes: parsed.memory_bytes ?? 0, + diskBytes: parsed.disk_bytes ?? 0, + networkRxBytes: parsed.network?.rx_bytes ?? 0, + networkTxBytes: parsed.network?.tx_bytes ?? 0, + uptimeMs: parsed.uptime ?? 0, + at: Date.now(), + }; + this.setStats(stats); + } +} diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.test.ts b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts index 11d138c..681ee35 100644 --- a/apps/api/src/modules/reforger-logs/missions-catalog.test.ts +++ b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { WorkshopModDetail, WorkshopScenario } from '@reforger-panel/shared'; +import type { WorkshopCache } from '../workshop/workshop-cache.js'; import { - hasScenarioTag, - mergeMissions, + MissionsService, + OFFICIAL_MISSIONS, + OFFICIAL_SCENARIO_IDS, parseMissionList, - scenariosFromWorkshopMod, } from './missions-catalog.js'; +import type { MissionCatalog, ParsedMission } from './missions-catalog.js'; // Verbatim shape from a real console.log (server runs with -listScenarios). const LOG = [ @@ -23,12 +26,12 @@ describe('parseMissionList', () => { it('parses scenario ids, display names, and section sources', () => { const missions = parseMissionList(LOG); expect(missions).toHaveLength(4); - expect(missions[0]).toEqual({ + expect(missions[0]).toMatchObject({ scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', name: 'Conflict - Everon', source: 'official', }); - expect(missions[3]).toEqual({ + expect(missions[3]).toMatchObject({ scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf', name: 'Custom Ops', source: 'workshop', @@ -36,8 +39,7 @@ describe('parseMissionList', () => { }); it('deduplicates repeated listings (multiple boots in one file)', () => { - const missions = parseMissionList(`${LOG}\n${LOG}`); - expect(missions).toHaveLength(4); + expect(parseMissionList(`${LOG}\n${LOG}`)).toHaveLength(4); }); it('returns an empty list when no listing is present', () => { @@ -45,62 +47,128 @@ describe('parseMissionList', () => { }); }); -describe('workshop scenario helpers', () => { - it('recognizes scenario tag variants from the workshop', () => { - expect(hasScenarioTag(['SCENARIOS_MP'])).toBe(true); - expect(hasScenarioTag(['scenario sp'])).toBe(true); - expect(hasScenarioTag(['WEAPONS'])).toBe(false); +describe('OFFICIAL_MISSIONS', () => { + it('covers the vanilla scenarios with well-formed ids', () => { + expect(OFFICIAL_MISSIONS.length).toBeGreaterThan(15); + for (const mission of OFFICIAL_MISSIONS) { + expect(mission.scenarioId).toMatch(/^\{[0-9A-F]{16}\}Missions\/.+\.conf$/); + expect(mission.name).not.toMatch(/^#AR-/); + } + expect(OFFICIAL_SCENARIO_IDS.has('{ECC61978EDCC2B5A}Missions/23_Campaign.conf')).toBe(true); }); +}); - it('converts mod scenarios into mission entries', () => { - const missions = scenariosFromWorkshopMod({ - id: 'ABC', - name: 'Scenario Pack', - author: 'Author', - imageUrl: null, - size: null, - rating: null, - workshopUrl: null, - version: null, - gameVersion: null, - subscribers: null, - downloads: null, - createdAtText: null, - lastModifiedText: null, - summary: null, - description: null, - license: null, - tags: [], - dependencies: [], - scenarios: [ +function scenario(id: string, name: string): WorkshopScenario { + return { + scenarioId: id, + name, + gameMode: 'Campaign', + author: null, + description: null, + playerCount: 64, + }; +} + +function fakeWorkshop(mods: Record) { + return { + tryGetMod: vi.fn(async (id: string) => { + const mod = mods[id]; + if (!mod) return null; + return { + id, + name: mod.name, + scenarioCount: mod.scenarios.length, + scenarios: mod.scenarios, + } as unknown as WorkshopModDetail; + }), + getScenarios: vi.fn(async (id: string) => mods[id]?.scenarios ?? []), + } as unknown as WorkshopCache; +} + +function fakeCatalog(missions: ParsedMission[]): MissionCatalog { + return { + list: async () => ({ missions, fetchedAt: new Date().toISOString() }), + } as MissionCatalog; +} + +describe('MissionsService', () => { + it('always offers the vanilla scenarios as one group', async () => { + const service = new MissionsService(fakeWorkshop({}), null); + const result = await service.list([]); + const official = result.groups.find((group) => group.id === 'official')!; + expect(official.kind).toBe('official'); + expect(official.missions.length).toBeGreaterThan(15); + }); + + it('adds one group per installed mod that ships scenarios', async () => { + const workshop = fakeWorkshop({ + AAAA000000000001: { + name: 'Scenario Pack', + scenarios: [scenario('{1111111111111111}Missions/RaidNight.conf', 'Raid Night')], + }, + BBBB000000000002: { name: 'Weapons Only', scenarios: [] }, + }); + const service = new MissionsService(workshop, null); + const result = await service.list([ + { modId: 'aaaa000000000001' }, + { modId: 'BBBB000000000002' }, + ]); + + const modGroups = result.groups.filter((group) => group.kind === 'mod'); + expect(modGroups).toHaveLength(1); + expect(modGroups[0]!.label).toBe('Scenario Pack'); + expect(modGroups[0]!.missions[0]).toEqual({ + scenarioId: '{1111111111111111}Missions/RaidNight.conf', + name: 'Raid Night', + gameMode: 'Campaign', + playerCount: 64, + }); + }); + + it('reports mods it could not resolve rather than silently dropping them', async () => { + const service = new MissionsService(fakeWorkshop({}), null); + const result = await service.list([{ modId: 'CCCC000000000003' }]); + expect(result.incompleteModIds).toEqual(['CCCC000000000003']); + }); + + it("prefers the server's own naming for official scenarios", async () => { + const service = new MissionsService( + fakeWorkshop({}), + fakeCatalog([ { - name: 'Raid Night', - description: null, - scenarioId: '{1111111111111111}Missions/RaidNight.conf', - gamemode: 'Coop', - playerCount: 32, - imageUrl: null, + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + name: 'Conflict - Everon (from log)', + gameMode: null, + playerCount: null, + source: 'official', }, - ], - }); - expect(missions).toEqual([ - { - scenarioId: '{1111111111111111}Missions/RaidNight.conf', - name: 'Raid Night', - source: 'mod: Scenario Pack', - }, - ]); + ]), + ); + const official = (await service.list([])).groups.find((group) => group.id === 'official')!; + const everon = official.missions.find( + (mission) => mission.scenarioId === '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + )!; + expect(everon.name).toBe('Conflict - Everon (from log)'); + // Only listed once, despite also being in the bundled set. + expect( + official.missions.filter((mission) => mission.scenarioId === everon.scenarioId), + ).toHaveLength(1); }); - it('deduplicates mission groups while preserving first source', () => { - const merged = mergeMissions( - [{ scenarioId: 'same', name: 'From Log', source: 'workshop' }], - [{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }], - [{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }], + it('surfaces log-reported scenarios that no mod group covers', async () => { + const service = new MissionsService( + fakeWorkshop({}), + fakeCatalog([ + { + scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf', + name: 'Custom Ops', + gameMode: null, + playerCount: null, + source: 'workshop', + }, + ]), ); - expect(merged).toEqual([ - { scenarioId: 'same', name: 'From Log', source: 'workshop' }, - { scenarioId: 'other', name: 'Other', source: 'mod: Pack' }, - ]); + const fallback = (await service.list([])).groups.find((group) => group.id === 'server-log')!; + expect(fallback.missions[0]!.name).toBe('Custom Ops'); }); }); diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.ts b/apps/api/src/modules/reforger-logs/missions-catalog.ts index a2e9cb0..349a012 100644 --- a/apps/api/src/modules/reforger-logs/missions-catalog.ts +++ b/apps/api/src/modules/reforger-logs/missions-catalog.ts @@ -1,17 +1,156 @@ -import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared'; +import type { + MissionGroup, + MissionInfo, + MissionsResponse, + ReforgerConfigMod, +} from '@reforger-panel/shared'; +import type { WorkshopCache } from '../workshop/workshop-cache.js'; import type { GameServerProvider } from '../pterodactyl/types.js'; import type { LogPathResolver } from './ingestion/log-path-resolver.js'; const CATALOG_TTL_MS = 10 * 60 * 1000; const CATALOG_MAX_BYTES = 2 * 1024 * 1024; -export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; -export const DEFAULT_MISSION: MissionInfo = { - scenarioId: DEFAULT_SCENARIO_ID, - name: 'Campaign - Montignac', - source: 'official', -}; -const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']); +/** Kept as the "known-good fallback" target for orphaned-mission recovery. */ +export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; + +/** + * Scenarios that ship with the base game. + * + * Compiled from the scenario ids live vanilla servers actually report (i.e. + * servers whose scenario is not provided by a Workshop mod), so the list + * reflects the shipped game rather than guesswork. It is a seed, not a closed + * set: anything the server itself prints at boot with `-listScenarios` is + * merged on top, so a game update that adds a mission still shows up. + */ +export const OFFICIAL_MISSIONS: readonly MissionInfo[] = [ + { + scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + name: 'Conflict - Everon', + gameMode: 'Conflict', + playerCount: 128, + }, + { + scenarioId: '{C41618FD18E9D714}Missions/23_Campaign_Arland.conf', + name: 'Conflict - Arland', + gameMode: 'Conflict', + playerCount: 128, + }, + { + scenarioId: '{9C6054B42A044DEC}Missions/23_Campaign_Cain.conf', + name: 'Conflict - Kolguyev', + gameMode: 'Conflict', + playerCount: 128, + }, + { + scenarioId: '{28802845ADA64D52}Missions/23_Campaign_NorthCentral.conf', + name: 'Conflict - Northern Everon', + gameMode: 'Conflict', + playerCount: 64, + }, + { + scenarioId: DEFAULT_SCENARIO_ID, + name: 'Conflict - Montignac', + gameMode: 'Conflict', + playerCount: 64, + }, + { + scenarioId: '{0220741028718E7F}Missions/23_Campaign_HQC_Everon.conf', + name: 'Commander - Everon', + gameMode: 'Commander', + playerCount: 128, + }, + { + scenarioId: '{68D1240A11492545}Missions/23_Campaign_HQC_Arland.conf', + name: 'Commander - Arland', + gameMode: 'Commander', + playerCount: 128, + }, + { + scenarioId: '{BB5345C22DD2B655}Missions/23_Campaign_HQC_Cain.conf', + name: 'Commander - Kolguyev', + gameMode: 'Commander', + playerCount: 128, + }, + { + scenarioId: '{DAA03C6E6099D50F}Missions/24_CombatOps.conf', + name: 'Combat Ops - Arland', + gameMode: 'Combat Ops', + playerCount: 16, + }, + { + scenarioId: '{DFAC5FABD11F2390}Missions/26_CombatOpsEveron.conf', + name: 'Combat Ops - Everon', + gameMode: 'Combat Ops', + playerCount: 16, + }, + { + scenarioId: '{CB347F2F10065C9C}Missions/CombatOpsCain.conf', + name: 'Combat Ops - Kolguyev', + gameMode: 'Combat Ops', + playerCount: 16, + }, + { + scenarioId: '{59AD59368755F41A}Missions/21_GM_Eden.conf', + name: 'Game Master - Everon', + gameMode: 'Game Master', + playerCount: 64, + }, + { + scenarioId: '{2BBBE828037C6F4B}Missions/22_GM_Arland.conf', + name: 'Game Master - Arland', + gameMode: 'Game Master', + playerCount: 64, + }, + { + scenarioId: '{F45C6C15D31252E6}Missions/27_GM_Cain.conf', + name: 'Game Master - Kolguyev', + gameMode: 'Game Master', + playerCount: 64, + }, + { + scenarioId: '{3F2E005F43DBD2F8}Missions/CAH_Briars_Coast.conf', + name: 'Capture & Hold - The Briars', + gameMode: 'Capture & Hold', + playerCount: 32, + }, + { + scenarioId: '{589945FB9FA7B97D}Missions/CAH_Concrete_Plant.conf', + name: 'Capture & Hold - Concrete Plant', + gameMode: 'Capture & Hold', + playerCount: 32, + }, + { + scenarioId: '{9405201CBD22A30C}Missions/CAH_Factory.conf', + name: 'Capture & Hold - Almara Factory', + gameMode: 'Capture & Hold', + playerCount: 32, + }, + { + scenarioId: '{1CD06B409C6FAE56}Missions/CAH_Forest.conf', + name: "Capture & Hold - Simon's Wood", + gameMode: 'Capture & Hold', + playerCount: 32, + }, + { + scenarioId: '{7C491B1FCC0FF0E1}Missions/CAH_LeMoule.conf', + name: 'Capture & Hold - Le Moule', + gameMode: 'Capture & Hold', + playerCount: 32, + }, + { + scenarioId: '{2B4183DF23E88249}Missions/CAH_Morton.conf', + name: 'Capture & Hold - Morton', + gameMode: 'Capture & Hold', + playerCount: 32, + }, +]; + +export const OFFICIAL_SCENARIO_IDS = new Set( + OFFICIAL_MISSIONS.map((mission) => mission.scenarioId), +); + +export type ParsedMission = MissionInfo & { source: string }; /** * Scenario listing printed at boot when the server runs with -listScenarios @@ -22,8 +161,8 @@ const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']); const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i; const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/; -export function parseMissionList(logContent: string): MissionInfo[] { - const missions: MissionInfo[] = []; +export function parseMissionList(logContent: string): ParsedMission[] { + const missions: ParsedMission[] = []; const seen = new Set(); let currentSource = 'official'; for (const line of logContent.split('\n')) { @@ -38,6 +177,8 @@ export function parseMissionList(logContent: string): MissionInfo[] { missions.push({ scenarioId: mission[1]!, name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1), + gameMode: null, + playerCount: null, source: currentSource, }); } @@ -45,47 +186,13 @@ export function parseMissionList(logContent: string): MissionInfo[] { return missions; } -export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] { - return mod.scenarios.map((scenario) => ({ - scenarioId: scenario.scenarioId, - name: scenario.name, - source: `mod: ${mod.name}`, - })); -} - -function normalizeTag(tag: string): string { - return tag - .trim() - .toLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .replace(/^scenarios\b/, 'scenario'); -} - -export function hasScenarioTag(tags: string[]): boolean { - return tags.some((tag) => SCENARIO_TAGS.has(normalizeTag(tag))); -} - -export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] { - const merged: MissionInfo[] = []; - const seen = new Set(); - for (const group of groups) { - for (const mission of group) { - if (seen.has(mission.scenarioId)) continue; - seen.add(mission.scenarioId); - merged.push(mission); - } - } - return merged; -} - /** - * Extracts the available-missions dropdown data from the server's current + * Extracts the scenarios the server itself reported at boot from its * console.log. Cached briefly; a fresh boot log always carries the listing * near the top, so the head of the file is enough. */ export class MissionCatalog { - private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null; + private cache: { missions: ParsedMission[]; fetchedAt: string; expiresAt: number } | null = null; constructor( private readonly provider: GameServerProvider, @@ -93,7 +200,7 @@ export class MissionCatalog { private readonly providerServerId: string, ) {} - async list(force = false): Promise { + async list(force = false): Promise<{ missions: ParsedMission[]; fetchedAt: string | null }> { if (!force && this.cache && this.cache.expiresAt > Date.now()) { return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt }; } @@ -121,3 +228,106 @@ export class MissionCatalog { return { missions: [], fetchedAt: null }; } } + +function dedupe(missions: MissionInfo[]): MissionInfo[] { + const seen = new Set(); + const result: MissionInfo[] = []; + for (const mission of missions) { + if (seen.has(mission.scenarioId)) continue; + seen.add(mission.scenarioId); + result.push(mission); + } + return result; +} + +/** + * Builds the mission picker: one list of the vanilla scenarios, then one group + * per installed mod that ships scenarios. + * + * Scenario ids come from the Workshop v2 `scenarios[].gameId` field, which is + * an actual id rather than something scraped out of prose, and a mod is asked + * for scenarios based on its reported `scenarioCount` rather than a tag + * heuristic — so mods that ship missions without tagging themselves as + * scenario mods are no longer missed. + */ +export class MissionsService { + constructor( + private readonly workshop: WorkshopCache, + private readonly catalog: MissionCatalog | null, + ) {} + + async list(installedMods: readonly ReforgerConfigMod[]): Promise { + const fromLog = this.catalog + ? await this.catalog.list().catch(() => ({ missions: [], fetchedAt: null })) + : { missions: [] as ParsedMission[], fetchedAt: null }; + + const logOfficial = fromLog.missions.filter((mission) => mission.source === 'official'); + const logOther = fromLog.missions.filter((mission) => mission.source !== 'official'); + + const groups: MissionGroup[] = [ + { + id: 'official', + label: 'Official (vanilla)', + kind: 'official', + // The server's own listing wins on naming; the bundled set fills in + // whatever a rotated log no longer mentions. + missions: dedupe([...logOfficial, ...OFFICIAL_MISSIONS]).sort((a, b) => + a.name.localeCompare(b.name), + ), + }, + ]; + + const incompleteModIds: string[] = []; + const modIds = installedMods.map((mod) => mod.modId.toUpperCase()); + const details = await Promise.all(modIds.map((id) => this.workshop.tryGetMod(id))); + + for (let index = 0; index < modIds.length; index += 1) { + const modId = modIds[index]!; + const detail = details[index]; + if (!detail) { + incompleteModIds.push(modId); + continue; + } + if (detail.scenarioCount === 0) continue; + const scenarios = await this.workshop.getScenarios(modId); + if (scenarios.length === 0) { + // Reported scenarios we could not enumerate — say so rather than + // silently showing a shorter list than the server has. + if (detail.scenarioCount > 0) incompleteModIds.push(modId); + continue; + } + groups.push({ + id: modId, + label: detail.name, + kind: 'mod', + missions: scenarios.map((scenario) => ({ + scenarioId: scenario.scenarioId, + name: scenario.name, + gameMode: scenario.gameMode, + playerCount: scenario.playerCount, + })), + }); + } + + // Anything the server reported that no group covers (mods the Workshop + // does not know about, hand-installed missions). + const covered = new Set( + groups.flatMap((group) => group.missions.map((mission) => mission.scenarioId)), + ); + const uncovered = logOther.filter((mission) => !covered.has(mission.scenarioId)); + if (uncovered.length > 0) { + groups.push({ + id: 'server-log', + label: 'Reported by the server', + kind: 'mod', + missions: dedupe(uncovered), + }); + } + + return { + groups: groups.filter((group) => group.missions.length > 0), + incompleteModIds, + fetchedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/modules/servers/metrics-service.ts b/apps/api/src/modules/servers/metrics-service.ts new file mode 100644 index 0000000..f8a4e8b --- /dev/null +++ b/apps/api/src/modules/servers/metrics-service.ts @@ -0,0 +1,68 @@ +import type { ServerResources, ServerStatus } from '@reforger-panel/shared'; +import type { ConsoleHub } from '../pterodactyl/console-hub.js'; +import type { GameServerProvider, ProviderServerLimits } from '../pterodactyl/types.js'; + +/** + * A Wings `stats` frame arrives roughly every two seconds. Anything older than + * this is treated as gone stale and we fall back to the REST endpoint. + */ +const LIVE_MAX_AGE_MS = 10_000; + +const NO_LIMITS: ProviderServerLimits = { + cpuLimitPercent: null, + memoryLimitBytes: null, + diskLimitBytes: null, +}; + +/** + * Single source of truth for "what is the server doing right now". + * + * Resource numbers previously came only from a 10-second REST poll, so every + * tile and graph in the panel lagged reality and disagreed with what + * Pterodactyl itself displayed. When the console hub is connected its pushed + * frames are authoritative; the REST call remains as the fallback for when the + * websocket is down or disabled. + */ +export class ServerMetricsService { + constructor( + private readonly provider: GameServerProvider, + private readonly hub: ConsoleHub | null, + ) {} + + private liveStats() { + const stats = this.hub?.latestStats(); + if (!stats) return null; + return Date.now() - stats.at <= LIVE_MAX_AGE_MS ? stats : null; + } + + async getStatus(serverId: string): Promise { + const live = this.liveStats(); + if (live) return live.status; + const hubStatus = this.hub?.latestStatus(); + if (hubStatus && hubStatus !== 'unknown') return hubStatus; + return this.provider.getServerStatus(serverId); + } + + async getResources(serverId: string): Promise { + const live = this.liveStats(); + if (live) { + const limits = await this.provider.getServerLimits(serverId).catch(() => NO_LIMITS); + return { + status: live.status, + cpuPercent: live.cpuPercent, + cpuLimitPercent: limits.cpuLimitPercent, + memoryBytes: live.memoryBytes, + memoryLimitBytes: limits.memoryLimitBytes, + diskBytes: live.diskBytes, + diskLimitBytes: limits.diskLimitBytes, + networkRxBytes: live.networkRxBytes, + networkTxBytes: live.networkTxBytes, + uptimeMs: live.uptimeMs, + fetchedAt: new Date(live.at).toISOString(), + source: 'live', + }; + } + const resources = await this.provider.getServerResources(serverId); + return { ...resources, fetchedAt: new Date().toISOString(), source: 'poll' }; + } +} diff --git a/apps/api/src/modules/servers/resource-history.ts b/apps/api/src/modules/servers/resource-history.ts index 10033eb..f41a449 100644 --- a/apps/api/src/modules/servers/resource-history.ts +++ b/apps/api/src/modules/servers/resource-history.ts @@ -1,6 +1,6 @@ import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared'; import type { Logger } from '../../lib/logger.js'; -import type { GameServerProvider } from '../pterodactyl/types.js'; +import type { ServerMetricsService } from './metrics-service.js'; export const SAMPLE_INTERVAL_SECONDS = 15; const MAX_SAMPLES = 240; // ~1 hour window @@ -9,8 +9,12 @@ type RawSample = ResourceSample & { rxTotal: number; txTotal: number }; /** * In-memory rolling window of resource usage for the dashboard graphs. - * Network rates are derived from the provider's cumulative rx/tx counters; - * history is intentionally not persisted (it is telemetry, not records). + * Network rates are derived from the cumulative rx/tx counters; history is + * intentionally not persisted (it is telemetry, not records). + * + * Samples come from ServerMetricsService, so when the Wings websocket is up + * this reads an already-pushed frame rather than issuing its own HTTP request + * every 15 seconds. */ export class ResourceHistoryService { private samples = new Map(); @@ -18,7 +22,7 @@ export class ResourceHistoryService { private servers: { serverId: string; providerServerId: string }[] = []; constructor( - private readonly provider: GameServerProvider, + private readonly metrics: ServerMetricsService, private readonly logger: Logger, private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS, ) {} @@ -62,7 +66,7 @@ export class ResourceHistoryService { } private async sampleOne(serverId: string, providerServerId: string): Promise { - const resources = await this.provider.getServerResources(providerServerId); + const resources = await this.metrics.getResources(providerServerId); const previous = this.samples.get(serverId)?.at(-1); const now = Date.now(); diff --git a/apps/api/src/modules/servers/server-routes.ts b/apps/api/src/modules/servers/server-routes.ts index ebe1da1..9a71ff2 100644 --- a/apps/api/src/modules/servers/server-routes.ts +++ b/apps/api/src/modules/servers/server-routes.ts @@ -1,54 +1,54 @@ import { Router } from 'express'; import { z } from 'zod'; import type { + ConfigPatchOp, LogIngestionHealth, - MissionInfo, - ModDependencyIssue, - ServerResources, + ReforgerConfigMod, ServerStatus, ServerSummary, } from '@reforger-panel/shared'; import { ApiError } from '../../lib/errors.js'; import { rateLimit } from '../../lib/rate-limit.js'; import { requireAuth, requireCapability } from '../auth/auth-middleware.js'; +import type { ConfigEditorService } from '../config/config-editor-service.js'; import type { ConfigSyncService } from '../config/config-sync.js'; import type { ServerModsService } from '../config/mods-service.js'; import type { PerformanceSettingsService } from '../config/performance-service.js'; -import type { ResourceHistoryService } from './resource-history.js'; +import type { ConsoleHub } from '../pterodactyl/console-hub.js'; import type { GameServerProvider } from '../pterodactyl/types.js'; import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js'; import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js'; -import type { MissionCatalog } from '../reforger-logs/missions-catalog.js'; -import { - DEFAULT_MISSION, - DEFAULT_SCENARIO_ID, - hasScenarioTag, - mergeMissions, - scenariosFromWorkshopMod, -} from '../reforger-logs/missions-catalog.js'; +import type { MissionsService } from '../reforger-logs/missions-catalog.js'; +import type { WorkshopCache } from '../workshop/workshop-cache.js'; +import type { ServerMetricsService } from './metrics-service.js'; +import type { ResourceHistoryService } from './resource-history.js'; import type { ServerRecord, ServerService } from './server-service.js'; -import type { WorkshopClient } from '../workshop/workshop-client.js'; const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.'); export type ServerRouterDeps = { service: ServerService; provider: GameServerProvider; + metrics: ServerMetricsService; + consoleHub: ConsoleHub | null; scheduler: IngestionScheduler | null; resolveLogPath: LogPathResolver | null; configSync: ConfigSyncService | null; + configEditor: ConfigEditorService | null; mods: ServerModsService | null; performance: PerformanceSettingsService | null; resourceHistory: ResourceHistoryService | null; - missions: MissionCatalog | null; - workshop: WorkshopClient; + missions: MissionsService; + workshop: WorkshopCache; staleAfterSeconds: number; mockMode: boolean; }; +const revisionSchema = z.string().regex(/^[a-f0-9]{8,64}$/, 'Invalid revision.'); + // Validation ranges follow the Bohemia server-config reference. Only provided // keys are touched; `null` removes the key (the game default applies). -const performanceBodySchema = z +const performanceSettingsSchema = z .object({ scenarioId: z .string() @@ -73,6 +73,48 @@ const performanceBodySchema = z .partial() .strict(); +const performanceBodySchema = z.object({ + settings: performanceSettingsSchema, + expectedRevision: revisionSchema.optional(), + writeStartupVars: z.boolean().default(true), +}); + +/** + * Dotted config paths only — no array indices, no prototype-polluting + * segments. `game.mods` is owned by the mods endpoints, which understand + * versions and dependencies, so it is refused here. + */ +const configPathSchema = z + .string() + .max(200) + .regex(/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/, 'Invalid config path.') + .refine((path) => !path.split('.').some((segment) => segment === '__proto__'), 'Invalid path.') + .refine((path) => path !== 'game.mods' && !path.startsWith('game.mods.'), { + message: 'The mod list is managed on the Mods page.', + }); + +const configPatchBodySchema = z.object({ + ops: z + .array( + z.object({ + path: configPathSchema, + value: z.union([z.string().max(4000), z.number(), z.boolean(), z.null()]), + }), + ) + .min(1) + .max(200), + expectedRevision: revisionSchema.optional(), + writeStartupVars: z.boolean().default(false), +}); + +const configRawBodySchema = z.object({ + content: z + .string() + .min(2) + .max(256 * 1024), + expectedRevision: revisionSchema.optional(), +}); + const startupVariableBodySchema = z.object({ key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'), value: z.string().max(500), @@ -91,31 +133,43 @@ const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid sche // Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server // config reference); name/version are free-ish text with sane caps. +const modEntrySchema = z.object({ + modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'), + name: z.string().trim().max(200).optional(), + version: z + .string() + .trim() + .max(32) + .regex(/^[\w.+-]*$/, 'Invalid version.') + .optional(), +}); + const modsBodySchema = z.object({ - mods: z - .array( - z.object({ - modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'), - name: z.string().trim().max(200).optional(), - version: z - .string() - .trim() - .max(32) - .regex(/^[\w.+-]*$/, 'Invalid version.') - .optional(), - }), - ) - .max(200), + mods: z.array(modEntrySchema).max(300), + expectedRevision: revisionSchema.optional(), +}); + +const modsResolveBodySchema = z.object({ + mods: z.array(modEntrySchema).max(300), }); function providerId(server: ServerRecord): string { return server.pterodactylServerId ?? server.slug; } +/** Rejects duplicate mod ids up front instead of silently collapsing them. */ +function assertNoDuplicates(mods: readonly ReforgerConfigMod[]): void { + const ids = mods.map((mod) => mod.modId.toUpperCase()); + if (new Set(ids).size !== ids.length) { + throw ApiError.validation('Duplicate mod ids in the list.'); + } +} + export function createServerRouter(deps: ServerRouterDeps): Router { const router = Router(); - const { service, provider } = deps; + const { service, provider, metrics } = deps; const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' }); + const writeRateLimit = rateLimit({ windowMs: 60_000, max: 20, keyPrefix: 'configwrite' }); const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' }); router.use(requireAuth); @@ -128,10 +182,24 @@ export function createServerRouter(deps: ServerRouterDeps): Router { return server; } + function requireMods(): ServerModsService { + if (!deps.mods) { + throw ApiError.notConfigured('Mod management requires a configured game server backend.'); + } + return deps.mods; + } + + function requireConfigEditor(): ConfigEditorService { + if (!deps.configEditor) { + throw ApiError.notConfigured('Config editing requires a configured game server backend.'); + } + return deps.configEditor; + } + async function toSummary(server: ServerRecord): Promise { let status = server.status as ServerStatus; try { - status = await provider.getServerStatus(providerId(server)); + status = await metrics.getStatus(providerId(server)); if (status !== server.status) { await service.updateStatus(server.id, status); } @@ -151,6 +219,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }; } + // ---------- server identity & telemetry ---------- + router.get('/', async (_req, res, next) => { try { const servers = await service.listServers(); @@ -162,8 +232,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.get('/:slug', async (req, res, next) => { try { - const server = await loadServer(req.params.slug); - res.json(await toSummary(server)); + res.json(await toSummary(await loadServer(req.params.slug))); } catch (error) { next(error); } @@ -172,9 +241,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.get('/:slug/resources', async (req, res, next) => { try { const server = await loadServer(req.params.slug); - const resources = await provider.getServerResources(providerId(server)); - const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() }; - res.json(body); + res.json(await metrics.getResources(providerId(server))); } catch (error) { next(error); } @@ -192,6 +259,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router { } }); + // ---------- configuration ---------- + + router.get('/:slug/configuration', async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.configSync) { + throw ApiError.notConfigured('Configuration requires a configured game server backend.'); + } + const { config, revision } = await deps.configSync.getLiveConfig(server); + res.json({ config, revision, fetchedAt: new Date().toISOString() }); + } catch (error) { + next(error); + } + }); + router.get('/:slug/config/performance', async (req, res, next) => { try { const server = await loadServer(req.params.slug); @@ -206,7 +288,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.put( '/:slug/config/performance', - syncRateLimit, + writeRateLimit, requireCapability('config.edit', 'You do not have permission to edit the configuration.'), async (req, res, next) => { try { @@ -221,14 +303,10 @@ export function createServerRouter(deps: ServerRouterDeps): Router { issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.', ); } - const result = await deps.performance.update(server, body.data); - // Many Reforger eggs template config.json from startup variables at - // boot; mirror the mission there too so switching sticks either way. - if (result.changedFields.includes('scenarioId') && body.data.scenarioId) { - await provider - .updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId) - .catch(() => undefined); // variable may not exist on this egg - } + const result = await deps.performance.update(server, body.data.settings, { + expectedRevision: body.data.expectedRevision, + writeStartupVars: body.data.writeStartupVars, + }); if (result.changedFields.length > 0) { const user = req.user!; await service.recordActivity({ @@ -246,6 +324,117 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }, ); + /** Every key config.json actually contains, for the searchable editor. */ + router.get( + '/:slug/config/tree', + requireCapability('config.edit', 'Configuration editing is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json(await requireConfigEditor().getTree(server)); + } catch (error) { + next(error); + } + }, + ); + + router.patch( + '/:slug/config', + writeRateLimit, + requireCapability('config.edit', 'You do not have permission to edit the configuration.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const body = configPatchBodySchema.safeParse(req.body); + if (!body.success) { + const issue = body.error.issues[0]; + throw ApiError.validation(issue?.message ?? 'Invalid configuration patch.'); + } + const ops: ConfigPatchOp[] = body.data.ops; + const result = await requireConfigEditor().patch(server, ops, { + expectedRevision: body.data.expectedRevision, + writeStartupVars: body.data.writeStartupVars, + }); + if (result.changedPaths.length > 0) { + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'config.updated', + // Paths only — values can be passwords. + summary: `config.json updated by ${user.displayName ?? user.username}: ${result.changedPaths.join(', ')} (applies on restart)`, + metadata: { changedPaths: result.changedPaths }, + }); + } + res.json(result); + } catch (error) { + next(error); + } + }, + ); + + router.get( + '/:slug/config/raw', + requireCapability('config.edit', 'Configuration editing is restricted.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + res.json(await requireConfigEditor().getRaw(server)); + } catch (error) { + next(error); + } + }, + ); + + router.put( + '/:slug/config/raw', + writeRateLimit, + requireCapability('config.edit', 'You do not have permission to edit the configuration.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + const body = configRawBodySchema.safeParse(req.body); + if (!body.success) throw ApiError.validation('Invalid config.json payload.'); + const result = await requireConfigEditor().putRaw( + server, + body.data.content, + body.data.expectedRevision, + ); + const user = req.user!; + await service.recordActivity({ + serverId: server.id, + actorUserId: user.id, + action: 'config.raw.updated', + summary: `config.json replaced by ${user.displayName ?? user.username} (applies on restart)`, + metadata: { revision: result.revision }, + }); + res.json(result); + } catch (error) { + next(error); + } + }, + ); + + router.post( + '/:slug/config/sync', + syncRateLimit, + requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'), + async (req, res, next) => { + try { + const server = await loadServer(req.params.slug); + if (!deps.configSync) { + throw ApiError.notConfigured('Config import requires a configured game server backend.'); + } + const result = await deps.configSync.sync(server); + res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers }); + } catch (error) { + next(error); + } + }, + ); + + // ---------- players, activity, killfeed ---------- + router.get('/:slug/players', async (req, res, next) => { try { const server = await loadServer(req.params.slug); @@ -284,137 +473,108 @@ export function createServerRouter(deps: ServerRouterDeps): Router { } }); - router.get('/:slug/configuration', async (req, res, next) => { - try { - const server = await loadServer(req.params.slug); - if (!deps.configSync) { - throw ApiError.notConfigured('Configuration requires a configured game server backend.'); - } - const config = await deps.configSync.getLiveConfig(server); - res.json({ config, fetchedAt: new Date().toISOString() }); - } catch (error) { - next(error); - } - }); + // ---------- missions ---------- router.get('/:slug/missions', async (req, res, next) => { try { const server = await loadServer(req.params.slug); - - // Resolve the installed mod list from config.json. - // Prefer the mods service (already owns that parse); fall back to configSync. - let installedModIds: string[] = []; + let installed: ReforgerConfigMod[] = []; if (deps.mods) { - const modsData = await deps.mods.getMods(server); - installedModIds = modsData.mods.map((m) => m.modId); + installed = (await deps.mods.getMods(server)).mods; } else if (deps.configSync) { - const config = await deps.configSync.getLiveConfig(server).catch(() => null); - installedModIds = (config?.mods ?? []).map((m) => m.modId); + const live = await deps.configSync.getLiveConfig(server).catch(() => null); + installed = live?.config.mods ?? []; } - - // Workshop API -> scenarios from installed scenario-tagged mods. - const modMissions: MissionInfo[] = []; - let scenarioLookupComplete = true; - if (installedModIds.length > 0) { - const details = await Promise.allSettled( - installedModIds.map((modId) => deps.workshop.getMod(modId)), - ); - scenarioLookupComplete = details.every((result) => result.status === 'fulfilled'); - for (const result of details) { - if (result.status !== 'fulfilled') continue; - const mod = result.value; - if (hasScenarioTag(mod.tags)) { - modMissions.push(...scenariosFromWorkshopMod(mod)); - } - } - } - - res.json({ - missions: mergeMissions([DEFAULT_MISSION], modMissions), - fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null, - }); + res.json(await deps.missions.list(installed)); } catch (error) { next(error); } }); + // ---------- live console ---------- + + /** + * Server-Sent Events relay of the Pterodactyl/Wings feed. + * + * This is what makes install, update and mod-download output visible: it + * carries whatever the hosting backend emits, rather than tailing the game's + * own log file, which does not exist until the game has already started. + */ router.get( - '/:slug/logs/stream', + '/:slug/console/stream', requireCapability('ops.health.view', 'Live console stream is restricted.'), async (req, res, next) => { try { - const server = await loadServer(req.params.slug); - if (!deps.resolveLogPath) { - throw ApiError.notConfigured('Log streaming requires a configured game server backend.'); + await loadServer(req.params.slug); + const hub = deps.consoleHub; + if (!hub) { + throw ApiError.notConfigured( + 'Live console requires a configured game server backend with the websocket enabled.', + ); } res.writeHead(200, { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders(); - const controller = new AbortController(); - req.on('close', () => controller.abort()); + const send = (event: string, data: unknown) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; - // Poll the log file every 2 seconds and push only new lines via SSE. - // Tracks total file size to derive the new-content offset on each poll, - // so we never re-send lines and handle log rotation gracefully. - let lastTotalBytes = 0; + send('backlog', hub.backlog()); + const stats = hub.latestStats(); + if (stats) send('stats', stats); - const sleep = (ms: number) => - new Promise((resolve) => { - const t = setTimeout(resolve, ms); - controller.signal.addEventListener( - 'abort', - () => { - clearTimeout(t); - resolve(); - }, - { once: true }, - ); - }); - - while (!controller.signal.aborted) { - try { - const logPath = await deps.resolveLogPath(); - if (logPath) { - const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024); - const totalBytes = - file.totalSizeBytes ?? file.contentStartOffset + file.content.length; - - let newContent: string; - if (lastTotalBytes === 0 || totalBytes < lastTotalBytes) { - // First poll or log rotated — send all available content. - newContent = file.content; - } else { - const skip = Math.max(0, lastTotalBytes - file.contentStartOffset); - newContent = file.content.slice(skip); - } - lastTotalBytes = totalBytes; - - if (newContent) { - for (const line of newContent.split('\n')) { - if (controller.signal.aborted) break; - if (line) res.write(`data: ${JSON.stringify(line)}\n\n`); - } - } - } - } catch { - // Provider unreachable or no log yet — keep the connection alive. + const unsubscribe = hub.subscribe((event) => { + switch (event.type) { + case 'line': + send('line', event.line); + break; + case 'status': + send('status', { status: event.status }); + break; + case 'stats': + send('stats', event.stats); + break; } - await sleep(2000); - } + }); - res.end(); + // Proxies drop idle connections; a comment frame keeps them open. + const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000); + heartbeat.unref?.(); + + req.on('close', () => { + clearInterval(heartbeat); + unsubscribe(); + res.end(); + }); } catch (error) { next(error); } }, ); + router.get( + '/:slug/console/backlog', + requireCapability('ops.health.view', 'Live console is restricted.'), + async (req, res, next) => { + try { + await loadServer(req.params.slug); + if (!deps.consoleHub) { + throw ApiError.notConfigured('Live console requires a configured game server backend.'); + } + res.json(deps.consoleHub.backlog()); + } catch (error) { + next(error); + } + }, + ); + + /** The game's own log file — kept as a diagnostic alongside the live feed. */ router.get( '/:slug/logs/raw', requireCapability('ops.health.view', 'Raw logs are restricted.'), @@ -447,6 +607,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }, ); + // ---------- startup variables ---------- + router.get( '/:slug/startup', requireCapability('config.edit', 'Startup variables are restricted.'), @@ -473,7 +635,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.put( '/:slug/startup/variable', - syncRateLimit, + writeRateLimit, requireCapability('config.edit', 'Startup variables are restricted.'), async (req, res, next) => { try { @@ -497,6 +659,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }, ); + // ---------- schedules ---------- + router.get( '/:slug/schedules', requireCapability('config.edit', 'Schedule management is restricted.'), @@ -515,7 +679,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.post( '/:slug/schedules/restarts', - syncRateLimit, + writeRateLimit, requireCapability('config.edit', 'Schedule management is restricted.'), async (req, res, next) => { try { @@ -540,7 +704,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.put( '/:slug/schedules/:scheduleId/restart', - syncRateLimit, + writeRateLimit, requireCapability('config.edit', 'Schedule management is restricted.'), async (req, res, next) => { try { @@ -571,7 +735,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.delete( '/:slug/schedules/:scheduleId', - syncRateLimit, + writeRateLimit, requireCapability('config.edit', 'Schedule management is restricted.'), async (req, res, next) => { try { @@ -594,84 +758,39 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }, ); + // ---------- mods ---------- + router.get('/:slug/mods', async (req, res, next) => { try { - const server = await loadServer(req.params.slug); - if (!deps.mods) { - throw ApiError.notConfigured('Mod management requires a configured game server backend.'); - } - res.json(await deps.mods.getMods(server)); + res.json(await requireMods().getMods(await loadServer(req.params.slug))); } catch (error) { next(error); } }); - router.get('/:slug/mods/check', async (req, res, next) => { + /** + * Everything the Mods page renders, in one request: installed mods joined + * with cached Workshop metadata, latest versions, dependency gaps and + * removal blockers. Replaces the old fan-out of one browser request per mod. + */ + router.get('/:slug/mods/overview', async (req, res, next) => { try { - const server = await loadServer(req.params.slug); - if (!deps.mods) { - throw ApiError.notConfigured('Mod management requires a configured game server backend.'); + res.json(await requireMods().getOverview(await loadServer(req.params.slug))); + } catch (error) { + next(error); + } + }); + + /** Expands a staged mod list into its full dependency closure, with sizes. */ + router.post('/:slug/mods/resolve', async (req, res, next) => { + try { + await loadServer(req.params.slug); + const body = modsResolveBodySchema.safeParse(req.body); + if (!body.success) { + throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.'); } - const { mods } = await deps.mods.getMods(server); - const installedIds = new Set(mods.map((m) => m.modId.toUpperCase())); - - // Fetch workshop details for all installed mods in parallel. - const details = await Promise.allSettled(mods.map((mod) => deps.workshop.getMod(mod.modId))); - - const modsWithMissingVersions: string[] = []; - const modsWithMissingDeps: ModDependencyIssue[] = []; - - for (let i = 0; i < mods.length; i++) { - const mod = mods[i]!; - if (!mod.version) modsWithMissingVersions.push(mod.modId); - const result = details[i]!; - if (result.status === 'fulfilled') { - const missing = result.value.dependencies.filter( - (dep) => dep.id && !installedIds.has(dep.id.toUpperCase()), - ); - if (missing.length > 0) { - modsWithMissingDeps.push({ - modId: mod.modId, - modName: mod.name ?? result.value.name ?? null, - missing, - }); - } - } - } - - // Detect orphaned mission: configured scenarioId no longer available. - let orphanedMission: { scenarioId: string; name: string | null } | null = null; - const configData = deps.configSync - ? await deps.configSync.getLiveConfig(server).catch(() => null) - : null; - const scenarioId = configData?.scenarioId ?? null; - - if (scenarioId) { - const knownScenarioIds = new Set([DEFAULT_SCENARIO_ID]); - const scenarioLookupComplete = details.every((result) => result.status === 'fulfilled'); - for (const result of details) { - if (result.status === 'fulfilled') { - const mod = result.value; - if (!hasScenarioTag(mod.tags)) continue; - for (const s of mod.scenarios) { - knownScenarioIds.add(s.scenarioId); - } - } - } - if (scenarioLookupComplete && !knownScenarioIds.has(scenarioId)) { - orphanedMission = { - scenarioId, - name: null, - }; - } - } - - res.json({ - modsWithMissingVersions, - modsWithMissingDeps, - orphanedMission, - checkedAt: new Date().toISOString(), - }); + assertNoDuplicates(body.data.mods); + res.json(await requireMods().resolve(body.data.mods)); } catch (error) { next(error); } @@ -679,46 +798,41 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.put( '/:slug/mods', - syncRateLimit, + writeRateLimit, requireCapability('mods.manage', 'You do not have permission to manage mods.'), async (req, res, next) => { try { const server = await loadServer(req.params.slug); - if (!deps.mods) { - throw ApiError.notConfigured('Mod management requires a configured game server backend.'); - } + const mods = requireMods(); const body = modsBodySchema.safeParse(req.body); if (!body.success) { throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.'); } - // Reject duplicate mod ids up front instead of silently collapsing. - const ids = body.data.mods.map((mod) => mod.modId.toUpperCase()); - if (new Set(ids).size !== ids.length) { - throw ApiError.validation('Duplicate mod ids in the list.'); - } + assertNoDuplicates(body.data.mods); - // Reforger requires a version in config.json for each mod to load. - // Fetch it from the Workshop for any mod the caller didn't supply one for. - const enrichedMods = await Promise.all( + // Reforger needs a version in config.json for each mod to load. Fill in + // the latest known version for any entry the caller left unpinned. + const enriched = await Promise.all( body.data.mods.map(async (mod) => { if (mod.version) return mod; - try { - const detail = await deps.workshop.getMod(mod.modId); - return { ...mod, ...(detail.version ? { version: detail.version } : {}) }; - } catch { - return mod; - } + const detail = await deps.workshop.tryGetMod(mod.modId); + return detail?.version ? { ...mod, version: detail.version } : mod; }), ); - const result = await deps.mods.setMods(server, enrichedMods); + const result = await mods.setMods(server, enriched, body.data.expectedRevision); const user = req.user!; await service.recordActivity({ serverId: server.id, actorUserId: user.id, action: 'mods.updated', - summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`, - metadata: { added: result.added, removed: result.removed, total: result.mods.length }, + summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed, ${result.changed} re-versioned (${result.mods.length} total, applies on restart)`, + metadata: { + added: result.added, + removed: result.removed, + changed: result.changed, + total: result.mods.length, + }, }); res.json(result); } catch (error) { @@ -736,6 +850,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router { } }); + // ---------- power ---------- + const powerActions = [ { action: 'start' as const, @@ -784,6 +900,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router { ); } + // ---------- log ingestion ---------- + router.post( '/:slug/logs/sync', syncRateLimit, @@ -817,25 +935,6 @@ export function createServerRouter(deps: ServerRouterDeps): Router { }, ); - router.post( - '/:slug/config/sync', - syncRateLimit, - requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'), - async (req, res, next) => { - try { - const server = await loadServer(req.params.slug); - if (!deps.configSync) { - throw ApiError.notConfigured('Config import requires a configured game server backend.'); - } - // Config is served live; this just refreshes the stored name/capacity. - const result = await deps.configSync.sync(server); - res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers }); - } catch (error) { - next(error); - } - }, - ); - router.get( '/:slug/logs/health', requireCapability('ops.health.view', 'Operational diagnostics are restricted.'), diff --git a/apps/api/src/modules/workshop/workshop-cache.test.ts b/apps/api/src/modules/workshop/workshop-cache.test.ts new file mode 100644 index 0000000..1e7c095 --- /dev/null +++ b/apps/api/src/modules/workshop/workshop-cache.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { WorkshopModDetail } from '@reforger-panel/shared'; +import { createLogger } from '../../lib/logger.js'; +import { ApiError } from '../../lib/errors.js'; +import { WorkshopCache } from './workshop-cache.js'; +import type { WorkshopClient } from './workshop-client.js'; + +function detail(id: string, overrides: Partial = {}): WorkshopModDetail { + return { + id, + name: `Mod ${id}`, + author: 'Author', + summary: null, + imageUrl: null, + workshopUrl: null, + version: '1.0.0', + gameVersion: null, + sizeBytes: 1024, + sizeText: '1 KiB', + rating: null, + ratingCount: null, + subscriberCount: null, + createdAt: null, + updatedAt: null, + tags: [], + obsolete: false, + description: null, + license: null, + downloadCount: null, + previewImages: [], + screenshots: [], + versionCount: 1, + dependencyCount: 0, + scenarioCount: 0, + dependencySizeBytes: null, + totalSizeBytes: null, + dependencies: [], + scenarios: [], + ...overrides, + }; +} + +function cacheWith(getMod: (id: string) => Promise) { + const client = { getMod: vi.fn(getMod) } as unknown as WorkshopClient; + return { cache: new WorkshopCache(client, createLogger('silent')), client }; +} + +describe('WorkshopCache', () => { + it('serves repeated reads of the same mod from cache', async () => { + const { cache, client } = cacheWith(async (id) => detail(id)); + await cache.getMod('AAAA000000000001'); + await cache.getMod('AAAA000000000001'); + expect(client.getMod).toHaveBeenCalledTimes(1); + }); + + it('collapses concurrent reads into a single upstream request', async () => { + // This is what stops the mods overview, the dependency check and the + // mission list from each fanning out over the same ids. + let resolveFetch: (value: WorkshopModDetail) => void = () => {}; + const { cache, client } = cacheWith( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const pending = Promise.all([ + cache.getMod('AAAA000000000002'), + cache.getMod('AAAA000000000002'), + cache.getMod('AAAA000000000002'), + ]); + resolveFetch(detail('AAAA000000000002')); + const results = await pending; + + expect(client.getMod).toHaveBeenCalledTimes(1); + expect(results.map((mod) => mod.id)).toEqual([ + 'AAAA000000000002', + 'AAAA000000000002', + 'AAAA000000000002', + ]); + }); + + it('normalises ids so casing differences share one entry', async () => { + const { cache, client } = cacheWith(async (id) => detail(id)); + await cache.getMod('aaaa000000000003'); + await cache.getMod('AAAA000000000003'); + expect(client.getMod).toHaveBeenCalledTimes(1); + }); + + it('remembers a missing mod instead of asking again', async () => { + const { cache, client } = cacheWith(async () => { + throw ApiError.notFound('Workshop mod not found.'); + }); + await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(client.getMod).toHaveBeenCalledTimes(1); + expect(cache.peekMod('AAAA000000000004')).toBeNull(); + }); + + it('backs off after an upstream failure rather than hammering it', async () => { + const { cache, client } = cacheWith(async () => { + throw ApiError.upstream('boom'); + }); + await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull(); + await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull(); + expect(client.getMod).toHaveBeenCalledTimes(1); + }); + + it('reports uncached ids as unknown, not missing', async () => { + const { cache } = cacheWith(async (id) => detail(id)); + expect(cache.peekMod('AAAA000000000006')).toBeUndefined(); + await cache.getMod('AAAA000000000006'); + expect(cache.peekMod('AAAA000000000006')?.name).toBe('Mod AAAA000000000006'); + }); + + it('reuses the detail payload for dependencies instead of a second request', async () => { + const { cache, client } = cacheWith(async (id) => + detail(id, { + dependencyCount: 1, + dependencies: [ + { + id: 'BBBB000000000001', + name: 'Dep', + version: null, + sizeBytes: 10, + published: true, + private: false, + }, + ], + }), + ); + const dependencies = await cache.getDependencies('AAAA000000000007'); + expect(dependencies).toHaveLength(1); + expect(client.getMod).toHaveBeenCalledTimes(1); + }); + + it('skips the scenario request for mods that ship none', async () => { + const { cache } = cacheWith(async (id) => detail(id, { scenarioCount: 0 })); + await expect(cache.getScenarios('AAAA000000000008')).resolves.toEqual([]); + }); + + it('drops cached entries on invalidate', async () => { + const { cache, client } = cacheWith(async (id) => detail(id)); + await cache.getMod('AAAA000000000009'); + cache.invalidate(['AAAA000000000009']); + await cache.getMod('AAAA000000000009'); + expect(client.getMod).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/modules/workshop/workshop-cache.ts b/apps/api/src/modules/workshop/workshop-cache.ts new file mode 100644 index 0000000..a29ef63 --- /dev/null +++ b/apps/api/src/modules/workshop/workshop-cache.ts @@ -0,0 +1,339 @@ +import type { + WorkshopDependency, + WorkshopModDetail, + WorkshopModVersionsResponse, + WorkshopScenario, + WorkshopSearchResponse, + WorkshopServerModsResponse, + WorkshopServerSearchResponse, +} from '@reforger-panel/shared'; +import { ApiError } from '../../lib/errors.js'; +import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js'; +import type { WorkshopClient, WorkshopSearchParams } from './workshop-client.js'; + +/** + * TTLs mirror what reforgermods.net caches upstream, so we never ask more + * often than the answer can change. `stale` is how long a value stays usable + * while a refresh runs in the background. + */ +const TTL = { + detail: { fresh: 60 * 60_000, stale: 24 * 60 * 60_000 }, + versions: { fresh: 6 * 60 * 60_000, stale: 7 * 24 * 60 * 60_000 }, + search: { fresh: 10 * 60_000, stale: 60 * 60_000 }, + servers: { fresh: 60_000, stale: 10 * 60_000 }, + /** How long a "this mod does not exist" answer is trusted. */ + negative: 10 * 60_000, +} as const; + +/** After an upstream failure, fail fast for this long instead of retrying. */ +const FAILURE_COOLDOWN_MS = 30_000; +const MAX_ENTRIES = 2_000; + +type Entry = { + value: unknown; + /** True for a cached "not found" — value is null and must not be retried yet. */ + missing: boolean; + freshUntil: number; + staleUntil: number; +}; + +/** + * Concurrency gate plus a token bucket. The free upstream tier allows 60 + * requests/minute per IP with a burst of 20; we deliberately sit under that so + * the panel never trips a 429 and never has to throttle in the browser (which + * is what the old client-side limiter in mods.tsx was doing). + */ +class RequestPacer { + private active = 0; + private tokens: number; + private lastRefillAt = Date.now(); + private waiters: (() => void)[] = []; + + constructor( + private readonly maxConcurrent: number, + private readonly perMinute: number, + private readonly burst: number, + ) { + this.tokens = burst; + } + + private refill(): void { + const now = Date.now(); + const elapsed = now - this.lastRefillAt; + if (elapsed <= 0) return; + this.lastRefillAt = now; + this.tokens = Math.min(this.burst, this.tokens + (elapsed / 60_000) * this.perMinute); + } + + private tryTake(): boolean { + this.refill(); + if (this.active >= this.maxConcurrent || this.tokens < 1) return false; + this.tokens -= 1; + this.active += 1; + return true; + } + + private pump(): void { + while (this.waiters.length > 0) { + if (!this.tryTake()) { + // Nothing available now; re-check when a token could have accrued. + setTimeout(() => this.pump(), Math.ceil(60_000 / this.perMinute)).unref?.(); + return; + } + this.waiters.shift()!(); + } + } + + async run(task: () => Promise): Promise { + if (!this.tryTake()) { + await new Promise((resolve) => { + this.waiters.push(resolve); + setTimeout(() => this.pump(), 0).unref?.(); + }); + } + try { + return await task(); + } finally { + this.active -= 1; + this.pump(); + } + } +} + +/** + * Read-through cache over the Workshop API with stale-while-revalidate, + * single-flight de-duplication and request pacing. + * + * This is what makes the Mods page fast: the installed-mod overview, the + * mission list and the dependency check all hit the same warm entries instead + * of each fanning out over the network, and a stale entry is served instantly + * while it refreshes behind the request. + */ +export class WorkshopCache { + private readonly store = new Map(); + private readonly inflight = new Map>(); + private readonly failures = new Map(); + private readonly pacer = new RequestPacer(6, 50, 15); + + constructor( + private readonly client: WorkshopClient, + private readonly logger: Logger, + ) {} + + // ---------- cache primitives ---------- + + private read(key: string): Entry | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (entry.staleUntil <= Date.now()) { + this.store.delete(key); + return undefined; + } + // Refresh LRU position. + this.store.delete(key); + this.store.set(key, entry); + return entry; + } + + private write(key: string, value: unknown, ttl: { fresh: number; stale: number }): void { + const now = Date.now(); + this.store.delete(key); + this.store.set(key, { + value, + missing: false, + freshUntil: now + ttl.fresh, + staleUntil: now + ttl.stale, + }); + this.evict(); + } + + private writeMissing(key: string): void { + const now = Date.now(); + this.store.delete(key); + this.store.set(key, { + value: null, + missing: true, + freshUntil: now + TTL.negative, + staleUntil: now + TTL.negative, + }); + this.evict(); + } + + private evict(): void { + while (this.store.size > MAX_ENTRIES) { + const oldest = this.store.keys().next(); + if (oldest.done) break; + this.store.delete(oldest.value); + } + } + + /** + * Runs `loader` once per key even if called concurrently, paced against the + * upstream rate limit. A 404 is remembered as a negative entry; any other + * failure starts a short cooldown so a flapping upstream cannot be hammered. + */ + private async load( + key: string, + ttl: { fresh: number; stale: number }, + loader: () => Promise, + ): Promise { + const existing = this.inflight.get(key); + if (existing) return (await existing) as T; + + const cooldownUntil = this.failures.get(key); + if (cooldownUntil && cooldownUntil > Date.now()) { + throw ApiError.upstream('Workshop API is temporarily unavailable.'); + } + + const promise = this.pacer + .run(loader) + .then((value) => { + this.failures.delete(key); + this.write(key, value, ttl); + return value; + }) + .catch((error: unknown) => { + if (error instanceof ApiError && error.code === 'NOT_FOUND') { + this.failures.delete(key); + this.writeMissing(key); + throw error; + } + this.failures.set(key, Date.now() + FAILURE_COOLDOWN_MS); + throw error; + }) + .finally(() => { + this.inflight.delete(key); + }); + + this.inflight.set(key, promise); + return (await promise) as T; + } + + private async cached( + key: string, + ttl: { fresh: number; stale: number }, + loader: () => Promise, + ): Promise { + const entry = this.read(key); + if (entry?.missing) throw ApiError.notFound('Workshop mod not found.'); + if (entry) { + if (entry.freshUntil > Date.now()) return entry.value as T; + // Stale but usable: serve it now, refresh behind the caller's back. + void this.load(key, ttl, loader).catch(() => undefined); + return entry.value as T; + } + return this.load(key, ttl, loader); + } + + // ---------- public API ---------- + + /** Synchronous peek. `undefined` means "not cached", `null` means "known missing". */ + peekMod(modId: string): WorkshopModDetail | null | undefined { + const entry = this.read(`mod:${modId.toUpperCase()}`); + if (!entry) return undefined; + return entry.missing ? null : (entry.value as WorkshopModDetail); + } + + async getMod(modId: string): Promise { + const id = modId.toUpperCase(); + return this.cached(`mod:${id}`, TTL.detail, () => this.client.getMod(id)); + } + + /** Non-throwing variant for bulk paths that must tolerate missing mods. */ + async tryGetMod(modId: string): Promise { + return this.getMod(modId).catch(() => null); + } + + async getVersions(modId: string): Promise { + const id = modId.toUpperCase(); + return this.cached(`versions:${id}`, TTL.versions, () => this.client.getVersions(id)); + } + + async getDependencies(modId: string): Promise { + // The detail payload already carries dependencies, so reuse that entry + // instead of spending a second upstream request on the same information. + const detail = await this.tryGetMod(modId); + if (detail) return detail.dependencies; + const id = modId.toUpperCase(); + return this.cached(`deps:${id}`, TTL.detail, () => this.client.getDependencies(id)); + } + + async getScenarios(modId: string): Promise { + const detail = await this.tryGetMod(modId); + if (!detail) return []; + // scenarioCount is authoritative; skip the request entirely for mods that + // ship none (the old code used a tag heuristic that both over- and + // under-matched). + if (detail.scenarioCount === 0) return []; + if (detail.scenarios.length > 0) return detail.scenarios; + const id = modId.toUpperCase(); + return this.cached(`scenarios:${id}`, TTL.detail, () => this.client.getScenarios(id)).catch( + () => [], + ); + } + + async search(params: WorkshopSearchParams): Promise { + const key = `search:${params.query ?? ''}|${params.page ?? 1}|${params.sort ?? ''}|${ + params.tag ?? '' + }|${params.category ?? ''}`; + return this.cached(key, TTL.search, () => this.client.search(params)); + } + + async searchServers(query: string, page: number): Promise { + return this.cached(`servers:${query}|${page}`, TTL.servers, () => + this.client.searchServers(query, page), + ); + } + + async getServerMods(serverId: string): Promise { + return this.cached(`server-mods:${serverId}`, TTL.servers, () => + this.client.getServerMods(serverId), + ); + } + + /** + * Populates details for a set of mod ids in the background. Called at boot + * with the installed mod list so the first Mods page open is already warm, + * and after a mod list change so the new entries are ready. + */ + warm(modIds: string[]): void { + const cold = modIds.filter((id) => this.peekMod(id) === undefined); + if (cold.length === 0) return; + this.logger.debug({ count: cold.length }, 'warming workshop cache'); + void Promise.allSettled(cold.map((id) => this.tryGetMod(id))).then((results) => { + const failed = results.filter((r) => r.status === 'fulfilled' && r.value === null).length; + if (failed > 0) { + this.logger.debug({ failed, total: cold.length }, 'workshop cache warm partially failed'); + } + }); + } + + /** Drops cached entries so the next read goes upstream (explicit Refresh). */ + invalidate(modIds?: string[]): void { + if (!modIds) { + this.store.clear(); + this.failures.clear(); + return; + } + for (const modId of modIds) { + const id = modId.toUpperCase(); + for (const prefix of ['mod', 'versions', 'deps', 'scenarios']) { + this.store.delete(`${prefix}:${id}`); + this.failures.delete(`${prefix}:${id}`); + } + } + } + + /** Diagnostics for the settings page. */ + stats(): { entries: number; inflight: number; cooldowns: number } { + return { + entries: this.store.size, + inflight: this.inflight.size, + cooldowns: [...this.failures.values()].filter((until) => until > Date.now()).length, + }; + } +} + +export function describeWorkshopError(error: unknown): string { + return sanitizeErrorMessage(error); +} diff --git a/apps/api/src/modules/workshop/workshop-client.test.ts b/apps/api/src/modules/workshop/workshop-client.test.ts index 3b73bb4..137896f 100644 --- a/apps/api/src/modules/workshop/workshop-client.test.ts +++ b/apps/api/src/modules/workshop/workshop-client.test.ts @@ -1,131 +1,234 @@ import { describe, expect, it, vi } from 'vitest'; -import { WorkshopClient, normalizeImageUrl } from './workshop-client.js'; +import { WorkshopClient, localizedLabel, normalizeImageUrl } from './workshop-client.js'; const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234'; +const MOD_ID = '595F2BF2F44836FB'; +/** Shape taken from a real GET /v2/mods response. */ function listResponse() { return { status: 'success', - meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 }, + meta: { totalPages: 3, currentPage: 1, totalMods: 42, shownMods: 1 }, data: [ { + id: MOD_ID, name: 'Mod A', author: 'Author', - imageURL: 'https://via.placeholder.com/640x360', - originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1', - apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1', - size: '1 MB', - rating: '99%', - ID: 'AAAAAAAAAAAAAAA1', + summary: 'Summary', + version: '1.2.0', + gameVersion: '1.8.0.10', + size: 204219382, + sizeFormatted: '195 MiB', + rating: 0.88, + ratingCount: 9323, + subscriberCount: 31997, + updatedAt: '2026-08-14T06:07:52Z', + tags: ['WEAPONS'], + // Upstream still serves placeholder stubs on some rows. + imageUrl: 'https://via.placeholder.com/640x360', + workshopUrl: `https://reforger.armaplatform.com/workshop/${MOD_ID}`, }, ], }; } -function detailResponse(id: string) { +function detailResponse() { return { status: 'success', mod: { + id: MOD_ID, name: 'Mod A', author: 'Author', - originalModURL: `https://reforger.armaplatform.com/workshop/${id}`, - apiModURL: `https://api.reforgermods.net/v1/mod/${id}`, - // Upstream bug: two URLs concatenated. - imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`, - rating: '99%', version: '1.2.0', - size: '1 MB', - id, - tags: [], - dependencies: [], - scenarios: [], + // Upstream bug: two URLs concatenated. + imageUrl: `https://reforger.armaplatform.com${REAL_IMAGE}`, + size: 0, + previewImages: [REAL_IMAGE], + screenshots: [], + scenarioCount: 2, + dependencyCount: 1, + totalSize: 8940841288, + tags: ['SCENARIOS_MP'], + dependencies: [ + { + id: '1337c0de5dabbeef', + name: 'Content Pack', + version: '0.16.5150', + size: 6343204665, + published: true, + private: false, + }, + ], + scenarios: [ + { + name: 'Conflict - Everon (RHS)', + gameId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf', + gameMode: '#AR-Scenario_GameMode_Campaign', + author: '#AR-Author_BI', + description: '#AR-Campaign_GamemodeDesc', + playerCount: 64, + }, + // No gameId: unusable as a mission, so it must be dropped. + { name: 'Broken', gameId: '', gameMode: null, playerCount: 0 }, + ], }, }; } +function client(handler: (path: string) => unknown) { + const fetchImpl = vi.fn(async (url: string | URL) => { + const path = String(url).replace('https://workshop.test', ''); + return new Response(JSON.stringify(handler(path)), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as typeof fetch; + return { + client: new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl }), + fetchImpl: fetchImpl as unknown as ReturnType, + }; +} + describe('normalizeImageUrl', () => { - it('drops dead placeholder URLs', () => { + it('drops placeholder stubs', () => { expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull(); }); - it('repairs concatenated double URLs', () => { + it('recovers the real URL from a concatenated pair', () => { expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE); }); - it('passes through well-formed URLs and rejects junk', () => { - expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE); + it('returns null for empty or non-http values', () => { expect(normalizeImageUrl('')).toBeNull(); - expect(normalizeImageUrl('not a url')).toBeNull(); + expect(normalizeImageUrl('not-a-url')).toBeNull(); }); }); -describe('WorkshopClient preview cache', () => { - it('does not fan out detail requests during search', async () => { - const fetchImpl = vi.fn(async (url: string | URL) => { - const path = String(url); - if (path.includes('/v1/mod/')) { - const id = path.slice(path.lastIndexOf('/') + 1); - return new Response(JSON.stringify(detailResponse(id)), { status: 200 }); - } - return new Response(JSON.stringify(listResponse()), { status: 200 }); - }); - const client = new WorkshopClient({ - baseUrl: 'https://workshop.test', - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - const first = await client.search('', 1); - expect(first.mods[0]!.imageUrl).toBeNull(); - expect(first.mods[0]!.version).toBeNull(); - const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); - expect(detailCalls).toHaveLength(0); - - await client.getMod('AAAAAAAAAAAAAAA1'); - - // Second search can use the cached detail without another detail request. - const second = await client.search('', 1); - expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE); - expect(second.mods[0]!.version).toBe('1.2.0'); - const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); - expect(detailCallsAfter).toHaveLength(1); +describe('localizedLabel', () => { + it('maps known Enfusion localization keys', () => { + expect(localizedLabel('#AR-Scenario_GameMode_Campaign')).toBe('Campaign'); }); - it('leaves the image empty when there is no cached detail', async () => { - const fetchImpl = vi.fn(async (url: string | URL) => { - return new Response(JSON.stringify(listResponse()), { status: 200 }); - }); - const client = new WorkshopClient({ - baseUrl: 'https://workshop.test', - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - const result = await client.search('', 1); - expect(result.mods[0]!.imageUrl).toBeNull(); + it('humanises unknown keys instead of leaking them', () => { + expect(localizedLabel('#AR-Scenario_GameMode_KingOfTheHill')).toBe('King Of The Hill'); }); - it('extracts scenario IDs from malformed scenario metadata', async () => { - const fetchImpl = vi.fn(async () => { - const detail = detailResponse('AAAAAAAAAAAAAAA1'); - detail.mod.scenarios = [ - { - name: '[OG] Udachne', - description: '', - scenarioID: '', - gamemode: 'Scenario ID{39AB5D9094E502AA}Missions/OG_Conflict.conf', - playerCount: 0, - imageURL: '', - }, - ]; - return new Response(JSON.stringify(detail), { status: 200 }); + it('passes plain text through', () => { + expect(localizedLabel('Sandbox')).toBe('Sandbox'); + expect(localizedLabel(null)).toBeNull(); + }); +}); + +describe('WorkshopClient (v2)', () => { + it('maps search results with typed sizes and ratings', async () => { + const { client: workshop, fetchImpl } = client(() => listResponse()); + const result = await workshop.search({ + query: 'mod a', + page: 2, + sort: 'newest', + tag: 'WEAPONS', }); - const client = new WorkshopClient({ - baseUrl: 'https://workshop.test', - fetchImpl: fetchImpl as unknown as typeof fetch, + + expect(String(fetchImpl.mock.calls[0]![0])).toContain('/v2/mods?'); + expect(String(fetchImpl.mock.calls[0]![0])).toContain('tags=WEAPONS'); + expect(result.meta.totalMods).toBe(42); + const mod = result.mods[0]!; + expect(mod.sizeBytes).toBe(204219382); + expect(mod.rating).toBe(0.88); + expect(mod.subscriberCount).toBe(31997); + expect(mod.imageUrl).toBeNull(); // placeholder stripped + }); + + it('maps mod details, uppercasing dependency ids and keeping real scenario ids', async () => { + const { client: workshop } = client(() => detailResponse()); + const detail = await workshop.getMod(MOD_ID); + + expect(detail.dependencies).toEqual([ + { + id: '1337C0DE5DABBEEF', + name: 'Content Pack', + version: '0.16.5150', + sizeBytes: 6343204665, + published: true, + private: false, + }, + ]); + expect(detail.scenarios).toHaveLength(1); + expect(detail.scenarios[0]).toEqual({ + scenarioId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf', + name: 'Conflict - Everon (RHS)', + gameMode: 'Campaign', + author: 'BI', + description: 'Campaign', + playerCount: 64, }); + expect(detail.imageUrl).toBe(REAL_IMAGE); + // Upstream reports 0 for "unknown", which must not read as "0 bytes". + expect(detail.sizeBytes).toBeNull(); + expect(detail.totalSizeBytes).toBe(8940841288); + }); - const mod = await client.getMod('AAAAAAAAAAAAAAA1'); - expect(mod.scenarios[0]).toMatchObject({ - scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf', - gamemode: null, + it('maps the version history used by the version picker', async () => { + const { client: workshop } = client(() => ({ + status: 'success', + data: { + modId: MOD_ID, + count: 1, + versions: [ + { + version: '0.16.5150', + gameVersion: '1.8.0.10', + size: 204219382, + sizeFormatted: '195 MiB', + approved: true, + published: true, + createdAt: '2026-08-14T06:05:43Z', + scenarioCount: 11, + dependencyCount: 2, + }, + ], + }, + })); + + const result = await workshop.getVersions(MOD_ID); + expect(result.modId).toBe(MOD_ID); + expect(result.versions[0]).toMatchObject({ + version: '0.16.5150', + gameVersion: '1.8.0.10', + sizeBytes: 204219382, + approved: true, }); + }); + + it('maps a server mod list for the import-from-server flow', async () => { + const { client: workshop } = client(() => ({ + status: 'success', + serverId: 'room-1', + summary: { count: 2, knownSize: 4096, unresolvedCount: 1 }, + data: [ + { id: '69f40d8f38530936', name: 'Hogs Scenario Core', version: '1.0.20', size: 2011590 }, + { id: '64610AFB74AA9842', name: 'WCS_Core', version: null, size: 0 }, + ], + })); + + const result = await workshop.getServerMods('room-1'); + expect(result.mods).toEqual([ + { + id: '69F40D8F38530936', + name: 'Hogs Scenario Core', + version: '1.0.20', + sizeBytes: 2011590, + }, + { id: '64610AFB74AA9842', name: 'WCS_Core', version: null, sizeBytes: null }, + ]); + expect(result.unresolvedCount).toBe(1); + }); + + it('translates upstream failures into ApiError codes', async () => { + const fetchImpl = vi.fn( + async () => new Response('nope', { status: 429 }), + ) as unknown as typeof fetch; + const workshop = new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl }); + await expect(workshop.getMod(MOD_ID)).rejects.toMatchObject({ code: 'RATE_LIMITED' }); }); }); diff --git a/apps/api/src/modules/workshop/workshop-client.ts b/apps/api/src/modules/workshop/workshop-client.ts index a2969ec..f628bd8 100644 --- a/apps/api/src/modules/workshop/workshop-client.ts +++ b/apps/api/src/modules/workshop/workshop-client.ts @@ -1,108 +1,190 @@ import { z } from 'zod'; import type { - WorkshopHealth, + WorkshopDependency, WorkshopModDetail, WorkshopModPreview, + WorkshopModVersion, + WorkshopModVersionsResponse, + WorkshopScenario, WorkshopSearchResponse, + WorkshopServerModsResponse, + WorkshopServerSearchResponse, + WorkshopServerSummary, + WorkshopSort, } from '@reforger-panel/shared'; import { ApiError } from '../../lib/errors.js'; -import { sanitizeErrorMessage } from '../../lib/logger.js'; /** - * Client for the public reforgermods.net Workshop metadata API. - * Endpoint shapes follow https://reforgermods.net/?page=documentation/api: - * GET /v1/health - * GET /v1/mods/{page}?search={q}&sort={sort} - * GET /v1/mod/{mod_id} - * Backend-only — the browser never talks to this host directly. + * Client for the reforgermods.net Workshop metadata API, v2. + * Endpoint shapes follow https://reforgermods.net/arma-reforger-mods-api/v2/: + * GET /v2/mods?page&search&sort&tags&category + * GET /v2/mods/{id} + * GET /v2/mods/{id}/versions + * GET /v2/mods/{id}/dependencies + * GET /v2/mods/{id}/scenarios + * GET /v2/servers?search&hasMods&perPage + * GET /v2/servers/{id}/mods + * + * v2 returns typed values (byte counts, numeric ratings) and real scenario ids, + * so none of v1's string parsing is needed here. Backend-only — the browser + * never talks to this host directly. Caching and request pacing live in + * WorkshopCache; this class is a thin typed transport. */ +/** Identifies the panel to the upstream, as its docs request. */ +const CLIENT_NAME = 'reforger-panel'; + +// ---------- upstream schemas ---------- + const modPreviewSchema = z.object({ - name: z.string(), - author: z.string().catch('Unknown'), - imageURL: z.string().catch(''), - originalModURL: z.string().catch(''), - size: z.string().catch(''), - rating: z.string().catch(''), - ID: z.string(), - version: z.string().nullish(), + id: z.string(), + name: z.string().catch('Unknown mod'), summary: z.string().nullish(), + author: z.string().catch('Unknown'), + version: z.string().nullish(), + gameVersion: z.string().nullish(), + size: z.number().nullish(), + sizeFormatted: z.string().nullish(), + rating: z.number().nullish(), + ratingCount: z.number().nullish(), + subscriberCount: z.number().nullish(), + createdAt: z.string().nullish(), + updatedAt: z.string().nullish(), + obsolete: z.boolean().nullish(), tags: z.array(z.string()).catch([]), + imageUrl: z.string().nullish(), + workshopUrl: z.string().nullish(), }); const searchResponseSchema = z.object({ - status: z.string(), - meta: z.object({ - totalPages: z.number().catch(1), - currentPage: z.number().catch(1), - totalMods: z.number().catch(0), - }), + meta: z + .object({ + totalPages: z.number().catch(1), + currentPage: z.number().catch(1), + totalMods: z.number().catch(0), + }) + .catch({ totalPages: 1, currentPage: 1, totalMods: 0 }), data: z.array(modPreviewSchema).catch([]), }); -const modDetailSchema = z.object({ - name: z.string(), - author: z.string().catch('Unknown'), - originalModURL: z.string().catch(''), - imageURL: z.string().catch(''), - rating: z.string().catch(''), - version: z.string().nullish(), - gameVersion: z.string().nullish(), - size: z.string().catch(''), - subscribers: z.number().nullish(), - downloads: z.number().nullish(), - created: z.string().nullish(), - lastModified: z.string().nullish(), +const dependencySchema = z.object({ id: z.string(), - summary: z.string().nullish(), + name: z.string().catch('Unknown mod'), + version: z.string().nullish(), + size: z.number().nullish(), + published: z.boolean().nullish(), + private: z.boolean().nullish(), +}); + +const scenarioSchema = z.object({ + name: z.string().catch('Unnamed scenario'), + gameId: z.string().nullish(), + gameMode: z.string().nullish(), + author: z.string().nullish(), + description: z.string().nullish(), + playerCount: z.number().nullish(), +}); + +const modDetailSchema = modPreviewSchema.extend({ description: z.string().nullish(), license: z.string().nullish(), - tags: z.array(z.string()).catch([]), - dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]), - scenarios: z + downloadCount: z.number().nullish(), + previewImages: z.array(z.string()).catch([]), + screenshots: z.array(z.string()).catch([]), + versionCount: z.number().nullish(), + dependencyCount: z.number().nullish(), + scenarioCount: z.number().nullish(), + dependencySize: z.number().nullish(), + totalSize: z.number().nullish(), + dependencies: z.array(dependencySchema).catch([]), + scenarios: z.array(scenarioSchema).catch([]), +}); + +const modDetailEnvelopeSchema = z.object({ mod: modDetailSchema }); + +const versionSchema = z.object({ + version: z.string(), + gameVersion: z.string().nullish(), + size: z.number().nullish(), + sizeFormatted: z.string().nullish(), + approved: z.boolean().nullish(), + published: z.boolean().nullish(), + createdAt: z.string().nullish(), + scenarioCount: z.number().nullish(), + dependencyCount: z.number().nullish(), +}); + +const versionsEnvelopeSchema = z.object({ + data: z.object({ + modId: z.string().catch(''), + versions: z.array(versionSchema).catch([]), + }), +}); + +const dependenciesEnvelopeSchema = z.object({ + data: z.object({ + dependencies: z.array(dependencySchema).catch([]), + }), +}); + +const scenariosEnvelopeSchema = z.object({ + data: z.object({ + scenarios: z.array(scenarioSchema).catch([]), + }), +}); + +const serverSummarySchema = z.object({ + id: z.string(), + name: z.string().catch('Unnamed server'), + scenarioId: z.string().nullish(), + scenarioName: z.string().nullish(), + gameVersion: z.string().nullish(), + players: z.number().catch(0), + maxPlayers: z.number().catch(0), + region: z.string().nullish(), + platform: z.string().nullish(), + modCount: z.number().catch(0), + official: z.boolean().nullish(), + online: z.boolean().nullish(), +}); + +const serverSearchEnvelopeSchema = z.object({ + meta: z + .object({ + totalPages: z.number().catch(1), + currentPage: z.number().catch(1), + totalServers: z.number().catch(0), + }) + .catch({ totalPages: 1, currentPage: 1, totalServers: 0 }), + data: z.array(serverSummarySchema).catch([]), +}); + +const serverModsEnvelopeSchema = z.object({ + serverId: z.string().catch(''), + summary: z + .object({ + knownSize: z.number().nullish(), + unresolvedCount: z.number().nullish(), + }) + .nullish(), + data: z .array( z.object({ - name: z.string(), - description: z.string().catch(''), - scenarioID: z.string().catch(''), - gamemode: z.string().catch(''), - playerCount: z.number().catch(0), - imageURL: z.string().catch(''), + id: z.string(), + name: z.string().catch('Unknown mod'), + version: z.string().nullish(), + size: z.number().nullish(), }), ) .catch([]), }); -const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema }); - -export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size'; - -function extractModId(apiModUrl: string): string | null { - const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl); - return match?.[1] ?? null; -} - -const SCENARIO_ID_PATTERN = /(\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf)/; -const SCENARIO_ID_WITH_LABEL_PATTERN = - /scenario\s*id\s*:?\s*\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf/i; - -function extractScenarioId(...values: Array): string { - for (const value of values) { - const match = value?.match(SCENARIO_ID_PATTERN); - if (match?.[1]) return match[1]; - } - return ''; -} - -function cleanScenarioText(value: string | null | undefined): string | null { - const cleaned = value?.replace(SCENARIO_ID_WITH_LABEL_PATTERN, '').trim(); - return cleaned || null; -} +// ---------- normalisation helpers ---------- /** - * Upstream image URLs need repair: list endpoints return dead - * via.placeholder.com stubs, and detail endpoints sometimes concatenate two - * URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn..."). + * Upstream image URLs still need repair: list endpoints occasionally return + * dead via.placeholder.com stubs, and some rows concatenate two URLs + * ("https://reforger.armaplatform.comhttps://ar-gcp-cdn..."). */ export function normalizeImageUrl(raw: string | null | undefined): string | null { if (!raw) return null; @@ -112,50 +194,162 @@ export function normalizeImageUrl(raw: string | null | undefined): string | null return candidate.startsWith('http') ? candidate : null; } +/** Upstream reports 0 for "size unknown"; keep that distinct from "0 bytes". */ +function sizeOrNull(size: number | null | undefined): number | null { + return typeof size === 'number' && size > 0 ? size : null; +} + +function text(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + function toPreview(mod: z.infer): WorkshopModPreview { return { - id: mod.ID, + id: mod.id, name: mod.name, author: mod.author, - imageUrl: normalizeImageUrl(mod.imageURL), - size: mod.size || null, - rating: mod.rating || null, - workshopUrl: mod.originalModURL || null, - version: mod.version ?? null, - summary: mod.summary ?? null, + summary: text(mod.summary), + imageUrl: normalizeImageUrl(mod.imageUrl), + workshopUrl: text(mod.workshopUrl), + version: text(mod.version), + gameVersion: text(mod.gameVersion), + sizeBytes: sizeOrNull(mod.size), + sizeText: text(mod.sizeFormatted), + rating: typeof mod.rating === 'number' ? mod.rating : null, + ratingCount: mod.ratingCount ?? null, + subscriberCount: mod.subscriberCount ?? null, + createdAt: text(mod.createdAt), + updatedAt: text(mod.updatedAt), tags: mod.tags, + obsolete: mod.obsolete ?? false, }; } -const PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache +function toDependency(dep: z.infer): WorkshopDependency { + return { + id: dep.id.toUpperCase(), + name: dep.name, + version: text(dep.version), + sizeBytes: sizeOrNull(dep.size), + published: dep.published ?? true, + private: dep.private ?? false, + }; +} + +/** + * Scenario `gameMode` / `description` are Enfusion localization keys such as + * `#AR-Scenario_GameMode_Campaign`. Map the ones that show up in practice and + * fall back to a readable form of the key rather than leaking `#AR-` at users. + */ +const GAME_MODE_LABELS: Record = { + '#AR-Scenario_GameMode_Campaign': 'Campaign', + '#AR-Scenario_GameMode_Conflict': 'Conflict', + '#AR-Scenario_GameMode_CombatOps': 'Combat Ops', + '#AR-Scenario_GameMode_GameMaster': 'Game Master', + '#AR-Scenario_GameMode_Tutorial': 'Tutorial', + '#AR-ServerBrowser_ServerScenario': 'Scenario', + '#AR-Campaign_GamemodeDesc': 'Campaign', + '#AR-CombatScenario_Description': 'Combat Ops', +}; + +export function localizedLabel(value: string | null | undefined): string | null { + const raw = text(value); + if (!raw) return null; + if (!raw.startsWith('#')) return raw; + const mapped = GAME_MODE_LABELS[raw]; + if (mapped) return mapped; + // "#AR-Scenario_GameMode_FooBar" -> "Foo Bar" + const tail = + raw + .replace(/^#[A-Za-z]+-/, '') + .split('_') + .pop() ?? raw; + const spaced = tail.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim(); + return spaced || null; +} + +function toScenario(scenario: z.infer): WorkshopScenario | null { + const scenarioId = text(scenario.gameId); + if (!scenarioId) return null; + return { + scenarioId, + name: scenario.name, + gameMode: localizedLabel(scenario.gameMode), + author: localizedLabel(scenario.author), + description: localizedLabel(scenario.description), + playerCount: scenario.playerCount && scenario.playerCount > 0 ? scenario.playerCount : null, + }; +} + +function toVersion(version: z.infer): WorkshopModVersion { + return { + version: version.version, + gameVersion: text(version.gameVersion), + sizeBytes: sizeOrNull(version.size), + sizeText: text(version.sizeFormatted), + approved: version.approved ?? true, + published: version.published ?? true, + createdAt: text(version.createdAt), + scenarioCount: version.scenarioCount ?? null, + dependencyCount: version.dependencyCount ?? null, + }; +} + +function toServerSummary(server: z.infer): WorkshopServerSummary { + return { + id: server.id, + name: server.name, + scenarioId: text(server.scenarioId), + scenarioName: text(server.scenarioName), + gameVersion: text(server.gameVersion), + players: server.players, + maxPlayers: server.maxPlayers, + region: text(server.region), + platform: text(server.platform), + modCount: server.modCount, + official: server.official ?? false, + online: server.online ?? true, + }; +} + +export type WorkshopSearchParams = { + query?: string; + page?: number; + sort?: WorkshopSort; + /** Upstream accepts a single tag only; comma-separated values are rejected. */ + tag?: string; + category?: string; +}; export class WorkshopClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; - /** modId -> detail fields used to make browse cards useful. */ - private previewCache = new Map< - string, - { - imageUrl: string | null; - version: string | null; - summary: string | null; - tags: string[]; - expiresAt: number; - } - >(); + private readonly apiKey: string; - constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) { + constructor(options: { + baseUrl: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + apiKey?: string; + }) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; - this.timeoutMs = options.timeoutMs ?? 10_000; + this.timeoutMs = options.timeoutMs ?? 12_000; + this.apiKey = options.apiKey ?? ''; } private async get(path: string): Promise { let response: Response; try { response = await this.fetchImpl(`${this.baseUrl}${path}`, { - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + 'X-API-Client': CLIENT_NAME, + 'User-Agent': CLIENT_NAME, + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), + }, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (error) { @@ -175,113 +369,96 @@ export class WorkshopClient { return response.json(); } - async health(): Promise { - const startedAt = Date.now(); - try { - await this.get('/v1/health'); - return { - ok: true, - latencyMs: Date.now() - startedAt, - checkedAt: new Date().toISOString(), - message: null, - }; - } catch (error) { - return { - ok: false, - latencyMs: null, - checkedAt: new Date().toISOString(), - message: sanitizeErrorMessage(error), - }; - } - } - - async search(query: string, page = 1, sort?: WorkshopSort): Promise { - const params = new URLSearchParams(); - if (query) params.set('search', query); - if (sort) params.set('sort', sort); - const qs = params.size > 0 ? `?${params.toString()}` : ''; - const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`); - const parsed = searchResponseSchema.safeParse(raw); + private parse(schema: T, raw: unknown): z.infer { + const parsed = schema.safeParse(raw); if (!parsed.success) { throw ApiError.upstream('Workshop API returned an unexpected response shape.'); } - const mods = parsed.data.data.map(toPreview); - this.applyCachedPreviews(mods); - return { - mods, - meta: parsed.data.meta, - }; + return parsed.data; } - private applyCachedPreviews(mods: WorkshopModPreview[]): void { - const now = Date.now(); - for (const mod of mods) { - const cached = this.previewCache.get(mod.id); - if (cached && cached.expiresAt > now) { - mod.imageUrl = mod.imageUrl ?? cached.imageUrl; - mod.version = mod.version ?? cached.version; - mod.summary = mod.summary ?? cached.summary; - mod.tags = mod.tags.length > 0 ? mod.tags : cached.tags; - } - } + async search(params: WorkshopSearchParams = {}): Promise { + const search = new URLSearchParams(); + search.set('page', String(Math.max(1, params.page ?? 1))); + if (params.query) search.set('search', params.query); + if (params.sort) search.set('sort', params.sort); + if (params.tag) search.set('tags', params.tag); + if (params.category) search.set('category', params.category); + const parsed = this.parse(searchResponseSchema, await this.get(`/v2/mods?${search}`)); + return { mods: parsed.data.map(toPreview), meta: parsed.meta }; } async getMod(modId: string): Promise { - const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`); - const parsed = modDetailEnvelopeSchema.safeParse(raw); - if (!parsed.success) { - throw ApiError.upstream('Workshop API returned an unexpected response shape.'); - } - const mod = parsed.data.mod; - const detail = { - id: mod.id, - name: mod.name, - author: mod.author, - imageUrl: normalizeImageUrl(mod.imageURL), - size: mod.size || null, - rating: mod.rating || null, - workshopUrl: mod.originalModURL || null, - version: mod.version ?? null, - gameVersion: mod.gameVersion ?? null, - subscribers: mod.subscribers ?? null, - downloads: mod.downloads ?? null, - createdAtText: mod.created ?? null, - lastModifiedText: mod.lastModified ?? null, - summary: mod.summary ?? null, - description: mod.description ?? null, - license: mod.license ?? null, - tags: mod.tags, - dependencies: mod.dependencies.map((dep) => ({ - name: dep.name, - id: extractModId(dep.apiModURL), - })), - scenarios: mod.scenarios.map((scenario) => ({ - name: scenario.name, - description: scenario.description || null, - scenarioId: extractScenarioId( - scenario.scenarioID, - scenario.gamemode, - scenario.description, - scenario.name, - ), - gamemode: cleanScenarioText(scenario.gamemode), - playerCount: scenario.playerCount || null, - imageUrl: normalizeImageUrl(scenario.imageURL), - })), + const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}`); + const { mod } = this.parse(modDetailEnvelopeSchema, raw); + return { + ...toPreview(mod), + description: text(mod.description), + license: text(mod.license), + downloadCount: mod.downloadCount ?? null, + previewImages: mod.previewImages + .map(normalizeImageUrl) + .filter((url): url is string => url !== null), + screenshots: mod.screenshots + .map(normalizeImageUrl) + .filter((url): url is string => url !== null), + versionCount: mod.versionCount ?? null, + dependencyCount: mod.dependencyCount ?? mod.dependencies.length, + scenarioCount: mod.scenarioCount ?? mod.scenarios.length, + dependencySizeBytes: sizeOrNull(mod.dependencySize), + totalSizeBytes: sizeOrNull(mod.totalSize), + dependencies: mod.dependencies.map(toDependency), + scenarios: mod.scenarios + .map(toScenario) + .filter((scenario): scenario is WorkshopScenario => scenario !== null), }; - this.previewCache.set(detail.id, { - imageUrl: detail.imageUrl, - version: detail.version, - summary: detail.summary ?? detail.description, - tags: detail.tags, - expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS, + } + + async getVersions(modId: string): Promise { + const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/versions`); + const { data } = this.parse(versionsEnvelopeSchema, raw); + return { modId: data.modId || modId, versions: data.versions.map(toVersion) }; + } + + async getDependencies(modId: string): Promise { + const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/dependencies`); + const { data } = this.parse(dependenciesEnvelopeSchema, raw); + return data.dependencies.map(toDependency); + } + + async getScenarios(modId: string): Promise { + const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/scenarios`); + const { data } = this.parse(scenariosEnvelopeSchema, raw); + return data.scenarios + .map(toScenario) + .filter((scenario): scenario is WorkshopScenario => scenario !== null); + } + + async searchServers(query: string, page = 1): Promise { + const search = new URLSearchParams({ + page: String(Math.max(1, page)), + perPage: '25', + hasMods: 'true', + sort: 'players', }); - if (this.previewCache.size > 5_000) { - const now = Date.now(); - for (const [key, value] of this.previewCache) { - if (value.expiresAt <= now) this.previewCache.delete(key); - } - } - return detail; + if (query) search.set('search', query); + const parsed = this.parse(serverSearchEnvelopeSchema, await this.get(`/v2/servers?${search}`)); + return { servers: parsed.data.map(toServerSummary), meta: parsed.meta }; + } + + async getServerMods(serverId: string): Promise { + const raw = await this.get(`/v2/servers/${encodeURIComponent(serverId)}/mods?sizes=true`); + const parsed = this.parse(serverModsEnvelopeSchema, raw); + return { + serverId: parsed.serverId || serverId, + mods: parsed.data.map((mod) => ({ + id: mod.id.toUpperCase(), + name: mod.name, + version: text(mod.version), + sizeBytes: sizeOrNull(mod.size), + })), + knownSizeBytes: sizeOrNull(parsed.summary?.knownSize), + unresolvedCount: parsed.summary?.unresolvedCount ?? 0, + }; } } diff --git a/apps/api/src/modules/workshop/workshop-routes.ts b/apps/api/src/modules/workshop/workshop-routes.ts index 2e88824..a94d77b 100644 --- a/apps/api/src/modules/workshop/workshop-routes.ts +++ b/apps/api/src/modules/workshop/workshop-routes.ts @@ -1,41 +1,53 @@ import { Router } from 'express'; import { z } from 'zod'; +import { WORKSHOP_SORTS } from '@reforger-panel/shared'; import { ApiError } from '../../lib/errors.js'; import { rateLimit } from '../../lib/rate-limit.js'; import { requireAuth } from '../auth/auth-middleware.js'; -import type { WorkshopClient } from './workshop-client.js'; +import type { WorkshopCache } from './workshop-cache.js'; const searchQuerySchema = z.object({ q: z.string().trim().max(100).default(''), page: z.coerce.number().int().min(1).max(10_000).default(1), - sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(), + sort: z.enum(WORKSHOP_SORTS).optional(), + // Upstream rejects comma-separated tags, so this is deliberately singular. + tag: z + .string() + .trim() + .max(40) + .regex(/^[A-Za-z0-9 _-]*$/, 'Invalid tag.') + .optional(), + category: z + .string() + .trim() + .max(40) + .regex(/^[a-z0-9-]*$/, 'Invalid category.') + .optional(), }); -const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.'); +const serverSearchQuerySchema = z.object({ + q: z.string().trim().max(100).default(''), + page: z.coerce.number().int().min(1).max(1_000).default(1), +}); -export function createWorkshopRouter(client: WorkshopClient): Router { +const modIdSchema = z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'); +// reforgermods.net identifies servers by room UUID. +const serverIdSchema = z.string().regex(/^[A-Za-z0-9-]{8,64}$/, 'Invalid server id.'); + +export function createWorkshopRouter(workshop: WorkshopCache): Router { const router = Router(); - // The upstream allows 60 req/min per IP; stay well under it. - const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' }); + // The cache does the real upstream pacing; this only guards against a + // runaway browser loop hammering our own API. + const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 240, keyPrefix: 'workshop' }); router.use(requireAuth, workshopRateLimit); - router.get('/health', async (_req, res, next) => { - try { - res.json(await client.health()); - } catch (error) { - next(error); - } - }); - router.get('/search', async (req, res, next) => { try { const parsed = searchQuerySchema.safeParse(req.query); - if (!parsed.success) { - throw ApiError.validation('Invalid search parameters.'); - } - const { q, page, sort } = parsed.data; - res.json(await client.search(q, page, sort)); + if (!parsed.success) throw ApiError.validation('Invalid search parameters.'); + const { q, page, sort, tag, category } = parsed.data; + res.json(await workshop.search({ query: q, page, sort, tag, category })); } catch (error) { next(error); } @@ -44,10 +56,39 @@ export function createWorkshopRouter(client: WorkshopClient): Router { router.get('/mods/:id', async (req, res, next) => { try { const parsed = modIdSchema.safeParse(req.params.id); - if (!parsed.success) { - throw ApiError.validation('Invalid mod id.'); - } - res.json(await client.getMod(parsed.data)); + if (!parsed.success) throw ApiError.validation('Invalid mod id.'); + res.json(await workshop.getMod(parsed.data)); + } catch (error) { + next(error); + } + }); + + router.get('/mods/:id/versions', async (req, res, next) => { + try { + const parsed = modIdSchema.safeParse(req.params.id); + if (!parsed.success) throw ApiError.validation('Invalid mod id.'); + res.json(await workshop.getVersions(parsed.data)); + } catch (error) { + next(error); + } + }); + + /** Live server browser — backs "add a modlist based off another server". */ + router.get('/servers', async (req, res, next) => { + try { + const parsed = serverSearchQuerySchema.safeParse(req.query); + if (!parsed.success) throw ApiError.validation('Invalid server search parameters.'); + res.json(await workshop.searchServers(parsed.data.q, parsed.data.page)); + } catch (error) { + next(error); + } + }); + + router.get('/servers/:id/mods', async (req, res, next) => { + try { + const parsed = serverIdSchema.safeParse(req.params.id); + if (!parsed.success) throw ApiError.validation('Invalid server id.'); + res.json(await workshop.getServerMods(parsed.data)); } catch (error) { next(error); } diff --git a/apps/api/test/auth-routes.test.ts b/apps/api/test/auth-routes.test.ts index 36f090f..e277c57 100644 --- a/apps/api/test/auth-routes.test.ts +++ b/apps/api/test/auth-routes.test.ts @@ -16,7 +16,9 @@ import type { ResourceHistoryService } from '../src/modules/servers/resource-his import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js'; import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js'; -import { WorkshopClient } from '../src/modules/workshop/workshop-client.js'; +import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js'; +import type { MissionsService } from '../src/modules/reforger-logs/missions-catalog.js'; +import { ServerMetricsService } from '../src/modules/servers/metrics-service.js'; const OWNER_ID = '111111111111111111'; @@ -115,24 +117,42 @@ function buildApp() { sessions, servers, provider, - workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }), + workshop: { + warm: () => undefined, + peekMod: () => undefined, + tryGetMod: async () => null, + } as unknown as WorkshopCache, + metrics: new ServerMetricsService(provider, null), + consoleHub: null, scheduler, resolveLogPath: async () => '/profile/logs/console.log', configSync: null, + configEditor: null, mods: { - getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }), + getMods: async () => ({ + mods: [], + revision: 'a1b2c3d4', + fetchedAt: new Date().toISOString(), + }), setMods: async () => ({ mods: [], + revision: 'a1b2c3d4', fetchedAt: new Date().toISOString(), added: 0, removed: 0, + changed: 0, requiresRestart: true as const, }), } as unknown as ServerModsService, performance: { - get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }), + get: async () => ({ + settings: {}, + revision: 'a1b2c3d4', + fetchedAt: new Date().toISOString(), + }), update: async (_server: unknown, settings: unknown) => ({ settings, + revision: 'a1b2c3d4', fetchedAt: new Date().toISOString(), changedFields: [], requiresRestart: true as const, @@ -141,7 +161,9 @@ function buildApp() { resourceHistory: { history: () => ({ samples: [], intervalSeconds: 15 }), } as unknown as ResourceHistoryService, - missions: null, + missions: { + list: async () => ({ groups: [], incompleteModIds: [], fetchedAt: null }), + } as unknown as MissionsService, }); return { app, provider, activity }; } @@ -335,7 +357,7 @@ describe('schedule management by role', () => { }); describe('performance config by role', () => { - const validBody = { + const validSettings = { maxPlayers: 32, serverMaxViewDistance: null, networkViewDistance: null, @@ -349,6 +371,9 @@ describe('performance config by role', () => { slotReservationTimeout: null, lobbyPlayerSynchronise: null, }; + // The endpoint takes a settings envelope so callers can also pass the + // revision their edits were based on. + const validBody = { settings: validSettings }; it('allows owner and server_admin, forbids mission_lead and viewer', async () => { const { app } = buildApp(); @@ -373,7 +398,7 @@ describe('performance config by role', () => { const response = await request(app) .put('/api/servers/training-server/config/performance') .set(asUser('owner-token')) - .send({ ...validBody, serverMaxViewDistance: 99999 }); + .send({ settings: { ...validSettings, serverMaxViewDistance: 99999 } }); expect(response.status).toBe(400); expect(response.body.error.message).toContain('serverMaxViewDistance'); }); diff --git a/apps/api/test/mods-service.test.ts b/apps/api/test/mods-service.test.ts index 506e24b..ff31d14 100644 --- a/apps/api/test/mods-service.test.ts +++ b/apps/api/test/mods-service.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkshopModDetail } from '@reforger-panel/shared'; import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js'; import { ServerModsService } from '../src/modules/config/mods-service.js'; -import type { ConfigSyncService } from '../src/modules/config/config-sync.js'; +import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js'; import type { ServerRecord } from '../src/modules/servers/server-service.js'; import { createLogger } from '../src/lib/logger.js'; import { ApiError } from '../src/lib/errors.js'; @@ -19,38 +20,81 @@ const server: ServerRecord = { updatedAt: new Date(), }; +const MOCK_MOD = '591AF5BDA9F7CE8B'; +const ADMIN_TOOLS = '5AAF0CCE3F001FB5'; +const DEPENDENCY = 'BBBB000000000001'; + +function workshopDetail(overrides: Partial & { id: string }) { + return { + name: `Mod ${overrides.id}`, + author: 'Author', + summary: null, + imageUrl: null, + workshopUrl: null, + version: '1.0.2', + gameVersion: null, + sizeBytes: 1024, + sizeText: '1 KiB', + rating: null, + ratingCount: null, + subscriberCount: null, + createdAt: null, + updatedAt: null, + tags: [], + obsolete: false, + description: null, + license: null, + downloadCount: null, + previewImages: [], + screenshots: [], + versionCount: 1, + dependencyCount: 0, + scenarioCount: 0, + dependencySizeBytes: null, + totalSizeBytes: null, + dependencies: [], + scenarios: [], + ...overrides, + } as WorkshopModDetail; +} + +function fakeWorkshop(catalog: Record = {}) { + const resolved = new Map(Object.entries(catalog)); + return { + warm: vi.fn(), + peekMod: (id: string) => resolved.get(id.toUpperCase()) ?? null, + tryGetMod: async (id: string) => resolved.get(id.toUpperCase()) ?? null, + getMod: async (id: string) => { + const mod = resolved.get(id.toUpperCase()); + if (!mod) throw ApiError.notFound('Workshop mod not found.'); + return mod; + }, + } as unknown as WorkshopCache; +} + describe('ServerModsService', () => { let provider: MockGameServerProvider; - let service: ServerModsService; - let configSyncCalled: number; + let gateway: ConfigFileGateway; + + function build(catalog: Record = {}) { + return new ServerModsService(gateway, fakeWorkshop(catalog), createLogger('silent')); + } beforeEach(() => { provider = new MockGameServerProvider(); - configSyncCalled = 0; - const configSync = { - sync: async () => { - configSyncCalled += 1; - return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 }; - }, - } as unknown as ConfigSyncService; - service = new ServerModsService( - new ConfigFileGateway(provider, '/config.json'), - configSync, - createLogger('silent'), - ); + gateway = new ConfigFileGateway(provider, '/config.json'); }); - it('reads the current mods from config.json', async () => { - const result = await service.getMods(server); - expect(result.mods).toEqual([ - { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, - ]); + it('reads the current mods from config.json with a revision to write back against', async () => { + const result = await build().getMods(server); + expect(result.mods).toEqual([{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }]); + expect(result.revision).toMatch(/^[a-f0-9]{16}$/); }); it('writes the new mod list while preserving every other config field', async () => { - const result = await service.setMods(server, [ - { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, - { modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' }, + const result = await build().setMods(server, [ + { modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }, + { modId: ADMIN_TOOLS, name: 'Server Admin Tools' }, ]); expect(result.added).toBe(1); @@ -58,14 +102,11 @@ describe('ServerModsService', () => { expect(result.requiresRestart).toBe(true); expect(result.mods).toHaveLength(2); - const written = provider.writtenFiles.get('/config.json')!; - const parsed = JSON.parse(written); - // game.mods replaced… + const parsed = JSON.parse(provider.writtenFiles.get('/config.json')!); expect(parsed.game.mods).toEqual([ - { modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }, - { modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' }, + { modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }, + { modId: ADMIN_TOOLS, name: 'Server Admin Tools' }, ]); - // …everything else untouched. expect(parsed.bindPort).toBe(2001); expect(parsed.game.name).toBe('Mock Reforger Server'); expect(parsed.game.maxPlayers).toBe(16); @@ -73,37 +114,178 @@ describe('ServerModsService', () => { expect(parsed.operating.aiLimit).toBe(40); }); - it('writes a rollback backup of the previous file before modifying it', async () => { - const before = (await provider.downloadTextFile('abc123', '/config.json')).content; - await service.setMods(server, []); - expect(provider.writtenFiles.get('/config.json.bak')).toBe(before); - // Removal reflected in the live file. - const after = JSON.parse(provider.writtenFiles.get('/config.json')!); - expect(after.game.mods).toEqual([]); + it('counts a version change as changed rather than add plus remove', async () => { + const result = await build().setMods(server, [ + { modId: MOCK_MOD, name: 'Mock Sample Mod', version: '2.0.0' }, + ]); + expect(result).toMatchObject({ added: 0, removed: 0, changed: 1 }); }); - it('imports a config revision after a successful write', async () => { - await service.setMods(server, []); - expect(configSyncCalled).toBe(1); + it('writes a rollback backup of the previous file before modifying it', async () => { + const before = (await provider.downloadTextFile('abc123', '/config.json')).content; + await build().setMods(server, []); + expect(provider.writtenFiles.get('/config.json.bak')).toBe(before); + expect(JSON.parse(provider.writtenFiles.get('/config.json')!).game.mods).toEqual([]); }); it('normalizes mod ids to uppercase and drops empty name/version', async () => { - const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]); + const result = await build().setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]); expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]); }); + it('rejects a write based on a stale revision instead of clobbering it', async () => { + const service = build(); + const stale = (await service.getMods(server)).revision; + // Somebody else edits the file in between. + await service.setMods(server, [{ modId: ADMIN_TOOLS }]); + await expect(service.setMods(server, [], stale)).rejects.toMatchObject({ code: 'CONFLICT' }); + }); + it('fails the write when read-back verification does not match', async () => { - // Simulate a server that ignores writes to config.json. const originalWrite = provider.writeTextFile.bind(provider); vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => { if (path === '/config.json') return; // swallow the write await originalWrite(sid, path, content); }); - await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/); + await expect(build().setMods(server, [])).rejects.toThrow(/verification failed/); }); it('refuses to modify a config without a game section', async () => { await provider.writeTextFile('abc123', '/config.json', '{"something": true}'); - await expect(service.setMods(server, [])).rejects.toThrow(ApiError); + await expect(build().setMods(server, [])).rejects.toThrow(ApiError); + }); + + describe('overview', () => { + it('joins installed mods with workshop metadata and flags updates', async () => { + const service = build({ + [MOCK_MOD]: workshopDetail({ id: MOCK_MOD, name: 'Mock Sample Mod', version: '1.4.0' }), + }); + const overview = await service.getOverview(server); + + expect(overview.mods).toHaveLength(1); + const entry = overview.mods[0]!; + expect(entry.pinnedVersion).toBe('1.0.2'); + expect(entry.workshop?.latestVersion).toBe('1.4.0'); + expect(entry.updateAvailable).toBe(true); + expect(overview.updatesAvailable).toBe(1); + expect(overview.warming).toBe(false); + }); + + it('lists missing dependencies and removal blockers', async () => { + await build().setMods(server, [{ modId: MOCK_MOD }, { modId: ADMIN_TOOLS }]); + const service = build({ + [MOCK_MOD]: workshopDetail({ + id: MOCK_MOD, + dependencyCount: 1, + dependencies: [ + { + id: DEPENDENCY, + name: 'Required Pack', + version: null, + sizeBytes: 10, + published: true, + private: false, + }, + ], + }), + [ADMIN_TOOLS]: workshopDetail({ + id: ADMIN_TOOLS, + dependencyCount: 1, + dependencies: [ + { + id: MOCK_MOD, + name: 'Mock Sample Mod', + version: null, + sizeBytes: 10, + published: true, + private: false, + }, + ], + }), + }); + + const overview = await service.getOverview(server); + const sample = overview.mods.find((mod) => mod.modId === MOCK_MOD)!; + expect(sample.missingDependencies.map((dep) => dep.id)).toEqual([DEPENDENCY]); + // Admin Tools depends on the sample mod, so removing it is unsafe. + expect(sample.requiredBy).toEqual([ADMIN_TOOLS]); + }); + + it('reports mods the workshop could not resolve', async () => { + const overview = await build().getOverview(server); + expect(overview.unresolvedIds).toEqual([MOCK_MOD]); + expect(overview.mods[0]!.workshop).toBeNull(); + }); + }); + + describe('resolve', () => { + it('pulls in transitive dependencies with their sizes', async () => { + const service = build({ + [MOCK_MOD]: workshopDetail({ + id: MOCK_MOD, + sizeBytes: 100, + dependencyCount: 1, + dependencies: [ + { + id: DEPENDENCY, + name: 'Required Pack', + version: null, + sizeBytes: 900, + published: true, + private: false, + }, + ], + }), + [DEPENDENCY]: workshopDetail({ id: DEPENDENCY, name: 'Required Pack', sizeBytes: 900 }), + }); + + const result = await service.resolve([{ modId: MOCK_MOD, version: '1.0.2' }]); + expect(result.mods.map((mod) => mod.modId).sort()).toEqual([DEPENDENCY, MOCK_MOD].sort()); + expect(result.addedDependencies).toHaveLength(1); + expect(result.addedDependencies[0]).toMatchObject({ + modId: DEPENDENCY, + viaDependency: true, + requiredBy: [MOCK_MOD], + }); + expect(result.totalSizeBytes).toBe(1000); + }); + + it('does not loop forever on a circular dependency graph', async () => { + const service = build({ + [MOCK_MOD]: workshopDetail({ + id: MOCK_MOD, + dependencies: [ + { + id: DEPENDENCY, + name: 'B', + version: null, + sizeBytes: 1, + published: true, + private: false, + }, + ], + }), + [DEPENDENCY]: workshopDetail({ + id: DEPENDENCY, + dependencies: [ + { + id: MOCK_MOD, + name: 'A', + version: null, + sizeBytes: 1, + published: true, + private: false, + }, + ], + }), + }); + const result = await service.resolve([{ modId: MOCK_MOD }]); + expect(result.mods).toHaveLength(2); + }); + + it('names ids the workshop does not know instead of dropping them', async () => { + const result = await build().resolve([{ modId: 'CCCC000000000009' }]); + expect(result.unresolvedIds).toEqual(['CCCC000000000009']); + }); }); }); diff --git a/apps/api/test/performance-service.test.ts b/apps/api/test/performance-service.test.ts index bd0e32a..0a70317 100644 --- a/apps/api/test/performance-service.test.ts +++ b/apps/api/test/performance-service.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js'; +import { ConfigEditorService } from '../src/modules/config/config-editor-service.js'; import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js'; import { PerformanceSettingsService } from '../src/modules/config/performance-service.js'; import type { ConfigSyncService } from '../src/modules/config/config-sync.js'; @@ -20,16 +21,16 @@ const server: ServerRecord = { describe('PerformanceSettingsService', () => { let provider: MockGameServerProvider; + let gateway: ConfigFileGateway; + let editor: ConfigEditorService; let service: PerformanceSettingsService; beforeEach(() => { provider = new MockGameServerProvider(); + gateway = new ConfigFileGateway(provider, '/config.json'); const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService; - service = new PerformanceSettingsService( - new ConfigFileGateway(provider, '/config.json'), - configSync, - createLogger('silent'), - ); + editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent')); + service = new PerformanceSettingsService(gateway, editor, createLogger('silent')); }); it('reads current values, reporting absent keys as null', async () => { @@ -46,16 +47,19 @@ describe('PerformanceSettingsService', () => { }); it('sets changed values and removes nulled keys, preserving everything else', async () => { - const { settings } = await service.get(server); const result = await service.update(server, { - ...settings, maxPlayers: 32, playerSaveTime: 180, // new key disableAI: null, - aiLimit: null, // remove key → game default + aiLimit: null, // remove key -> game default }); - expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']); + expect(result.changedFields.sort()).toEqual([ + 'aiLimit', + 'disableAI', + 'maxPlayers', + 'playerSaveTime', + ]); expect(result.requiresRestart).toBe(true); const written = JSON.parse(provider.writtenFiles.get('/config.json')!); @@ -66,14 +70,101 @@ describe('PerformanceSettingsService', () => { expect(written.bindPort).toBe(2001); expect(written.game.scenarioId).toContain('Missions'); expect(written.game.mods).toHaveLength(1); - // Backup written: expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy(); }); + /** + * The old form posted every field on every save, so a form loaded before + * somebody else's change silently reverted it. Only the submitted keys may + * ever be written. + */ + it('leaves fields the caller did not submit alone', async () => { + await service.update(server, { maxPlayers: 48 }); + const written = JSON.parse(provider.writtenFiles.get('/config.json')!); + expect(written.game.maxPlayers).toBe(48); + expect(written.game.gameProperties.serverMaxViewDistance).toBe(2500); + expect(written.operating.aiLimit).toBe(40); + }); + it('does not write the file at all when nothing changed', async () => { const { settings } = await service.get(server); - const result = await service.update(server, settings); + const result = await service.update(server, { + maxPlayers: settings.maxPlayers, + aiLimit: settings.aiLimit, + }); expect(result.changedFields).toEqual([]); expect(provider.writtenFiles.has('/config.json')).toBe(false); }); + + it('rejects a save based on a revision that has since moved', async () => { + const stale = (await service.get(server)).revision; + await service.update(server, { maxPlayers: 24 }); + await expect( + service.update(server, { maxPlayers: 48 }, { expectedRevision: stale }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); +}); + +describe('ConfigEditorService', () => { + let provider: MockGameServerProvider; + let editor: ConfigEditorService; + + beforeEach(() => { + provider = new MockGameServerProvider(); + const gateway = new ConfigFileGateway(provider, '/config.json'); + const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService; + editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent')); + }); + + it('exposes every key in the file, not just the ones the panel knows', async () => { + const tree = await editor.getTree(server); + const paths = tree.entries.map((entry) => entry.path); + expect(paths).toContain('bindAddress'); + expect(paths).toContain('game.crossPlatform'); + expect(paths).toContain('game.gameProperties.networkViewDistance'); + expect(tree.revision).toMatch(/^[a-f0-9]{16}$/); + }); + + /** + * Reforger eggs often re-template config.json from startup variables at + * boot, which is why edits could appear to save and then vanish. + */ + it('flags config keys that a startup variable also controls', async () => { + const tree = await editor.getTree(server); + const mirror = tree.mirrors.find((entry) => entry.envVariable === 'MAX_PLAYERS'); + expect(mirror).toBeDefined(); + expect(mirror!.configPath).toBe('game.maxPlayers'); + }); + + it('patches an arbitrary path and reports what changed', async () => { + const result = await editor.patch(server, [ + { path: 'operating.slotReservationTimeout', value: 90 }, + ]); + expect(result.changedPaths).toEqual(['operating.slotReservationTimeout']); + const written = JSON.parse(provider.writtenFiles.get('/config.json')!); + expect(written.operating.slotReservationTimeout).toBe(90); + }); + + it('mirrors a changed value into its startup variable when asked', async () => { + const result = await editor.patch(server, [{ path: 'game.maxPlayers', value: 40 }], { + writeStartupVars: true, + }); + expect(result.startupVarsWritten).toContain('MAX_PLAYERS'); + const variables = await provider.listStartupVariables(); + expect(variables.find((v) => v.envVariable === 'MAX_PLAYERS')?.serverValue).toBe('40'); + }); + + it('round-trips the raw editor and rejects invalid JSON', async () => { + const raw = await editor.getRaw(server); + expect(JSON.parse(raw.content).game.name).toBe('Mock Reforger Server'); + await expect(editor.putRaw(server, '{ nope')).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + }); + + it('refuses a raw write that would drop the game section', async () => { + await expect(editor.putRaw(server, '{"bindPort":2001}')).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b511061..a89c491 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,20 +4,27 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { useCurrentUser } from './api/hooks.js'; import { api, ApiClientError } from './api/client.js'; import { Layout } from './components/layout.js'; -import { Spinner } from './components/ui.js'; +import { EmptyState, Spinner, ToastProvider } from './components/ui.js'; import { LoginPage } from './pages/login.js'; import { OverviewPage } from './pages/overview.js'; import { ModsPage } from './pages/mods.js'; -import { LogsPage } from './pages/logs.js'; -import { - ActivityPage, - ConfigurationsPage, - KillfeedPage, - PlayersPage, - SettingsPage, -} from './pages/simple-pages.js'; +import { ConfigurationPage } from './pages/configuration.js'; +import { MissionPage } from './pages/mission.js'; +import { ConsolePage } from './pages/console.js'; +import { ActivityPage, KillfeedPage, PlayersPage, SettingsPage } from './pages/simple-pages.js'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Config and mod reads hit the game server; do not re-fetch them just + // because a tab regained focus. + refetchOnWindowFocus: false, + retry: (failureCount, error) => + !(error instanceof ApiClientError && error.status >= 400 && error.status < 500) && + failureCount < 2, + }, + }, +}); /** Redeems a stored invite code once, right after login, then refreshes /me. */ function InviteRedeemer() { @@ -49,8 +56,12 @@ function AuthGate() { } if (!user) { return ( -
- Could not reach the panel API. Is the backend running? +
+
); } @@ -62,13 +73,15 @@ function AuthGate() { }> } /> } /> - } /> + } /> + } /> } /> } /> } /> - } /> + } /> } /> - {/* Old bookmarks from the tabbed server page and plural path. */} + {/* Old bookmarks. */} + } /> } /> } /> } /> @@ -81,9 +94,11 @@ function AuthGate() { export function App() { return ( - - - + + + + + ); } diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index 54eb4fd..015dcd5 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -1,37 +1,49 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ActivityItem, + ConfigPatchOp, + ConfigPatchResult, + ConfigRawResponse, + ConfigTreeResponse, ConfigurationResponse, + ConsoleBacklog, + ConsoleLine, CurrentUser, InviteSummary, KillfeedEvent, - MissionsResponse, - ModsCheckResponse, - PerformanceSettingsPatch, - PerformanceSettingsResponse, - RawLogsResponse, - RestartScheduleInput, - ResourceHistoryResponse, - StartupResponse, KnownPlayer, LogIngestionHealth, LogSyncResult, + MissionsResponse, ModPackSummary, + ModResolveResponse, + ModsOverviewResponse, PanelUser, + PerformanceSettingsPatch, + PerformanceSettingsResponse, PlayersResponse, + RawLogsResponse, ReforgerConfigMod, + ResourceHistoryResponse, + RestartScheduleInput, ServerModsResponse, - UpdateModsResult, ServerResources, ServerScheduleSummary, + ServerStatus, ServerSummary, - WorkshopHealth, + StartupResponse, + UpdateModsResult, WorkshopModDetail, + WorkshopModVersionsResponse, WorkshopSearchResponse, + WorkshopServerModsResponse, + WorkshopServerSearchResponse, } from '@reforger-panel/shared'; import { api, ApiClientError } from './client.js'; +/* -------------------------------------------------------------------- auth */ + export function useCurrentUser() { return useQuery({ queryKey: ['auth', 'me'], @@ -50,6 +62,8 @@ export function useLogout() { }); } +/* ----------------------------------------------------------------- servers */ + export function useServers() { return useQuery({ queryKey: ['servers'], @@ -58,23 +72,43 @@ export function useServers() { }); } -export function useServer(slug: string) { - return useQuery({ - queryKey: ['servers', slug], - queryFn: () => api.get(`/api/servers/${slug}`), - refetchInterval: 15_000, - }); +/** The panel manages one server; every page derives its slug from here. */ +export function usePrimaryServer(): ServerSummary | undefined { + return useServers().data?.servers[0]; } export function useServerResources(slug: string, enabled = true) { return useQuery({ queryKey: ['servers', slug, 'resources'], queryFn: () => api.get(`/api/servers/${slug}/resources`), - refetchInterval: 10_000, + // Backed by the websocket stats frame server-side, so this is a cheap + // in-memory read rather than an upstream request. + refetchInterval: 5_000, enabled, }); } +export function useResourceHistory(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'resources', 'history'], + queryFn: () => api.get(`/api/servers/${slug}/resources/history`), + refetchInterval: 15_000, + }); +} + +export function usePowerAction(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (action: 'start' | 'stop' | 'restart') => + api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['servers'] }); + }, + }); +} + +/* ----------------------------------------------------------------- players */ + export function usePlayers(slug: string) { return useQuery({ queryKey: ['servers', slug, 'players'], @@ -109,6 +143,8 @@ export function useKillfeed(slug: string, limit = 100) { }); } +/* ----------------------------------------------------------- configuration */ + export function useConfiguration(slug: string) { return useQuery({ queryKey: ['servers', slug, 'configuration'], @@ -119,45 +155,75 @@ export function useConfiguration(slug: string) { }); } -export function useMissions(slug: string) { +export function usePerformanceSettings(slug: string) { return useQuery({ - queryKey: ['servers', slug, 'missions'], - queryFn: () => api.get(`/api/servers/${slug}/missions`), - staleTime: 60_000, + queryKey: ['servers', slug, 'config', 'performance'], + queryFn: () => api.get(`/api/servers/${slug}/config/performance`), + staleTime: 30_000, + refetchOnWindowFocus: false, }); } -/** - * Opens a persistent SSE connection to stream live console output line by line. - * `onLine` is called for each received line. The connection closes and re-opens - * automatically when the component unmounts or `slug` changes. - */ -export function useConsoleStream(slug: string, onLine: (line: string) => void, enabled: boolean) { - const onLineRef = useRef(onLine); - onLineRef.current = onLine; +export type PerformanceSavePayload = { + settings: PerformanceSettingsPatch; + expectedRevision?: string; + writeStartupVars?: boolean; +}; - useEffect(() => { - if (!enabled || !slug) return; - const es = new EventSource(`/api/servers/${slug}/logs/stream`, { withCredentials: true }); - es.onmessage = (e: MessageEvent) => { - try { - const line = JSON.parse(e.data) as string; - onLineRef.current(line); - } catch { - // ignore - } - }; - es.onerror = () => es.close(); - return () => es.close(); - }, [slug, enabled]); +export function useSetPerformanceSettings(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: PerformanceSavePayload) => + api.put( + `/api/servers/${slug}/config/performance`, + payload, + ), + onSuccess: () => { + // config.json moved: every view derived from it is now stale. + void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); + }, + }); } -export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) { +/** Every key present in config.json, for the searchable editor. */ +export function useConfigTree(slug: string, enabled: boolean) { return useQuery({ - queryKey: ['servers', slug, 'logs', 'raw', lines], - queryFn: () => api.get(`/api/servers/${slug}/logs/raw?lines=${lines}`), - refetchInterval: autoRefresh ? 10_000 : false, + queryKey: ['servers', slug, 'config', 'tree'], + queryFn: () => api.get(`/api/servers/${slug}/config/tree`), enabled, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function usePatchConfig(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + ops: ConfigPatchOp[]; + expectedRevision?: string; + writeStartupVars?: boolean; + }) => api.patch(`/api/servers/${slug}/config`, payload), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }), + }); +} + +export function useConfigRaw(slug: string, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'config', 'raw'], + queryFn: () => api.get(`/api/servers/${slug}/config/raw`), + enabled, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function usePutConfigRaw(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { content: string; expectedRevision?: string }) => + api.put(`/api/servers/${slug}/config/raw`, payload), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }), }); } @@ -183,6 +249,59 @@ export function useUpdateStartupVariable(slug: string) { }); } +/* ---------------------------------------------------------------- missions */ + +export function useMissions(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'missions'], + queryFn: () => api.get(`/api/servers/${slug}/missions`), + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + }); +} + +/* -------------------------------------------------------------------- mods */ + +export function useServerMods(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'mods'], + queryFn: () => api.get(`/api/servers/${slug}/mods`), + staleTime: 60_000, + refetchOnWindowFocus: false, + }); +} + +/** + * The whole Mods page in one request. While the server-side Workshop cache is + * still filling (`warming`), this refetches shortly so metadata appears + * progressively instead of blocking the first paint. + */ +export function useModsOverview(slug: string) { + return useQuery({ + queryKey: ['servers', slug, 'mods', 'overview'], + queryFn: () => api.get(`/api/servers/${slug}/mods/overview`), + staleTime: 60_000, + refetchOnWindowFocus: false, + refetchInterval: (query) => (query.state.data?.warming ? 3_000 : false), + }); +} + +export function useResolveMods(slug: string) { + return useMutation({ + mutationFn: (mods: ReforgerConfigMod[]) => + api.post(`/api/servers/${slug}/mods/resolve`, { mods }), + }); +} + +export function useSetServerMods(slug: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { mods: ReforgerConfigMod[]; expectedRevision?: string }) => + api.put(`/api/servers/${slug}/mods`, payload), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }), + }); +} + export function useModPacks(slug: string) { return useQuery({ queryKey: ['servers', slug, 'mod-packs'], @@ -190,128 +309,189 @@ export function useModPacks(slug: string) { }); } +/* ---------------------------------------------------------------- workshop */ + +export type WorkshopSearchParams = { + query: string; + page: number; + sort?: string; + tag?: string; + category?: string; +}; + +export function useWorkshopSearch(params: WorkshopSearchParams, enabled = true) { + const search = new URLSearchParams({ q: params.query, page: String(params.page) }); + if (params.sort) search.set('sort', params.sort); + if (params.tag) search.set('tag', params.tag); + if (params.category) search.set('category', params.category); + return useQuery({ + queryKey: ['workshop', 'search', params], + queryFn: () => api.get(`/api/workshop/search?${search}`), + enabled, + placeholderData: (previous) => previous, + staleTime: 5 * 60_000, + }); +} + +export function useWorkshopMod(modId: string | null) { + return useQuery({ + queryKey: ['workshop', 'mod', modId], + queryFn: () => api.get(`/api/workshop/mods/${modId}`), + enabled: modId !== null, + staleTime: 30 * 60_000, + }); +} + +export function useWorkshopModVersions(modId: string | null) { + return useQuery({ + queryKey: ['workshop', 'mod', modId, 'versions'], + queryFn: () => api.get(`/api/workshop/mods/${modId}/versions`), + enabled: modId !== null, + staleTime: 30 * 60_000, + }); +} + +/** Live server browser, used to copy another server's modlist. */ +export function useWorkshopServers(query: string, enabled: boolean) { + return useQuery({ + queryKey: ['workshop', 'servers', query], + queryFn: () => + api.get(`/api/workshop/servers?q=${encodeURIComponent(query)}`), + enabled: enabled && query.trim().length >= 2, + staleTime: 60_000, + }); +} + +export function useWorkshopServerMods(serverId: string | null) { + return useQuery({ + queryKey: ['workshop', 'servers', serverId, 'mods'], + queryFn: () => api.get(`/api/workshop/servers/${serverId}/mods`), + enabled: serverId !== null, + staleTime: 60_000, + }); +} + +/* ------------------------------------------------------------ live console */ + +const MAX_CONSOLE_LINES = 2_000; + +export type ConsoleFeed = { + lines: ConsoleLine[]; + status: ServerStatus; + connected: boolean; + stats: ServerResources | null; + clear: () => void; +}; + +/** + * Subscribes to the panel's SSE relay of the Pterodactyl/Wings feed. + * + * Because the backend keeps its own line backlog, attaching mid-session + * immediately yields recent context — including install, update and mod + * download output, which never reaches the game's own log file. + */ +export function useConsoleFeed(slug: string, enabled: boolean): ConsoleFeed { + const [lines, setLines] = useState([]); + const [status, setStatus] = useState('unknown'); + const [connected, setConnected] = useState(false); + const [stats, setStats] = useState(null); + const lastSeq = useRef(0); + + useEffect(() => { + if (!enabled || !slug) return; + const source = new EventSource(`/api/servers/${slug}/console/stream`, { + withCredentials: true, + }); + + const append = (incoming: ConsoleLine[]) => { + const fresh = incoming.filter((line) => line.seq > lastSeq.current); + if (fresh.length === 0) return; + lastSeq.current = fresh[fresh.length - 1]!.seq; + setLines((current) => { + const next = [...current, ...fresh]; + return next.length > MAX_CONSOLE_LINES ? next.slice(next.length - MAX_CONSOLE_LINES) : next; + }); + }; + + const parse = (event: MessageEvent): T | null => { + try { + return JSON.parse(event.data) as T; + } catch { + return null; + } + }; + + source.addEventListener('backlog', (event) => { + const backlog = parse(event as MessageEvent); + if (!backlog) return; + // A reconnect replays the backlog; seq numbers keep it idempotent. + append(backlog.lines); + setStatus(backlog.status); + setConnected(backlog.connected); + }); + + source.addEventListener('line', (event) => { + const line = parse(event as MessageEvent); + if (line) append([line]); + }); + + source.addEventListener('status', (event) => { + const payload = parse<{ status: ServerStatus }>(event as MessageEvent); + if (payload) setStatus(payload.status); + }); + + source.addEventListener('stats', (event) => { + const payload = parse(event as MessageEvent); + if (payload) setStats(payload); + }); + + source.onopen = () => setConnected(true); + source.onerror = () => setConnected(false); + + return () => source.close(); + }, [slug, enabled]); + + return { + lines, + status, + connected, + stats, + clear: () => setLines([]), + }; +} + +/** The game's own log file, as a secondary diagnostic to the live feed. */ +export function useRawLogs(slug: string, lines: number, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'logs', 'raw', lines], + queryFn: () => api.get(`/api/servers/${slug}/logs/raw?lines=${lines}`), + enabled, + staleTime: 10_000, + refetchOnWindowFocus: false, + }); +} + +/* ----------------------------------------------------------- log ingestion */ + export function useLogHealth(slug: string, enabled: boolean) { return useQuery({ queryKey: ['servers', slug, 'logs', 'health'], queryFn: () => api.get(`/api/servers/${slug}/logs/health`), - refetchInterval: 20_000, + refetchInterval: 30_000, enabled, }); } -export function usePowerAction(slug: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (action: 'start' | 'stop' | 'restart') => - api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['servers'] }); - }, - }); -} - -export function useResourceHistory(slug: string) { - return useQuery({ - queryKey: ['servers', slug, 'resources', 'history'], - queryFn: () => api.get(`/api/servers/${slug}/resources/history`), - refetchInterval: 15_000, - }); -} - -export function usePerformanceSettings(slug: string) { - return useQuery({ - queryKey: ['servers', slug, 'config', 'performance'], - queryFn: () => api.get(`/api/servers/${slug}/config/performance`), - staleTime: 60_000, - refetchOnWindowFocus: false, - }); -} - -export function useSetPerformanceSettings(slug: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (settings: PerformanceSettingsPatch) => - api.put( - `/api/servers/${slug}/config/performance`, - settings, - ), - onSuccess: (result) => { - queryClient.setQueryData(['servers', slug, 'config', 'performance'], result); - void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); - }, - }); -} - -export function useInvites(enabled: boolean) { - return useQuery({ - queryKey: ['invites'], - queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'), - enabled, - }); -} - -export function useCreateInvite() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (input: { role: string; expiresInHours?: number | null }) => - api.post<{ id: string; code: string; role: string; expiresAt: string }>( - '/api/invites', - input, - ), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), - }); -} - -export function useDeleteInvite() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => api.delete(`/api/invites/${id}`), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), - }); -} - -export function useServerMods(slug: string) { - return useQuery({ - queryKey: ['servers', slug, 'mods'], - queryFn: () => api.get(`/api/servers/${slug}/mods`), - // Each call downloads config.json from Pterodactyl — no background polling. - staleTime: 60_000, - refetchOnWindowFocus: false, - }); -} - -export function useSetServerMods(slug: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (mods: ReforgerConfigMod[]) => - api.put(`/api/servers/${slug}/mods`, { mods }), - onSuccess: (result) => { - queryClient.setQueryData(['servers', slug, 'mods'], result); - void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); - }, - }); -} - -export function useServerModsCheck(slug: string, enabled: boolean) { - return useQuery({ - queryKey: ['servers', slug, 'mods', 'check'], - queryFn: () => api.get(`/api/servers/${slug}/mods/check`), - enabled, - staleTime: 2 * 60_000, - refetchOnWindowFocus: false, - }); -} - export function useManualLogSync(slug: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => api.post(`/api/servers/${slug}/logs/sync`), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); - }, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }), }); } +/* --------------------------------------------------------------- schedules */ + export function useServerSchedules(slug: string, enabled: boolean) { return useQuery({ queryKey: ['servers', slug, 'schedules'], @@ -359,35 +539,33 @@ export function useDeleteSchedule(slug: string) { }); } -export function useWorkshopHealth() { +/* --------------------------------------------------------- users & invites */ + +export function useInvites(enabled: boolean) { return useQuery({ - queryKey: ['workshop', 'health'], - queryFn: () => api.get('/api/workshop/health'), - refetchInterval: 60_000, + queryKey: ['invites'], + queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'), + enabled, }); } -export function useWorkshopSearch(query: string, page: number, sort?: string) { - return useQuery({ - queryKey: ['workshop', 'search', query, page, sort], - queryFn: () => - api.get( - `/api/workshop/search?q=${encodeURIComponent(query)}&page=${page}${ - sort ? `&sort=${encodeURIComponent(sort)}` : '' - }`, +export function useCreateInvite() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { role: string; expiresInHours?: number | null }) => + api.post<{ id: string; code: string; role: string; expiresAt: string }>( + '/api/invites', + input, ), - // An empty query browses the Workshop front page (/v1/mods). - placeholderData: (previous) => previous, - staleTime: 5 * 60_000, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), }); } -export function useWorkshopMod(modId: string | null) { - return useQuery({ - queryKey: ['workshop', 'mod', modId], - queryFn: () => api.get(`/api/workshop/mods/${modId}`), - enabled: modId !== null, - staleTime: 5 * 60_000, +export function useDeleteInvite() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.delete(`/api/invites/${id}`), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }), }); } diff --git a/apps/web/src/components/charts.tsx b/apps/web/src/components/charts.tsx index 911a3b9..042f551 100644 --- a/apps/web/src/components/charts.tsx +++ b/apps/web/src/components/charts.tsx @@ -1,3 +1,5 @@ +import { useId, useRef, useState } from 'react'; + export type ChartSeries = { points: { t: number; v: number }[]; /** Any CSS color; used for the line and (when filled) the area. */ @@ -9,24 +11,34 @@ export type ChartSeries = { /** * Dependency-free SVG time-series chart. Series share the x (time) axis and a * single y scale (`max` fixes it, e.g. 100 for CPU%; otherwise it fits data). + * + * Unlike a plain sparkline this is readable: it carries a gridline, an axis + * maximum, and a hover crosshair that reports the value under the pointer — + * previously there was no way to get a number off these graphs at all. */ export function TimeSeriesChart({ series, max, - height = 64, + height = 56, className = '', + format = (value: number) => value.toFixed(0), }: { series: ChartSeries[]; max?: number | null; height?: number; className?: string; + format?: (value: number) => string; }) { + const clipId = useId(); + const svgRef = useRef(null); + const [hover, setHover] = useState<{ ratio: number } | null>(null); + const allPoints = series.flatMap((s) => s.points); if (allPoints.length < 2) { return (
collecting data…
@@ -44,38 +56,128 @@ export function TimeSeriesChart({ const x = (t: number) => ((t - tMin) / tSpan) * W; const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H; + /** Nearest sample to the hovered x position, per series. */ + const hovered = + hover === null + ? null + : series.map((s) => { + const target = tMin + hover.ratio * tSpan; + let best = s.points[0]!; + for (const point of s.points) { + if (Math.abs(point.t - target) < Math.abs(best.t - target)) best = point; + } + return { series: s, point: best }; + }); + + const onPointerMove = (event: React.PointerEvent) => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + setHover({ ratio: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) }); + }; + return ( - - {/* 50% guide line */} - - {series.map((s, index) => { - if (s.points.length < 2) return null; - const line = s.points - .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`) - .join(' '); - const first = s.points[0]!; - const last = s.points[s.points.length - 1]!; - const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`; - return ( - - {s.fill !== false && } - - - ); - })} - +
+ setHover(null)} + > + + + + + + {/* Quarter gridlines give the eye a scale without adding clutter. */} + {[0.25, 0.5, 0.75].map((fraction) => ( + + ))} + + {series.map((s, index) => { + if (s.points.length < 2) return null; + const line = s.points + .map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`) + .join(' '); + const first = s.points[0]!; + const last = s.points[s.points.length - 1]!; + const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`; + return ( + + {s.fill !== false && } + + + ); + })} + {hovered && ( + <> + + {hovered.map(({ series: s, point }, index) => ( + + ))} + + )} + + + + {/* Axis maximum, so the shape has a magnitude attached to it. */} + + {format(scale)} + + + {hovered && ( +
0.6 ? '-100%' : '0'}, -100%)`, + }} + > + {hovered.map(({ series: s, point }, index) => ( +
+ + {s.label && {s.label}} + {format(point.v)} +
+ ))} +
+ )} +
); } diff --git a/apps/web/src/components/config/key-editor.tsx b/apps/web/src/components/config/key-editor.tsx new file mode 100644 index 0000000..fb00bd8 --- /dev/null +++ b/apps/web/src/components/config/key-editor.tsx @@ -0,0 +1,293 @@ +import { useMemo, useState } from 'react'; +import type { ConfigEntry, ConfigPatchOp, StartupMirror } from '@reforger-panel/shared'; +import { useConfigTree, usePatchConfig } from '../../api/hooks.js'; +import { formatRelativeTime } from '../../lib/format.js'; +import { + Badge, + Button, + EmptyState, + Notice, + SearchInput, + Spinner, + Toggle, + useToast, +} from '../ui.js'; +import { Icon } from '../icons.js'; + +type EditValue = string | number | boolean | null; + +/** + * Searchable editor over every key config.json actually contains. + * + * The panel used to reach only eleven hardcoded fields, and submitted all of + * them on every save. Here each row tracks its own dirty state and only the + * touched paths are sent, against the revision the page was loaded at — so a + * stale tab is rejected instead of quietly reverting someone else's edit. + */ +export function ConfigKeyEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) { + const toast = useToast(); + const { data, isLoading, error, refetch } = useConfigTree(slug, canEdit); + const patch = usePatchConfig(slug); + + const [query, setQuery] = useState(''); + const [edits, setEdits] = useState>(new Map()); + const [writeStartupVars, setWriteStartupVars] = useState(true); + + const entries = data?.entries ?? []; + const mirrorByPath = useMemo(() => { + const map = new Map(); + for (const mirror of data?.mirrors ?? []) map.set(mirror.configPath, mirror); + return map; + }, [data?.mirrors]); + + const visible = useMemo(() => { + if (!query.trim()) return entries; + const needle = query.trim().toLowerCase(); + return entries.filter( + (entry) => + entry.path.toLowerCase().includes(needle) || + String(entry.value ?? '') + .toLowerCase() + .includes(needle), + ); + }, [entries, query]); + + const setEdit = (path: string, value: EditValue) => { + setEdits((current) => { + const next = new Map(current); + next.set(path, value); + return next; + }); + }; + + const clearEdit = (path: string) => { + setEdits((current) => { + const next = new Map(current); + next.delete(path); + return next; + }); + }; + + const ops: ConfigPatchOp[] = useMemo( + () => [...edits.entries()].map(([path, value]) => ({ path, value })), + [edits], + ); + + const touchedMirrors = ops + .map((op) => mirrorByPath.get(op.path)) + .filter((mirror): mirror is StartupMirror => mirror !== undefined); + + const conflictingMirrors = (data?.mirrors ?? []).filter((mirror) => mirror.conflict); + + const apply = () => { + patch.mutate( + { ops, expectedRevision: data?.revision, writeStartupVars }, + { + onSuccess: (result) => { + setEdits(new Map()); + void refetch(); + toast( + result.changedPaths.length === 0 + ? 'No changes to save.' + : `Saved ${result.changedPaths.length} value${result.changedPaths.length === 1 ? '' : 's'}${ + result.startupVarsWritten.length > 0 + ? ` (also mirrored to ${result.startupVarsWritten.join(', ')})` + : '' + }. Restart to apply.`, + 'ok', + ); + }, + onError: (mutationError) => toast(mutationError.message, 'danger'), + }, + ); + }; + + if (!canEdit) { + return ; + } + if (isLoading) return ; + if (error || !data) { + return ( + void refetch()}> + Retry + + } + /> + ); + } + + return ( +
+
+ + + {visible.length} of {entries.length} keys · read {formatRelativeTime(data.fetchedAt)} + +
+ + {conflictingMirrors.length > 0 && ( + +

+ This egg regenerates parts of config.json from Pterodactyl startup variables at boot. + For these keys the file and the variable currently disagree, so the variable wins on the + next restart: +

+
    + {conflictingMirrors.map((mirror) => ( +
  • + {mirror.configPath} = {String(mirror.configValue ?? '—')} · {mirror.envVariable} ={' '} + {mirror.startupValue || '—'} +
  • + ))} +
+
+ )} + + {visible.length === 0 ? ( + + ) : ( +
    + {visible.map((entry) => ( + setEdit(entry.path, value)} + onReset={() => clearEdit(entry.path)} + /> + ))} +
+ )} + + {ops.length > 0 && ( +
+ + {ops.length} value{ops.length === 1 ? '' : 's'} changed + + {touchedMirrors.length > 0 && ( + + )} +
+ + +
+
+ )} +
+ ); +} + +function KeyRow({ + entry, + edited, + editValue, + mirror, + onChange, + onReset, +}: { + entry: ConfigEntry; + edited: boolean; + editValue: EditValue; + mirror: StartupMirror | undefined; + onChange: (value: EditValue) => void; + onReset: () => void; +}) { + const value = edited ? editValue : entry.value; + const removed = edited && editValue === null; + const readOnly = entry.type === 'array'; + + return ( +
  • +
    +

    + {entry.path} + {edited && } + {mirror && ( + + {mirror.envVariable} + + )} +

    + {removed && ( +

    + Key will be removed — the game default applies. +

    + )} +
    + +
    + {readOnly ? ( + + {entry.raw} + + ) : entry.type === 'boolean' ? ( + + ) : entry.type === 'number' ? ( + + onChange(event.target.value === '' ? null : Number(event.target.value)) + } + className="input numeric" + /> + ) : ( + onChange(event.target.value)} + className="input font-mono text-xs" + /> + )} + + {!readOnly && ( + + )} +
    +
  • + ); +} diff --git a/apps/web/src/components/config/mirror-hints.ts b/apps/web/src/components/config/mirror-hints.ts new file mode 100644 index 0000000..7977ec8 --- /dev/null +++ b/apps/web/src/components/config/mirror-hints.ts @@ -0,0 +1,33 @@ +/** + * Startup variables that a Reforger egg typically templates into config.json + * at boot, and the config path each one lands on. + * + * Mirrors the authoritative server-side map in + * `apps/api/src/modules/config/startup-mirrors.ts`. The API detects these + * properly (it can see which variables the egg actually exposes and whether + * the two values currently disagree); this copy exists only so the startup + * variable list can label a row without a second round trip. + */ +export const STARTUP_MIRROR_HINTS: Record = { + SCENARIO_ID: 'game.scenarioId', + MISSION_ID: 'game.scenarioId', + MAX_PLAYERS: 'game.maxPlayers', + SERVER_NAME: 'game.name', + HOSTNAME: 'game.name', + SERVER_PASSWORD: 'game.password', + ADMIN_PASSWORD: 'game.passwordAdmin', + GAME_PORT: 'bindPort', + SERVER_PORT: 'bindPort', + BIND_PORT: 'bindPort', + SERVER_IP: 'bindAddress', + BIND_ADDRESS: 'bindAddress', + A2S_PORT: 'a2s.port', + RCON_PORT: 'rcon.port', + RCON_PASSWORD: 'rcon.password', + CROSS_PLATFORM: 'game.crossPlatform', + CROSSPLAY: 'game.crossPlatform', + BATTLEYE: 'game.gameProperties.battlEye', + VISIBLE: 'game.visible', + DISABLE_THIRD_PERSON: 'game.gameProperties.disableThirdPerson', + VIEW_DISTANCE: 'game.gameProperties.serverMaxViewDistance', +}; diff --git a/apps/web/src/components/config/raw-editor.tsx b/apps/web/src/components/config/raw-editor.tsx new file mode 100644 index 0000000..34a692a --- /dev/null +++ b/apps/web/src/components/config/raw-editor.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useConfigRaw, usePutConfigRaw } from '../../api/hooks.js'; +import { formatRelativeTime } from '../../lib/format.js'; +import { Badge, Button, EmptyState, Notice, Spinner, useToast } from '../ui.js'; + +/** + * Direct editor for config.json, for the cases a structured form cannot cover. + * The write is refused server-side if the file moved since it was loaded, and + * the previous content is always kept as config.json.bak. + */ +export function ConfigRawEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) { + const toast = useToast(); + const { data, isLoading, error, refetch } = useConfigRaw(slug, canEdit); + const save = usePutConfigRaw(slug); + const [content, setContent] = useState(null); + + useEffect(() => { + if (data) setContent((current) => current ?? data.content); + }, [data]); + + const parseError = useMemo(() => { + if (content === null) return null; + try { + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return 'config.json must be a JSON object.'; + } + if (!('game' in parsed)) return 'config.json must contain a "game" section.'; + return null; + } catch (jsonError) { + return jsonError instanceof Error ? jsonError.message : 'Invalid JSON.'; + } + }, [content]); + + if (!canEdit) { + return ; + } + if (isLoading || content === null) return ; + if (error || !data) { + return ( + void refetch()}> + Retry + + } + /> + ); + } + + const dirty = content !== data.content; + + return ( +
    +
    + + {parseError ? 'invalid JSON' : 'valid JSON'} + + {dirty && unsaved changes} + + revision {data.revision} · read {formatRelativeTime(data.fetchedAt)} + +
    + + {parseError && {parseError}} + +