From 819eb6bc079c665152ec829ac737959298e8a8e3 Mon Sep 17 00:00:00 2001 From: Niek Candaele Date: Wed, 27 Dec 2023 17:34:49 +0100 Subject: [PATCH] feat: import data from CSMM --- .../src/controllers/GameServerController.ts | 36 ++++ packages/app-api/src/lib/steamApi.ts | 2 +- packages/app-api/src/main.ts | 4 + .../app-api/src/service/GameServerService.ts | 30 +++ .../app-api/src/workers/csmmImportWorker.ts | 162 ++++++++++++++ .../app-api/src/workers/playerSyncWorker.ts | 2 + packages/lib-apiclient/src/generated/api.ts | 199 ++++++++++++++++++ .../components/feedback/FormError/index.tsx | 4 + ...0231227144315-unique-constraints-player.ts | 74 +++++++ packages/lib-queues/src/QueueService.ts | 4 + packages/lib-queues/src/config.ts | 11 + packages/lib-queues/src/dataDefinitions.ts | 4 + packages/web-main/src/Router.tsx | 2 + .../src/components/GameServerCard/index.tsx | 5 +- packages/web-main/src/pages/GameServers.tsx | 3 + .../web-main/src/pages/gameserver/import.tsx | 115 ++++++++++ packages/web-main/src/paths.ts | 1 + 17 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 packages/app-api/src/workers/csmmImportWorker.ts create mode 100644 packages/lib-db/src/migrations/sql/20231227144315-unique-constraints-player.ts create mode 100644 packages/web-main/src/pages/gameserver/import.tsx diff --git a/packages/app-api/src/controllers/GameServerController.ts b/packages/app-api/src/controllers/GameServerController.ts index 5c433b2671..1df22fca4c 100644 --- a/packages/app-api/src/controllers/GameServerController.ts +++ b/packages/app-api/src/controllers/GameServerController.ts @@ -190,6 +190,21 @@ class BanOutputDTO extends APIOutput { declare data: BanDTO[]; } +export class ImportInputDTO extends TakaroDTO { + @IsJSON() + csmmData: string; +} + +class ImportOutputDTO extends TakaroDTO { + @IsString() + id!: string; +} + +class ImportOutputDTOAPI extends APIOutput { + @Type(() => ImportOutputDTO) + @ValidateNested() + declare data: ImportOutputDTO; +} @OpenAPI({ security: [{ domainAuth: [] }], }) @@ -409,4 +424,25 @@ export class GameServerController { const result = await service.getPlayers(params.id); return apiResponse(result); } + + @Get('/gameserver/import/:id') + @UseBefore(AuthService.getAuthMiddleware([PERMISSIONS.MANAGE_GAMESERVERS])) + @ResponseSchema(ImportOutputDTOAPI) + async getImport(@Req() req: AuthenticatedRequest, @Params() params: ImportOutputDTO) { + const service = new GameServerService(req.domainId); + const result = await service.getImport(params.id); + return apiResponse(result); + } + + @Post('/gameserver/import') + @UseBefore(AuthService.getAuthMiddleware([PERMISSIONS.MANAGE_GAMESERVERS])) + @OpenAPI({ + description: 'Import a gameserver from CSMM', + }) + @ResponseSchema(ImportOutputDTOAPI) + async importFromCSMM(@Req() req: AuthenticatedRequest, @Body() data: ImportInputDTO) { + const service = new GameServerService(req.domainId); + const result = await service.import(data); + return apiResponse(result); + } } diff --git a/packages/app-api/src/lib/steamApi.ts b/packages/app-api/src/lib/steamApi.ts index cd8be1101b..6fc4976fd7 100644 --- a/packages/app-api/src/lib/steamApi.ts +++ b/packages/app-api/src/lib/steamApi.ts @@ -49,7 +49,7 @@ class SteamApi { return config; }); - axios.interceptors.request.use((request) => { + this._client.interceptors.request.use((request) => { this.log.info(`➡️ ${request.method?.toUpperCase()} ${request.url}`, { method: request.method, url: request.url, diff --git a/packages/app-api/src/main.ts b/packages/app-api/src/main.ts index 9994cbe702..0e3eefa305 100644 --- a/packages/app-api/src/main.ts +++ b/packages/app-api/src/main.ts @@ -34,6 +34,7 @@ import { PlayerOnGameServerController } from './controllers/PlayerOnGameserverCo import { ItemController } from './controllers/ItemController.js'; import { ItemsSyncWorker } from './workers/ItemsSyncWorker.js'; import { PlayerSyncWorker } from './workers/playerSyncWorker.js'; +import { CSMMImportWorker } from './workers/csmmImportWorker.js'; export const server = new HTTP( { @@ -100,6 +101,9 @@ async function main() { new PlayerSyncWorker(); log.info('👷 playerSync worker started'); + + new CSMMImportWorker(); + log.info('👷 csmmImport worker started'); } await getSocketServer(server.server as HttpServer); diff --git a/packages/app-api/src/service/GameServerService.ts b/packages/app-api/src/service/GameServerService.ts index 24a4c2a656..6dc875265e 100644 --- a/packages/app-api/src/service/GameServerService.ts +++ b/packages/app-api/src/service/GameServerService.ts @@ -30,6 +30,7 @@ import { getEmptySystemConfigSchema } from '../lib/systemConfig.js'; import { PlayerService } from './PlayerService.js'; import { PlayerOnGameServerService, PlayerOnGameServerUpdateDTO } from './PlayerOnGameserverService.js'; import { ItemCreateDTO, ItemsService } from './ItemsService.js'; +import { ImportInputDTO } from '../controllers/GameServerController.js'; const Ajv = _Ajv as unknown as typeof _Ajv.default; const ajv = new Ajv({ useDefaults: true }); @@ -467,4 +468,33 @@ export class GameServerService extends TakaroService< }) ); } + + async getImport(id: string) { + const job = await queueService.queues.csmmImport.queue.bullQueue.getJob(id); + + if (!job) { + throw new errors.NotFoundError('Job not found'); + } + + return { + jobId: job.id, + status: await job.getState(), + failedReason: job.failedReason, + }; + } + + async import(data: ImportInputDTO) { + let parsed; + try { + parsed = JSON.parse(data.csmmData); + } catch (error) { + throw new errors.BadRequestError('Invalid JSON'); + } + + const job = await queueService.queues.csmmImport.queue.add({ csmmExport: parsed, domainId: this.domainId }); + + return { + id: job.id, + }; + } } diff --git a/packages/app-api/src/workers/csmmImportWorker.ts b/packages/app-api/src/workers/csmmImportWorker.ts new file mode 100644 index 0000000000..bc10ff4c32 --- /dev/null +++ b/packages/app-api/src/workers/csmmImportWorker.ts @@ -0,0 +1,162 @@ +import { Job } from 'bullmq'; +import { TakaroWorker, ICSMMImportData } from '@takaro/queues'; +import { ctx, errors, logger } from '@takaro/util'; +import { config } from '../config.js'; +import { GameServerCreateDTO, GameServerService } from '../service/GameServerService.js'; +import { GAME_SERVER_TYPE } from '@takaro/gameserver'; +import { RoleCreateInputDTO, RoleService } from '../service/RoleService.js'; +import { PlayerService } from '../service/PlayerService.js'; +import { PlayerOnGameServerService } from '../service/PlayerOnGameserverService.js'; +import { IGamePlayer } from '@takaro/modules'; + +const log = logger('worker:csmmImport'); + +interface ICSMMPlayer { + // Can be xbl ID too... + // XBL_xxx + steamId: string; + name: string; + ip: string; + crossId: string; + role: number; +} + +interface ICSMMRole { + id: number; + name: string; +} + +interface ICSMMData { + server: { + name: string; + ip: string; + webPort: number; + authName: string; + authToken: string; + }; + players: ICSMMPlayer[]; + roles: ICSMMRole[]; +} + +export class CSMMImportWorker extends TakaroWorker { + constructor() { + super(config.get('queues.csmmImport.name'), 1, process); + } +} + +async function process(job: Job) { + ctx.addData({ + domain: job.data.domainId, + jobId: job.id, + }); + + const data = job.data.csmmExport as unknown as ICSMMData; + + const gameserverService = new GameServerService(job.data.domainId); + const roleService = new RoleService(job.data.domainId); + const playerService = new PlayerService(job.data.domainId); + const pogService = new PlayerOnGameServerService(job.data.domainId); + + // Check if the server already exists + const existingServer = await gameserverService.find({ + filters: { + name: [data.server.name], + }, + }); + + if (existingServer.total) { + throw new errors.BadRequestError(`Server with name ${data.server.name} already exists`); + } + + const server = await gameserverService.create( + await new GameServerCreateDTO().construct({ + name: data.server.name, + type: GAME_SERVER_TYPE.SEVENDAYSTODIE, + connectionInfo: JSON.stringify({ + host: `${data.server.ip}:${data.server.webPort}`, + adminUser: data.server.authName, + adminToken: data.server.authToken, + useTls: data.server.webPort === 443, + }), + }) + ); + + ctx.addData({ + gameServer: server.id, + }); + + // Sync the roles + for (const role of data.roles) { + const existing = await roleService.find({ + filters: { + name: [role.name], + }, + }); + + if (existing.total) { + continue; + } + + await roleService.create( + await new RoleCreateInputDTO().construct({ + name: role.name, + permissions: [], + }) + ); + } + + const roles = await roleService.find({}); + + // Sync the players + for (const player of data.players) { + if (!player.crossId) { + log.warn(`Player ${player.name} has no crossId, skipping player resolving`); + continue; + } + const createData = await new IGamePlayer().construct({ + name: player.name, + gameId: player.crossId.replace('EOS_', ''), + epicOnlineServicesId: player.crossId.replace('EOS_', ''), + }); + + if (player.steamId.startsWith('XBL_')) { + createData.xboxLiveId = player.steamId.replace('XBL_', ''); + } else { + createData.steamId = player.steamId; + } + + await playerService.sync(await new IGamePlayer().construct(createData), server.id); + } + + // Sync the player roles + for (const player of data.players) { + if (!player.crossId) { + log.warn(`Player ${player.name} has no crossId, skipping role assignment`); + continue; + } + const pog = await pogService.findAssociations(player.crossId.replace('EOS_', ''), server.id); + + if (!pog.length) { + log.warn(`Player ${player.name} has no player on game server association, skipping role assignment`); + continue; + } + + const CSMMRole = data.roles.find((r) => r.id === player.role); + if (!CSMMRole) { + log.warn(`Player ${player.name} has no role with id ${player.role}, skipping role assignment`); + continue; + } + + const takaroRole = roles.results.find((r) => r.name === CSMMRole.name); + + if (!takaroRole) { + log.warn(`Player ${player.name} has no role with name ${CSMMRole.name}, skipping role assignment`); + continue; + } + + if (!takaroRole.system) { + log.info(`Assigning role ${takaroRole.name} to player ${player.name}`); + await playerService.assignRole(takaroRole.id, pog[0].playerId, server.id); + } + } +} diff --git a/packages/app-api/src/workers/playerSyncWorker.ts b/packages/app-api/src/workers/playerSyncWorker.ts index 84a8102512..c8bfcc8efd 100644 --- a/packages/app-api/src/workers/playerSyncWorker.ts +++ b/packages/app-api/src/workers/playerSyncWorker.ts @@ -80,6 +80,8 @@ export async function processJob(job: Job) { }) ); + await Promise.allSettled(domainPromises); + return; } diff --git a/packages/lib-apiclient/src/generated/api.ts b/packages/lib-apiclient/src/generated/api.ts index f7489ef85e..c8203d9e2e 100644 --- a/packages/lib-apiclient/src/generated/api.ts +++ b/packages/lib-apiclient/src/generated/api.ts @@ -3345,6 +3345,51 @@ export interface IdUuidDTOAPI { */ meta: MetadataOutput; } +/** + * + * @export + * @interface ImportInputDTO + */ +export interface ImportInputDTO { + /** + * + * @type {string} + * @memberof ImportInputDTO + */ + csmmData: string; +} +/** + * + * @export + * @interface ImportOutputDTO + */ +export interface ImportOutputDTO { + /** + * + * @type {string} + * @memberof ImportOutputDTO + */ + id: string; +} +/** + * + * @export + * @interface ImportOutputDTOAPI + */ +export interface ImportOutputDTOAPI { + /** + * + * @type {ImportOutputDTO} + * @memberof ImportOutputDTOAPI + */ + data: ImportOutputDTO; + /** + * + * @type {MetadataOutput} + * @memberof ImportOutputDTOAPI + */ + meta: MetadataOutput; +} /** * * @export @@ -9850,6 +9895,39 @@ export const GameServerApiAxiosParamCreator = function (configuration?: Configur options: localVarRequestOptions, }; }, + /** + * Required permissions: `MANAGE_GAMESERVERS` + * @summary Get import + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + gameServerControllerGetImport: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('gameServerControllerGetImport', 'id', id); + const localVarPath = `/gameserver/import/{id}`.replace(`{${'id'}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication domainAuth required + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { ...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Required permissions: `READ_GAMESERVERS` * @summary Get installed modules @@ -10071,6 +10149,43 @@ export const GameServerApiAxiosParamCreator = function (configuration?: Configur options: localVarRequestOptions, }; }, + /** + * Import a gameserver from CSMM Required permissions: `MANAGE_GAMESERVERS` + * @summary Import from csmm + * @param {ImportInputDTO} [importInputDTO] ImportInputDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + gameServerControllerImportFromCSMM: async ( + importInputDTO?: ImportInputDTO, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/gameserver/import`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication domainAuth required + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { ...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers }; + localVarRequestOptions.data = serializeDataIfNeeded(importInputDTO, localVarRequestOptions, configuration); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Required permissions: `MANAGE_GAMESERVERS` * @summary Install module @@ -10636,6 +10751,20 @@ export const GameServerApiFp = function (configuration?: Configuration) { ); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Required permissions: `MANAGE_GAMESERVERS` + * @summary Get import + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async gameServerControllerGetImport( + id: string, + options?: AxiosRequestConfig + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.gameServerControllerGetImport(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Required permissions: `READ_GAMESERVERS` * @summary Get installed modules @@ -10733,6 +10862,23 @@ export const GameServerApiFp = function (configuration?: Configuration) { ); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * Import a gameserver from CSMM Required permissions: `MANAGE_GAMESERVERS` + * @summary Import from csmm + * @param {ImportInputDTO} [importInputDTO] ImportInputDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async gameServerControllerImportFromCSMM( + importInputDTO?: ImportInputDTO, + options?: AxiosRequestConfig + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.gameServerControllerImportFromCSMM( + importInputDTO, + options + ); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Required permissions: `MANAGE_GAMESERVERS` * @summary Install module @@ -11019,6 +11165,16 @@ export const GameServerApiFactory = function (configuration?: Configuration, bas .gameServerControllerExecuteCommand(id, commandExecuteInputDTO, options) .then((request) => request(axios, basePath)); }, + /** + * Required permissions: `MANAGE_GAMESERVERS` + * @summary Get import + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + gameServerControllerGetImport(id: string, options?: any): AxiosPromise { + return localVarFp.gameServerControllerGetImport(id, options).then((request) => request(axios, basePath)); + }, /** * Required permissions: `READ_GAMESERVERS` * @summary Get installed modules @@ -11099,6 +11255,21 @@ export const GameServerApiFactory = function (configuration?: Configuration, bas .gameServerControllerGiveItem(gameserverId, playerId, giveItemInputDTO, options) .then((request) => request(axios, basePath)); }, + /** + * Import a gameserver from CSMM Required permissions: `MANAGE_GAMESERVERS` + * @summary Import from csmm + * @param {ImportInputDTO} [importInputDTO] ImportInputDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + gameServerControllerImportFromCSMM( + importInputDTO?: ImportInputDTO, + options?: any + ): AxiosPromise { + return localVarFp + .gameServerControllerImportFromCSMM(importInputDTO, options) + .then((request) => request(axios, basePath)); + }, /** * Required permissions: `MANAGE_GAMESERVERS` * @summary Install module @@ -11349,6 +11520,20 @@ export class GameServerApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * Required permissions: `MANAGE_GAMESERVERS` + * @summary Get import + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GameServerApi + */ + public gameServerControllerGetImport(id: string, options?: AxiosRequestConfig) { + return GameServerApiFp(this.configuration) + .gameServerControllerGetImport(id, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * Required permissions: `READ_GAMESERVERS` * @summary Get installed modules @@ -11444,6 +11629,20 @@ export class GameServerApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * Import a gameserver from CSMM Required permissions: `MANAGE_GAMESERVERS` + * @summary Import from csmm + * @param {ImportInputDTO} [importInputDTO] ImportInputDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof GameServerApi + */ + public gameServerControllerImportFromCSMM(importInputDTO?: ImportInputDTO, options?: AxiosRequestConfig) { + return GameServerApiFp(this.configuration) + .gameServerControllerImportFromCSMM(importInputDTO, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * Required permissions: `MANAGE_GAMESERVERS` * @summary Install module diff --git a/packages/lib-components/src/components/feedback/FormError/index.tsx b/packages/lib-components/src/components/feedback/FormError/index.tsx index a007caa629..0ae220fb00 100644 --- a/packages/lib-components/src/components/feedback/FormError/index.tsx +++ b/packages/lib-components/src/components/feedback/FormError/index.tsx @@ -50,6 +50,10 @@ export const FormError: FC = ({ message, error }) => { containerRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [containerRef, message]); + if (!error && !message) { + return null; + } + // If error provided and no custom message // Try to parse error message from error object if (error && !message) { diff --git a/packages/lib-db/src/migrations/sql/20231227144315-unique-constraints-player.ts b/packages/lib-db/src/migrations/sql/20231227144315-unique-constraints-player.ts new file mode 100644 index 0000000000..29279b955f --- /dev/null +++ b/packages/lib-db/src/migrations/sql/20231227144315-unique-constraints-player.ts @@ -0,0 +1,74 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + // Delete any duplicate rows first + // Retain the record where createdAt is the oldest + await knex.raw(` + DELETE FROM "players" + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY domain, "steamId" ORDER BY "createdAt") as rnk + FROM "players" + WHERE "steamId" IS NOT NULL + ) t + WHERE t.rnk > 1 + ) + `); + + // Also for epicOnlineServicesId + await knex.raw(` + DELETE FROM "players" + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY domain, "epicOnlineServicesId" ORDER BY "createdAt") as rnk + FROM "players" + WHERE "epicOnlineServicesId" IS NOT NULL + ) t + WHERE t.rnk > 1 + ) + `); + + // And xbox + await knex.raw(` + DELETE FROM "players" + WHERE id IN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY domain, "xboxLiveId" ORDER BY "createdAt") as rnk + FROM "players" + WHERE "xboxLiveId" IS NOT NULL + ) t + WHERE t.rnk > 1 + ) + `); + + await knex.schema.alterTable('players', (table) => { + table.unique(['domain', 'steamId']); + table.unique(['domain', 'epicOnlineServicesId']); + table.unique(['domain', 'xboxLiveId']); + }); + + await knex.raw(` + ALTER TABLE players + ADD CONSTRAINT players_domain_steam_xbox_eos_unique + UNIQUE NULLS NOT DISTINCT (domain, "steamId", "xboxLiveId", "epicOnlineServicesId"); +`); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('players', (table) => { + table.dropUnique(['domain', 'steamId']); + table.dropUnique(['domain', 'epicOnlineServicesId']); + table.dropUnique(['domain', 'xboxLiveId']); + }); + + await knex.raw(` + ALTER TABLE players + DROP CONSTRAINT players_domain_steam_xbox_eos_unique; + `); +} diff --git a/packages/lib-queues/src/QueueService.ts b/packages/lib-queues/src/QueueService.ts index 774128af69..3a171e8165 100644 --- a/packages/lib-queues/src/QueueService.ts +++ b/packages/lib-queues/src/QueueService.ts @@ -6,6 +6,7 @@ import { IEventQueueData, IHookJobData, IGameServerQueueData, + ICSMMImportData, } from './dataDefinitions.js'; import { TakaroQueue } from './TakaroQueue.js'; @@ -41,6 +42,9 @@ class QueuesService { playerSync: { queue: new TakaroQueue(config.get('queues.playerSync.name')), }, + csmmImport: { + queue: new TakaroQueue(config.get('queues.csmmImport.name')), + }, }; get queues() { diff --git a/packages/lib-queues/src/config.ts b/packages/lib-queues/src/config.ts index 1c5e6b81d9..8abbe46e2f 100644 --- a/packages/lib-queues/src/config.ts +++ b/packages/lib-queues/src/config.ts @@ -31,6 +31,9 @@ export interface IQueuesConfig extends IBaseConfig { interval: number; concurrency: number; }; + csmmImport: { + name: string; + }; }; redis: { host: string; @@ -166,6 +169,14 @@ export const queuesConfigSchema = { env: 'PLAYER_SYNC_QUEUE_CONCURRENCY', }, }, + csmmImport: { + name: { + doc: 'The name of the queue to use for csmm import', + format: String, + default: 'csmmImport', + env: 'CSMM_IMPORT_QUEUE_NAME', + }, + }, }, }; diff --git a/packages/lib-queues/src/dataDefinitions.ts b/packages/lib-queues/src/dataDefinitions.ts index 0e1a1fa5ed..b6554d7859 100644 --- a/packages/lib-queues/src/dataDefinitions.ts +++ b/packages/lib-queues/src/dataDefinitions.ts @@ -68,3 +68,7 @@ export interface IConnectorQueueData extends IBaseJobData { export interface IGameServerQueueData extends IBaseJobData { gameServerId?: string; } + +export interface ICSMMImportData extends IBaseJobData { + csmmExport: Record; +} diff --git a/packages/web-main/src/Router.tsx b/packages/web-main/src/Router.tsx index 5c93f3aa47..cdfb7c6811 100644 --- a/packages/web-main/src/Router.tsx +++ b/packages/web-main/src/Router.tsx @@ -41,6 +41,7 @@ import Forbidden from 'pages/Forbidden'; import { LogOut } from 'pages/LogOut'; import { LogoutSuccess } from 'pages/LogoutSuccess'; import { VariablesCreate, VariablesUpdate } from 'pages/variables/VariableCreateAndUpdate'; +import { ImportGameServer } from 'pages/gameserver/import'; const SentryRoutes = withSentryReactRouterV6Routing(Routes); @@ -91,6 +92,7 @@ export const Router: FC = () => ( } path={PATHS.gameServers.overview()}> }> } path={PATHS.gameServers.create()} /> + } path={PATHS.gameServers.import()} /> } path={PATHS.gameServers.update(':serverId')} /> diff --git a/packages/web-main/src/components/GameServerCard/index.tsx b/packages/web-main/src/components/GameServerCard/index.tsx index 13aafc6261..c47b409112 100644 --- a/packages/web-main/src/components/GameServerCard/index.tsx +++ b/packages/web-main/src/components/GameServerCard/index.tsx @@ -98,13 +98,14 @@ export const GameServerCard: FC = ({ id, name, type, reacha interface EmptyGameServerCardProps { onClick: () => void; + title?: string; } -export const EmptyGameServerCard: FC = ({ onClick }) => { +export const EmptyGameServerCard: FC = ({ onClick, title }) => { return ( -

Gameserver

+

{title ? title : 'Gameserver'}

); }; diff --git a/packages/web-main/src/pages/GameServers.tsx b/packages/web-main/src/pages/GameServers.tsx index 6f3f66d7f0..3dd9817231 100644 --- a/packages/web-main/src/pages/GameServers.tsx +++ b/packages/web-main/src/pages/GameServers.tsx @@ -65,6 +65,9 @@ const GameServers: FC = () => { navigate(PATHS.gameServers.create())} /> + + navigate(PATHS.gameServers.import())} /> + {InfiniteScroll} {/* show editGameServer and newGameServer drawers above this listView*/} diff --git a/packages/web-main/src/pages/gameserver/import.tsx b/packages/web-main/src/pages/gameserver/import.tsx new file mode 100644 index 0000000000..9c3dc3d16d --- /dev/null +++ b/packages/web-main/src/pages/gameserver/import.tsx @@ -0,0 +1,115 @@ +import { Alert, Button, Drawer, FormError, Loading, TextAreaField } from '@takaro/lib-components'; +import { PATHS } from 'paths'; +import { FC, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useApiClient } from 'hooks/useApiClient'; + +export interface IFormInputs { + data: string; +} + +const validationSchema = z.object({ + data: z.string(), +}); + +export const ImportGameServer: FC = () => { + const [open, setOpen] = useState(true); + const [importError, setError] = useState(null); + const [jobStatus, setJobStatus] = useState(null); + const [jobId, setJobId] = useState(null); + const navigate = useNavigate(); + const api = useApiClient(); + + const fetchJobStatus = async () => { + if (!jobId) return; + const res = await api.gameserver.gameServerControllerGetImport(jobId); + setJobStatus(res.data.data); + }; + + useEffect(() => { + if (!open) { + navigate(PATHS.gameServers.overview()); + } + }, [open, navigate]); + + useEffect(() => { + fetchJobStatus(); + + const refreshInterval = setInterval(() => { + fetchJobStatus(); + }, 5000); + + return () => { + clearInterval(refreshInterval); + }; + }, [jobId]); + + const { control, handleSubmit, watch } = useForm({ + mode: 'onSubmit', + resolver: zodResolver(validationSchema), + }); + + const onSubmit: SubmitHandler = async ({ data }) => { + try { + const res = await api.gameserver.gameServerControllerImportFromCSMM({ + csmmData: data, + }); + + setJobId(res.data.data.id); + } catch (error) { + if (error instanceof Error) { + setError(error); + } else { + setError(new Error('Unknown error')); + } + } + }; + + const { data } = watch(); + + return ( + + + Create Game Server + + +
+ + + {} + {jobStatus && jobStatus.status !== 'completed' && jobStatus.status !== 'failed' && } + {jobStatus &&
{JSON.stringify(jobStatus, null, 2)}
} +
+ +