From 39622de8dc39ea1db9df3263eb10a9ebfaee744c Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sun, 22 Feb 2026 02:19:14 -0500 Subject: [PATCH] feat: Add Kits + FurnaceSplitter plugin config modules DB migrations 016 (kits_configs) and 019 (furnacesplitter_configs) applied. Backend: NestJS modules with CRUD, apply-to-server, import-from-server. Frontend: Pinia stores, Vue views with config editor, router + nav wiring. Kits view: 3-tab editor (list/editor/settings), kit items with shortname/amount/skinId/container. FurnaceSplitter view: per-furnace toggles, split count, fuel multiplier settings. Co-Authored-By: Claude Opus 4.6 --- .../entities/furnacesplitter-config.entity.ts | 33 + .../src/entities/kits-config.entity.ts | 33 + .../dto/create-furnacesplitter-config.dto.ts | 19 + .../dto/import-furnacesplitter-config.dto.ts | 14 + .../dto/update-furnacesplitter-config.dto.ts | 25 + .../furnacesplitter.controller.ts | 80 ++ .../furnacesplitter/furnacesplitter.module.ts | 14 + .../furnacesplitter.service.ts | 180 ++++ .../kits/dto/create-kits-config.dto.ts | 19 + .../kits/dto/import-kits-config.dto.ts | 14 + .../kits/dto/update-kits-config.dto.ts | 25 + .../src/modules/kits/kits.controller.ts | 80 ++ backend-nest/src/modules/kits/kits.module.ts | 14 + backend-nest/src/modules/kits/kits.service.ts | 180 ++++ backend/migrations/016_kits_configs.sql | 11 + .../019_furnacesplitter_configs.sql | 11 + .../src/components/layout/DashboardLayout.vue | 6 + frontend/src/router/index.ts | 20 + frontend/src/stores/furnacesplitter.ts | 145 ++++ frontend/src/stores/kits.ts | 145 ++++ frontend/src/types/index.ts | 54 ++ .../src/views/admin/FurnaceSplitterView.vue | 371 +++++++++ frontend/src/views/admin/KitsView.vue | 778 ++++++++++++++++++ 23 files changed, 2271 insertions(+) create mode 100644 backend-nest/src/entities/furnacesplitter-config.entity.ts create mode 100644 backend-nest/src/entities/kits-config.entity.ts create mode 100644 backend-nest/src/modules/furnacesplitter/dto/create-furnacesplitter-config.dto.ts create mode 100644 backend-nest/src/modules/furnacesplitter/dto/import-furnacesplitter-config.dto.ts create mode 100644 backend-nest/src/modules/furnacesplitter/dto/update-furnacesplitter-config.dto.ts create mode 100644 backend-nest/src/modules/furnacesplitter/furnacesplitter.controller.ts create mode 100644 backend-nest/src/modules/furnacesplitter/furnacesplitter.module.ts create mode 100644 backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts create mode 100644 backend-nest/src/modules/kits/dto/create-kits-config.dto.ts create mode 100644 backend-nest/src/modules/kits/dto/import-kits-config.dto.ts create mode 100644 backend-nest/src/modules/kits/dto/update-kits-config.dto.ts create mode 100644 backend-nest/src/modules/kits/kits.controller.ts create mode 100644 backend-nest/src/modules/kits/kits.module.ts create mode 100644 backend-nest/src/modules/kits/kits.service.ts create mode 100644 backend/migrations/016_kits_configs.sql create mode 100644 backend/migrations/019_furnacesplitter_configs.sql create mode 100644 frontend/src/stores/furnacesplitter.ts create mode 100644 frontend/src/stores/kits.ts create mode 100644 frontend/src/views/admin/FurnaceSplitterView.vue create mode 100644 frontend/src/views/admin/KitsView.vue diff --git a/backend-nest/src/entities/furnacesplitter-config.entity.ts b/backend-nest/src/entities/furnacesplitter-config.entity.ts new file mode 100644 index 0000000..0be998a --- /dev/null +++ b/backend-nest/src/entities/furnacesplitter-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('furnacesplitter_configs') +export class FurnaceSplitterConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + license_id: string; + + @Column({ type: 'varchar', length: 100 }) + config_name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: () => "'{}'" }) + config_data: Record; + + @Column({ type: 'boolean', default: false }) + is_active: boolean; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + created_at: Date; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + updated_at: Date; + + @ManyToOne(() => License, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'license_id' }) + license: License; +} diff --git a/backend-nest/src/entities/kits-config.entity.ts b/backend-nest/src/entities/kits-config.entity.ts new file mode 100644 index 0000000..3d5a5e5 --- /dev/null +++ b/backend-nest/src/entities/kits-config.entity.ts @@ -0,0 +1,33 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { License } from './license.entity'; + +@Entity('kits_configs') +export class KitsConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + license_id: string; + + @Column({ type: 'varchar', length: 100 }) + config_name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: () => "'{}'" }) + config_data: Record; + + @Column({ type: 'boolean', default: false }) + is_active: boolean; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + created_at: Date; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + updated_at: Date; + + @ManyToOne(() => License, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'license_id' }) + license: License; +} diff --git a/backend-nest/src/modules/furnacesplitter/dto/create-furnacesplitter-config.dto.ts b/backend-nest/src/modules/furnacesplitter/dto/create-furnacesplitter-config.dto.ts new file mode 100644 index 0000000..b9af0ca --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/dto/create-furnacesplitter-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateFurnaceSplitterConfigDto { + @ApiProperty({ example: 'Default FurnaceSplitter' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard furnace splitter settings' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/furnacesplitter/dto/import-furnacesplitter-config.dto.ts b/backend-nest/src/modules/furnacesplitter/dto/import-furnacesplitter-config.dto.ts new file mode 100644 index 0000000..4bb61f2 --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/dto/import-furnacesplitter-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportFurnaceSplitterConfigDto { + @ApiProperty({ example: 'Server Import' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Imported from live server' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend-nest/src/modules/furnacesplitter/dto/update-furnacesplitter-config.dto.ts b/backend-nest/src/modules/furnacesplitter/dto/update-furnacesplitter-config.dto.ts new file mode 100644 index 0000000..f4d8faf --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/dto/update-furnacesplitter-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateFurnaceSplitterConfigDto { + @ApiPropertyOptional({ example: 'Updated FurnaceSplitter' }) + @IsString() + @MaxLength(100) + @IsOptional() + config_name?: string; + + @ApiPropertyOptional({ example: 'Updated description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + is_active?: boolean; +} diff --git a/backend-nest/src/modules/furnacesplitter/furnacesplitter.controller.ts b/backend-nest/src/modules/furnacesplitter/furnacesplitter.controller.ts new file mode 100644 index 0000000..12ee582 --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/furnacesplitter.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { FurnaceSplitterService } from './furnacesplitter.service'; +import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto'; +import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto'; +import { ImportFurnaceSplitterConfigDto } from './dto/import-furnacesplitter-config.dto'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; + +@ApiTags('furnacesplitter') +@ApiBearerAuth() +@Controller('furnacesplitter') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class FurnaceSplitterController { + constructor(private readonly furnaceSplitterService: FurnaceSplitterService) {} + + @Get('configs') + @RequirePermission('furnacesplitter.view') + @ApiOperation({ summary: 'List furnace splitter configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.furnaceSplitterService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('furnacesplitter.view') + @ApiOperation({ summary: 'Get full furnace splitter config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.furnaceSplitterService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('furnacesplitter.manage') + @ApiOperation({ summary: 'Create furnace splitter config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateFurnaceSplitterConfigDto) { + return this.furnaceSplitterService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('furnacesplitter.manage') + @ApiOperation({ summary: 'Update furnace splitter config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateFurnaceSplitterConfigDto, + ) { + return this.furnaceSplitterService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('furnacesplitter.manage') + @ApiOperation({ summary: 'Delete furnace splitter config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.furnaceSplitterService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('furnacesplitter.manage') + @ApiOperation({ summary: 'Deploy furnace splitter config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.furnaceSplitterService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('furnacesplitter.manage') + @ApiOperation({ summary: 'Import FurnaceSplitter.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportFurnaceSplitterConfigDto) { + return this.furnaceSplitterService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/furnacesplitter/furnacesplitter.module.ts b/backend-nest/src/modules/furnacesplitter/furnacesplitter.module.ts new file mode 100644 index 0000000..249611b --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/furnacesplitter.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FurnaceSplitterController } from './furnacesplitter.controller'; +import { FurnaceSplitterService } from './furnacesplitter.service'; +import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([FurnaceSplitterConfig])], + controllers: [FurnaceSplitterController], + providers: [FurnaceSplitterService, NatsService], + exports: [FurnaceSplitterService], +}) +export class FurnaceSplitterModule {} diff --git a/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts b/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts new file mode 100644 index 0000000..72db115 --- /dev/null +++ b/backend-nest/src/modules/furnacesplitter/furnacesplitter.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FurnaceSplitterConfig } from '../../entities/furnacesplitter-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateFurnaceSplitterConfigDto } from './dto/create-furnacesplitter-config.dto'; +import { UpdateFurnaceSplitterConfigDto } from './dto/update-furnacesplitter-config.dto'; + +@Injectable() +export class FurnaceSplitterService { + private readonly logger = new Logger(FurnaceSplitterService.name); + + constructor( + @InjectRepository(FurnaceSplitterConfig) + private readonly furnaceRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.furnaceRepo.find({ + where: { license_id: licenseId }, + select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'], + order: { created_at: 'DESC' }, + }); + return { configs }; + } + + /** Get full config with JSONB data */ + async getConfig(licenseId: string, configId: string) { + const config = await this.furnaceRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('FurnaceSplitter config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateFurnaceSplitterConfigDto) { + const config = this.furnaceRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.furnaceRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateFurnaceSplitterConfigDto) { + const config = await this.furnaceRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('FurnaceSplitter config not found'); + + if (dto.config_name !== undefined) config.config_name = dto.config_name; + if (dto.description !== undefined) config.description = dto.description; + if (dto.config_data !== undefined) config.config_data = dto.config_data; + if (dto.is_active !== undefined) config.is_active = dto.is_active; + config.updated_at = new Date(); + + const saved = await this.furnaceRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.furnaceRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('FurnaceSplitter config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.furnaceRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('FurnaceSplitter config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write FurnaceSplitter.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/FurnaceSplitter.json', + content: jsonString, + }, + 30000, + ); + + // Reload FurnaceSplitter plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload FurnaceSplitter', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.furnaceRepo.update({ license_id: licenseId }, { is_active: false }); + await this.furnaceRepo.update( + { id: configId, license_id: licenseId }, + { is_active: true, updated_at: new Date() }, + ); + + return { + success: true, + message: `Config "${config.config_name}" deployed to server`, + config_name: config.config_name, + }; + } catch (error) { + this.logger.error(`Failed to deploy furnace splitter config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy furnace splitter config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import FurnaceSplitter.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read FurnaceSplitter.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/FurnaceSplitter.json', + }, + 30000, + ); + + if (!response) { + throw new HttpException( + 'No response from agent — it may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Parse the response content as JSON + const responseData = response as Record; + let configData: Record; + + if (typeof responseData.content === 'string') { + configData = JSON.parse(responseData.content); + } else if (typeof responseData.content === 'object') { + configData = responseData.content; + } else { + throw new HttpException( + 'Unexpected response format from agent', + HttpStatus.BAD_GATEWAY, + ); + } + + // Create new furnace splitter config row + const config = this.furnaceRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.furnaceRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import furnace splitter config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import furnace splitter config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend-nest/src/modules/kits/dto/create-kits-config.dto.ts b/backend-nest/src/modules/kits/dto/create-kits-config.dto.ts new file mode 100644 index 0000000..81d2fc0 --- /dev/null +++ b/backend-nest/src/modules/kits/dto/create-kits-config.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateKitsConfigDto { + @ApiProperty({ example: 'Default Kits' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Standard kit configuration' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; +} diff --git a/backend-nest/src/modules/kits/dto/import-kits-config.dto.ts b/backend-nest/src/modules/kits/dto/import-kits-config.dto.ts new file mode 100644 index 0000000..4212c29 --- /dev/null +++ b/backend-nest/src/modules/kits/dto/import-kits-config.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportKitsConfigDto { + @ApiProperty({ example: 'Server Import' }) + @IsString() + @MaxLength(100) + config_name: string; + + @ApiPropertyOptional({ example: 'Imported from live server' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend-nest/src/modules/kits/dto/update-kits-config.dto.ts b/backend-nest/src/modules/kits/dto/update-kits-config.dto.ts new file mode 100644 index 0000000..a14df93 --- /dev/null +++ b/backend-nest/src/modules/kits/dto/update-kits-config.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateKitsConfigDto { + @ApiPropertyOptional({ example: 'Updated Kits' }) + @IsString() + @MaxLength(100) + @IsOptional() + config_name?: string; + + @ApiPropertyOptional({ example: 'Updated description' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional() + @IsObject() + @IsOptional() + config_data?: Record; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + is_active?: boolean; +} diff --git a/backend-nest/src/modules/kits/kits.controller.ts b/backend-nest/src/modules/kits/kits.controller.ts new file mode 100644 index 0000000..3860142 --- /dev/null +++ b/backend-nest/src/modules/kits/kits.controller.ts @@ -0,0 +1,80 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { KitsService } from './kits.service'; +import { CreateKitsConfigDto } from './dto/create-kits-config.dto'; +import { UpdateKitsConfigDto } from './dto/update-kits-config.dto'; +import { ImportKitsConfigDto } from './dto/import-kits-config.dto'; +import { CurrentTenant } from '../../common/decorators/current-tenant.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; + +@ApiTags('kits') +@ApiBearerAuth() +@Controller('kits') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class KitsController { + constructor(private readonly kitsService: KitsService) {} + + @Get('configs') + @RequirePermission('kits.view') + @ApiOperation({ summary: 'List kits configs (summaries)' }) + getConfigs(@CurrentTenant() licenseId: string) { + return this.kitsService.getConfigs(licenseId); + } + + @Get('configs/:id') + @RequirePermission('kits.view') + @ApiOperation({ summary: 'Get full kits config with data' }) + getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.kitsService.getConfig(licenseId, id); + } + + @Post('configs') + @RequirePermission('kits.manage') + @ApiOperation({ summary: 'Create kits config' }) + createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateKitsConfigDto) { + return this.kitsService.createConfig(licenseId, dto); + } + + @Put('configs/:id') + @RequirePermission('kits.manage') + @ApiOperation({ summary: 'Update kits config' }) + updateConfig( + @CurrentTenant() licenseId: string, + @Param('id') id: string, + @Body() dto: UpdateKitsConfigDto, + ) { + return this.kitsService.updateConfig(licenseId, id, dto); + } + + @Delete('configs/:id') + @RequirePermission('kits.manage') + @ApiOperation({ summary: 'Delete kits config' }) + deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.kitsService.deleteConfig(licenseId, id); + } + + @Post('configs/:id/apply') + @RequirePermission('kits.manage') + @ApiOperation({ summary: 'Deploy kits config to server' }) + applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) { + return this.kitsService.applyToServer(licenseId, id); + } + + @Post('import-from-server') + @RequirePermission('kits.manage') + @ApiOperation({ summary: 'Import Kits.json from server via NATS' }) + importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportKitsConfigDto) { + return this.kitsService.importFromServer(licenseId, dto.config_name, dto.description); + } +} diff --git a/backend-nest/src/modules/kits/kits.module.ts b/backend-nest/src/modules/kits/kits.module.ts new file mode 100644 index 0000000..3cadd38 --- /dev/null +++ b/backend-nest/src/modules/kits/kits.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { KitsController } from './kits.controller'; +import { KitsService } from './kits.service'; +import { KitsConfig } from '../../entities/kits-config.entity'; +import { NatsService } from '../../services/nats.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([KitsConfig])], + controllers: [KitsController], + providers: [KitsService, NatsService], + exports: [KitsService], +}) +export class KitsModule {} diff --git a/backend-nest/src/modules/kits/kits.service.ts b/backend-nest/src/modules/kits/kits.service.ts new file mode 100644 index 0000000..69e60c6 --- /dev/null +++ b/backend-nest/src/modules/kits/kits.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { KitsConfig } from '../../entities/kits-config.entity'; +import { NatsService } from '../../services/nats.service'; +import { CreateKitsConfigDto } from './dto/create-kits-config.dto'; +import { UpdateKitsConfigDto } from './dto/update-kits-config.dto'; + +@Injectable() +export class KitsService { + private readonly logger = new Logger(KitsService.name); + + constructor( + @InjectRepository(KitsConfig) + private readonly kitsRepo: Repository, + private readonly natsService: NatsService, + ) {} + + /** List configs for a license (summaries — no JSONB) */ + async getConfigs(licenseId: string) { + const configs = await this.kitsRepo.find({ + where: { license_id: licenseId }, + select: ['id', 'config_name', 'description', 'is_active', 'created_at', 'updated_at'], + order: { created_at: 'DESC' }, + }); + return { configs }; + } + + /** Get full config with JSONB data */ + async getConfig(licenseId: string, configId: string) { + const config = await this.kitsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Kits config not found'); + return { config }; + } + + /** Create a new config */ + async createConfig(licenseId: string, dto: CreateKitsConfigDto) { + const config = this.kitsRepo.create({ + license_id: licenseId, + config_name: dto.config_name, + description: dto.description || null, + config_data: dto.config_data || {}, + }); + const saved = await this.kitsRepo.save(config); + return { config: saved }; + } + + /** Update an existing config */ + async updateConfig(licenseId: string, configId: string, dto: UpdateKitsConfigDto) { + const config = await this.kitsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Kits config not found'); + + if (dto.config_name !== undefined) config.config_name = dto.config_name; + if (dto.description !== undefined) config.description = dto.description; + if (dto.config_data !== undefined) config.config_data = dto.config_data; + if (dto.is_active !== undefined) config.is_active = dto.is_active; + config.updated_at = new Date(); + + const saved = await this.kitsRepo.save(config); + return { config: saved }; + } + + /** Delete a config */ + async deleteConfig(licenseId: string, configId: string) { + const result = await this.kitsRepo.delete({ id: configId, license_id: licenseId }); + if (result.affected === 0) throw new NotFoundException('Kits config not found'); + return { deleted: true }; + } + + /** Deploy config to game server via NATS */ + async applyToServer(licenseId: string, configId: string) { + const config = await this.kitsRepo.findOne({ + where: { id: configId, license_id: licenseId }, + }); + if (!config) throw new NotFoundException('Kits config not found'); + + const jsonString = JSON.stringify(config.config_data, null, 2); + + try { + // Write Kits.json via file manager NATS + await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_save', + path: 'server://oxide/config/Kits.json', + content: jsonString, + }, + 30000, + ); + + // Reload Kits plugin via RCON + await this.natsService.publish( + `corrosion.${licenseId}.cmd.server`, + { + action: 'command', + command: 'oxide.reload Kits', + timestamp: new Date().toISOString(), + }, + ); + + // Mark this config as active, deactivate others + await this.kitsRepo.update({ license_id: licenseId }, { is_active: false }); + await this.kitsRepo.update( + { id: configId, license_id: licenseId }, + { is_active: true, updated_at: new Date() }, + ); + + return { + success: true, + message: `Config "${config.config_name}" deployed to server`, + config_name: config.config_name, + }; + } catch (error) { + this.logger.error(`Failed to deploy kits config: ${(error as Error).message}`); + throw new HttpException( + 'Failed to deploy kits config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } + + /** Import Kits.json from game server via NATS */ + async importFromServer(licenseId: string, configName: string, description?: string) { + try { + // Read Kits.json from server via file manager NATS + const response = await this.natsService.request( + `corrosion.${licenseId}.files.cmd`, + { + func: 'fm_preview', + path: 'server://oxide/config/Kits.json', + }, + 30000, + ); + + if (!response) { + throw new HttpException( + 'No response from agent — it may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + // Parse the response content as JSON + const responseData = response as Record; + let configData: Record; + + if (typeof responseData.content === 'string') { + configData = JSON.parse(responseData.content); + } else if (typeof responseData.content === 'object') { + configData = responseData.content; + } else { + throw new HttpException( + 'Unexpected response format from agent', + HttpStatus.BAD_GATEWAY, + ); + } + + // Create new kits config row + const config = this.kitsRepo.create({ + license_id: licenseId, + config_name: configName, + description: description || 'Imported from server', + config_data: configData, + }); + const saved = await this.kitsRepo.save(config); + + return { config: saved }; + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error(`Failed to import kits config from server: ${(error as Error).message}`); + throw new HttpException( + 'Failed to import kits config — agent may be offline', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + } +} diff --git a/backend/migrations/016_kits_configs.sql b/backend/migrations/016_kits_configs.sql new file mode 100644 index 0000000..47030f5 --- /dev/null +++ b/backend/migrations/016_kits_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS kits_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + config_name VARCHAR(100) NOT NULL, + description TEXT, + config_data JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_kits_configs_license ON kits_configs(license_id); diff --git a/backend/migrations/019_furnacesplitter_configs.sql b/backend/migrations/019_furnacesplitter_configs.sql new file mode 100644 index 0000000..5f74005 --- /dev/null +++ b/backend/migrations/019_furnacesplitter_configs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS furnacesplitter_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + config_name VARCHAR(100) NOT NULL, + description TEXT, + config_data JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_furnacesplitter_configs_license ON furnacesplitter_configs(license_id); diff --git a/frontend/src/components/layout/DashboardLayout.vue b/frontend/src/components/layout/DashboardLayout.vue index 7107d8f..d5e04a1 100644 --- a/frontend/src/components/layout/DashboardLayout.vue +++ b/frontend/src/components/layout/DashboardLayout.vue @@ -31,6 +31,8 @@ import { Navigation2, Pickaxe, DoorOpen, + Gift, + Flame, Menu, X, } from 'lucide-vue-next' @@ -52,6 +54,10 @@ const navItems = [ { name: 'Teleport Config', path: '/teleport-config', icon: Navigation2, permission: 'teleport.view' }, { name: 'Gather Rates', path: '/gather-manager', icon: Pickaxe, permission: 'gather.view' }, { name: 'Auto Doors', path: '/autodoors', icon: DoorOpen, permission: 'autodoors.view' }, + { name: 'Kits', path: '/kits', icon: Gift, permission: 'kits.view' }, + { name: 'Furnace Splitter', path: '/furnace-splitter', icon: Flame, permission: 'furnacesplitter.view' }, + { name: 'Better Chat', path: '/better-chat', icon: MessageSquare, permission: 'betterchat.view' }, + { name: 'Timed Execute', path: '/timed-execute', icon: Clock, permission: 'timedexecute.view' }, { name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' }, { name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' }, { name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8281d9e..6195bcc 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -130,6 +130,26 @@ const panelRoutes: RouteRecordRaw[] = [ name: 'autodoors', component: () => import('@/views/admin/AutoDoorsView.vue'), }, + { + path: 'kits', + name: 'kits-config', + component: () => import('@/views/admin/KitsView.vue'), + }, + { + path: 'furnace-splitter', + name: 'furnace-splitter', + component: () => import('@/views/admin/FurnaceSplitterView.vue'), + }, + { + path: 'better-chat', + name: 'better-chat', + component: () => import('@/views/admin/BetterChatView.vue'), + }, + { + path: 'timed-execute', + name: 'timed-execute', + component: () => import('@/views/admin/TimedExecuteView.vue'), + }, { path: 'wipes', name: 'wipes', diff --git a/frontend/src/stores/furnacesplitter.ts b/frontend/src/stores/furnacesplitter.ts new file mode 100644 index 0000000..b9856ce --- /dev/null +++ b/frontend/src/stores/furnacesplitter.ts @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '@/composables/useApi' +import { useToastStore } from '@/stores/toast' +import type { FurnaceSplitterConfigSummary, FurnaceSplitterConfigFull, FurnaceSplitterApplyResult } from '@/types' + +export const useFurnaceSplitterStore = defineStore('furnacesplitter', () => { + const configs = ref([]) + const currentConfig = ref(null) + const isLoading = ref(false) + const isSaving = ref(false) + const isApplying = ref(false) + const isDirty = ref(false) + const api = useApi() + const toast = useToastStore() + + async function fetchConfigs() { + isLoading.value = true + try { + const res = await api.get<{ configs: FurnaceSplitterConfigSummary[] }>('/furnacesplitter/configs') + configs.value = res.configs + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function loadConfig(id: string) { + isLoading.value = true + try { + const res = await api.get<{ config: FurnaceSplitterConfigFull }>(`/furnacesplitter/configs/${id}`) + currentConfig.value = res.config + isDirty.value = false + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function createConfig(name: string, description?: string) { + try { + const res = await api.post<{ config: FurnaceSplitterConfigFull }>('/furnacesplitter/configs', { + config_name: name, + description, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config "${name}" created`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } + } + + async function saveCurrentConfig() { + if (!currentConfig.value) return + isSaving.value = true + try { + await api.put(`/furnacesplitter/configs/${currentConfig.value.id}`, { + config_name: currentConfig.value.config_name, + description: currentConfig.value.description, + config_data: currentConfig.value.config_data, + }) + isDirty.value = false + await fetchConfigs() + toast.success('Config saved') + } catch (err) { + toast.error((err as Error).message) + } finally { + isSaving.value = false + } + } + + async function deleteConfig(id: string) { + try { + await api.del(`/furnacesplitter/configs/${id}`) + if (currentConfig.value?.id === id) { + currentConfig.value = null + } + await fetchConfigs() + toast.success('Config deleted') + } catch (err) { + toast.error((err as Error).message) + } + } + + async function applyToServer(id: string) { + isApplying.value = true + try { + const res = await api.post(`/furnacesplitter/configs/${id}/apply`) + await fetchConfigs() + toast.success(res.message) + return res + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isApplying.value = false + } + } + + async function importFromServer(configName: string) { + isLoading.value = true + try { + const res = await api.post<{ config: FurnaceSplitterConfigFull }>('/furnacesplitter/import-from-server', { + config_name: configName, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config imported from server as "${configName}"`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isLoading.value = false + } + } + + function markDirty() { + isDirty.value = true + } + + return { + configs, + currentConfig, + isLoading, + isSaving, + isApplying, + isDirty, + fetchConfigs, + loadConfig, + createConfig, + saveCurrentConfig, + deleteConfig, + applyToServer, + importFromServer, + markDirty, + } +}) diff --git a/frontend/src/stores/kits.ts b/frontend/src/stores/kits.ts new file mode 100644 index 0000000..fc33d59 --- /dev/null +++ b/frontend/src/stores/kits.ts @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '@/composables/useApi' +import { useToastStore } from '@/stores/toast' +import type { KitsConfigSummary, KitsConfigFull, KitsApplyResult } from '@/types' + +export const useKitsStore = defineStore('kits', () => { + const configs = ref([]) + const currentConfig = ref(null) + const isLoading = ref(false) + const isSaving = ref(false) + const isApplying = ref(false) + const isDirty = ref(false) + const api = useApi() + const toast = useToastStore() + + async function fetchConfigs() { + isLoading.value = true + try { + const res = await api.get<{ configs: KitsConfigSummary[] }>('/kits/configs') + configs.value = res.configs + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function loadConfig(id: string) { + isLoading.value = true + try { + const res = await api.get<{ config: KitsConfigFull }>(`/kits/configs/${id}`) + currentConfig.value = res.config + isDirty.value = false + } catch (err) { + toast.error((err as Error).message) + } finally { + isLoading.value = false + } + } + + async function createConfig(name: string, description?: string) { + try { + const res = await api.post<{ config: KitsConfigFull }>('/kits/configs', { + config_name: name, + description, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config "${name}" created`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } + } + + async function saveCurrentConfig() { + if (!currentConfig.value) return + isSaving.value = true + try { + await api.put(`/kits/configs/${currentConfig.value.id}`, { + config_name: currentConfig.value.config_name, + description: currentConfig.value.description, + config_data: currentConfig.value.config_data, + }) + isDirty.value = false + await fetchConfigs() + toast.success('Config saved') + } catch (err) { + toast.error((err as Error).message) + } finally { + isSaving.value = false + } + } + + async function deleteConfig(id: string) { + try { + await api.del(`/kits/configs/${id}`) + if (currentConfig.value?.id === id) { + currentConfig.value = null + } + await fetchConfigs() + toast.success('Config deleted') + } catch (err) { + toast.error((err as Error).message) + } + } + + async function applyToServer(id: string) { + isApplying.value = true + try { + const res = await api.post(`/kits/configs/${id}/apply`) + await fetchConfigs() + toast.success(res.message) + return res + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isApplying.value = false + } + } + + async function importFromServer(configName: string) { + isLoading.value = true + try { + const res = await api.post<{ config: KitsConfigFull }>('/kits/import-from-server', { + config_name: configName, + }) + await fetchConfigs() + currentConfig.value = res.config + isDirty.value = false + toast.success(`Config imported from server as "${configName}"`) + return res.config + } catch (err) { + toast.error((err as Error).message) + return null + } finally { + isLoading.value = false + } + } + + function markDirty() { + isDirty.value = true + } + + return { + configs, + currentConfig, + isLoading, + isSaving, + isApplying, + isDirty, + fetchConfigs, + loadConfig, + createConfig, + saveCurrentConfig, + deleteConfig, + applyToServer, + importFromServer, + markDirty, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d58b988..5912cb6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -654,3 +654,57 @@ export interface FurnaceSplitterApplyResult { message: string config_name: string } + +// BetterChat Config types +export interface BetterChatConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface BetterChatConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface BetterChatApplyResult { + success: boolean + message: string + config_name: string +} + +// TimedExecute Config types +export interface TimedExecuteConfigSummary { + id: string + config_name: string + description: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface TimedExecuteConfigFull { + id: string + license_id: string + config_name: string + description: string | null + config_data: Record + is_active: boolean + created_at: string + updated_at: string +} + +export interface TimedExecuteApplyResult { + success: boolean + message: string + config_name: string +} diff --git a/frontend/src/views/admin/FurnaceSplitterView.vue b/frontend/src/views/admin/FurnaceSplitterView.vue new file mode 100644 index 0000000..9b70d76 --- /dev/null +++ b/frontend/src/views/admin/FurnaceSplitterView.vue @@ -0,0 +1,371 @@ + + +