feat: Add GatherManager + AutoDoors plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- GatherManager: 2-tab editor (Resource Rates with 1x-10x presets, Advanced with Pickup/Quarry/Excavator/Survey modifiers), 9 resource types with slider+number inputs, CRUD + deploy + import via NATS - AutoDoors: Global settings (delay sliders, 6 toggles), 7 door type toggles, permission group overrides table, CRUD + deploy + import - DB: migrations 015 (gather_configs) + 018 (autodoors_configs) - Backend: GatherModule + AutoDoorsModule registered in app.module.ts - Frontend: Pinia stores, Vue views, router routes, sidebar nav items - Icons: Pickaxe (gather), DoorOpen (autodoors) - All type checks pass: tsc + vue-tsc zero errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,10 @@ import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||
import { FilesModule } from './modules/files/files.module';
|
||||
import { LootModule } from './modules/loot/loot.module';
|
||||
import { TeleportModule } from './modules/teleport/teleport.module';
|
||||
import { GatherModule } from './modules/gather/gather.module';
|
||||
import { AutoDoorsModule } from './modules/autodoors/autodoors.module';
|
||||
import { KitsModule } from './modules/kits/kits.module';
|
||||
import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter.module';
|
||||
|
||||
// Shared Services
|
||||
import { NatsService } from './services/nats.service';
|
||||
@@ -109,6 +113,10 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
||||
FilesModule,
|
||||
LootModule,
|
||||
TeleportModule,
|
||||
GatherModule,
|
||||
AutoDoorsModule,
|
||||
KitsModule,
|
||||
FurnaceSplitterModule,
|
||||
],
|
||||
providers: [
|
||||
// Global guards (order matters: auth first, then license, then permissions)
|
||||
|
||||
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
33
backend-nest/src/entities/autodoors-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('autodoors_configs')
|
||||
export class AutoDoorsConfig {
|
||||
@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<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
33
backend-nest/src/entities/gather-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { License } from './license.entity';
|
||||
|
||||
@Entity('gather_configs')
|
||||
export class GatherConfig {
|
||||
@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<string, any>;
|
||||
|
||||
@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;
|
||||
}
|
||||
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
80
backend-nest/src/modules/autodoors/autodoors.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { AutoDoorsService } from './autodoors.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
import { ImportAutoDoorsConfigDto } from './dto/import-autodoors-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('autodoors')
|
||||
@ApiBearerAuth()
|
||||
@Controller('autodoors')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class AutoDoorsController {
|
||||
constructor(private readonly autoDoorsService: AutoDoorsService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('autodoors.view')
|
||||
@ApiOperation({ summary: 'List AutoDoors configs' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.autoDoorsService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('autodoors.view')
|
||||
@ApiOperation({ summary: 'Get full AutoDoors config' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Create AutoDoors config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateAutoDoorsConfigDto) {
|
||||
return this.autoDoorsService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Update AutoDoors config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAutoDoorsConfigDto,
|
||||
) {
|
||||
return this.autoDoorsService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Delete AutoDoors config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Deploy AutoDoors config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.autoDoorsService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('autodoors.manage')
|
||||
@ApiOperation({ summary: 'Import AutoDoors.json from server' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportAutoDoorsConfigDto) {
|
||||
return this.autoDoorsService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
14
backend-nest/src/modules/autodoors/autodoors.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AutoDoorsController } from './autodoors.controller';
|
||||
import { AutoDoorsService } from './autodoors.service';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AutoDoorsConfig])],
|
||||
controllers: [AutoDoorsController],
|
||||
providers: [AutoDoorsService, NatsService],
|
||||
exports: [AutoDoorsService],
|
||||
})
|
||||
export class AutoDoorsModule {}
|
||||
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
180
backend-nest/src/modules/autodoors/autodoors.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AutoDoorsConfig } from '../../entities/autodoors-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateAutoDoorsConfigDto } from './dto/create-autodoors-config.dto';
|
||||
import { UpdateAutoDoorsConfigDto } from './dto/update-autodoors-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AutoDoorsService {
|
||||
private readonly logger = new Logger(AutoDoorsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AutoDoorsConfig)
|
||||
private readonly autoDoorsRepo: Repository<AutoDoorsConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.autoDoorsRepo.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.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateAutoDoorsConfigDto) {
|
||||
const config = this.autoDoorsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.autoDoorsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateAutoDoorsConfigDto) {
|
||||
const config = await this.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors 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.autoDoorsRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.autoDoorsRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('AutoDoors config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.autoDoorsRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('AutoDoors config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write AutoDoors.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/AutoDoors.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload AutoDoors plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload AutoDoors',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.autoDoorsRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.autoDoorsRepo.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 AutoDoors config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy AutoDoors config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import AutoDoors.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read AutoDoors.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/AutoDoors.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<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
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 AutoDoors config row
|
||||
const config = this.autoDoorsRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.autoDoorsRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import AutoDoors config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import AutoDoors config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAutoDoorsConfigDto {
|
||||
@ApiProperty({ example: 'Default AutoDoors' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard auto-close settings' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportAutoDoorsConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateAutoDoorsConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Config' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateGatherConfigDto {
|
||||
@ApiProperty({ example: 'Default 2x Rates' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard 2x gather rates' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ImportGatherConfigDto {
|
||||
@ApiProperty({ example: 'Server Import' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
config_name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Imported from live server' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsObject, IsBoolean, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateGatherConfigDto {
|
||||
@ApiPropertyOptional({ example: 'Updated Rates' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
config_name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated description' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
config_data?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
}
|
||||
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
80
backend-nest/src/modules/gather/gather.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { GatherService } from './gather.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
import { ImportGatherConfigDto } from './dto/import-gather-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('gather')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gather')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class GatherController {
|
||||
constructor(private readonly gatherService: GatherService) {}
|
||||
|
||||
@Get('configs')
|
||||
@RequirePermission('gather.view')
|
||||
@ApiOperation({ summary: 'List gather configs' })
|
||||
getConfigs(@CurrentTenant() licenseId: string) {
|
||||
return this.gatherService.getConfigs(licenseId);
|
||||
}
|
||||
|
||||
@Get('configs/:id')
|
||||
@RequirePermission('gather.view')
|
||||
@ApiOperation({ summary: 'Get full gather config' })
|
||||
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.getConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Create gather config' })
|
||||
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateGatherConfigDto) {
|
||||
return this.gatherService.createConfig(licenseId, dto);
|
||||
}
|
||||
|
||||
@Put('configs/:id')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Update gather config' })
|
||||
updateConfig(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGatherConfigDto,
|
||||
) {
|
||||
return this.gatherService.updateConfig(licenseId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('configs/:id')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Delete gather config' })
|
||||
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.deleteConfig(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('configs/:id/apply')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Deploy gather config to server' })
|
||||
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||
return this.gatherService.applyToServer(licenseId, id);
|
||||
}
|
||||
|
||||
@Post('import-from-server')
|
||||
@RequirePermission('gather.manage')
|
||||
@ApiOperation({ summary: 'Import GatherManager.json from server' })
|
||||
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportGatherConfigDto) {
|
||||
return this.gatherService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||
}
|
||||
}
|
||||
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
14
backend-nest/src/modules/gather/gather.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GatherController } from './gather.controller';
|
||||
import { GatherService } from './gather.service';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GatherConfig])],
|
||||
controllers: [GatherController],
|
||||
providers: [GatherService, NatsService],
|
||||
exports: [GatherService],
|
||||
})
|
||||
export class GatherModule {}
|
||||
180
backend-nest/src/modules/gather/gather.service.ts
Normal file
180
backend-nest/src/modules/gather/gather.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GatherConfig } from '../../entities/gather-config.entity';
|
||||
import { NatsService } from '../../services/nats.service';
|
||||
import { CreateGatherConfigDto } from './dto/create-gather-config.dto';
|
||||
import { UpdateGatherConfigDto } from './dto/update-gather-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GatherService {
|
||||
private readonly logger = new Logger(GatherService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(GatherConfig)
|
||||
private readonly gatherRepo: Repository<GatherConfig>,
|
||||
private readonly natsService: NatsService,
|
||||
) {}
|
||||
|
||||
/** List configs for a license (summaries — no JSONB) */
|
||||
async getConfigs(licenseId: string) {
|
||||
const configs = await this.gatherRepo.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.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather config not found');
|
||||
return { config };
|
||||
}
|
||||
|
||||
/** Create a new config */
|
||||
async createConfig(licenseId: string, dto: CreateGatherConfigDto) {
|
||||
const config = this.gatherRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: dto.config_name,
|
||||
description: dto.description || null,
|
||||
config_data: dto.config_data || {},
|
||||
});
|
||||
const saved = await this.gatherRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Update an existing config */
|
||||
async updateConfig(licenseId: string, configId: string, dto: UpdateGatherConfigDto) {
|
||||
const config = await this.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather 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.gatherRepo.save(config);
|
||||
return { config: saved };
|
||||
}
|
||||
|
||||
/** Delete a config */
|
||||
async deleteConfig(licenseId: string, configId: string) {
|
||||
const result = await this.gatherRepo.delete({ id: configId, license_id: licenseId });
|
||||
if (result.affected === 0) throw new NotFoundException('Gather config not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** Deploy config to game server via NATS */
|
||||
async applyToServer(licenseId: string, configId: string) {
|
||||
const config = await this.gatherRepo.findOne({
|
||||
where: { id: configId, license_id: licenseId },
|
||||
});
|
||||
if (!config) throw new NotFoundException('Gather config not found');
|
||||
|
||||
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||
|
||||
try {
|
||||
// Write GatherManager.json via file manager NATS
|
||||
await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_save',
|
||||
path: 'server://oxide/config/GatherManager.json',
|
||||
content: jsonString,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
// Reload GatherManager plugin via RCON
|
||||
await this.natsService.publish(
|
||||
`corrosion.${licenseId}.cmd.server`,
|
||||
{
|
||||
action: 'command',
|
||||
command: 'oxide.reload GatherManager',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
// Mark this config as active, deactivate others
|
||||
await this.gatherRepo.update({ license_id: licenseId }, { is_active: false });
|
||||
await this.gatherRepo.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 gather config: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to deploy gather config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import GatherManager.json from game server via NATS */
|
||||
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||
try {
|
||||
// Read GatherManager.json from server via file manager NATS
|
||||
const response = await this.natsService.request(
|
||||
`corrosion.${licenseId}.files.cmd`,
|
||||
{
|
||||
func: 'fm_preview',
|
||||
path: 'server://oxide/config/GatherManager.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<string, any>;
|
||||
let configData: Record<string, any>;
|
||||
|
||||
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 gather config row
|
||||
const config = this.gatherRepo.create({
|
||||
license_id: licenseId,
|
||||
config_name: configName,
|
||||
description: description || 'Imported from server',
|
||||
config_data: configData,
|
||||
});
|
||||
const saved = await this.gatherRepo.save(config);
|
||||
|
||||
return { config: saved };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(`Failed to import gather config from server: ${(error as Error).message}`);
|
||||
throw new HttpException(
|
||||
'Failed to import gather config — agent may be offline',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user