feat: Add Kits + FurnaceSplitter plugin config modules
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-22 02:19:14 -05:00
parent 500dca48a5
commit 39622de8dc
23 changed files with 2271 additions and 0 deletions

View File

@@ -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<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;
}

View File

@@ -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<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;
}

View File

@@ -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<string, any>;
}

View File

@@ -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;
}

View File

@@ -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<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View 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 { 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);
}
}

View File

@@ -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 {}

View File

@@ -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<FurnaceSplitterConfig>,
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<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 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,
);
}
}
}

View File

@@ -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<string, any>;
}

View File

@@ -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;
}

View File

@@ -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<string, any>;
@ApiPropertyOptional()
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View 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 { 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);
}
}

View File

@@ -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 {}

View File

@@ -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<KitsConfig>,
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<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 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,
);
}
}
}