Compare commits
16 Commits
v1.0.7
...
ef128b47d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef128b47d2 | ||
|
|
1bb810f851 | ||
|
|
b4d1bc8dd0 | ||
|
|
d15ea28e8f | ||
|
|
7d5966839a | ||
|
|
2668014068 | ||
|
|
bb381569e3 | ||
|
|
39622de8dc | ||
|
|
500dca48a5 | ||
|
|
b542f30dcf | ||
|
|
6461417b50 | ||
|
|
380ab2700c | ||
|
|
585e8aa3f7 | ||
|
|
4d087132db | ||
|
|
16f378eada | ||
|
|
3e1af29b38 |
@@ -423,3 +423,11 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
|
16. **Response shape mismatches are silent killers.** The frontend destructures `data.config` and the backend returns the raw entity — no error thrown, no 500, just `undefined` propagating through the template until Vue hits `Cannot read properties of undefined`. The fix is trivial (wrap in `{ config }`), but finding it requires knowing what the frontend expects. Document the contract.
|
||||||
|
|
||||||
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
|
17. **Tools that close the feedback loop are worth 10x their cost.** The debugging bottleneck was never the fix — it was the round-trip of push → rebuild → check → paste → interpret → fix. Playwright and Postgres MCP don't make you smarter, they make you faster. And faster means more iterations, which means better outcomes.
|
||||||
|
|
||||||
|
18. **When aggregating across N similar modules, scout for the one that doesn't match the pattern — it's always the oldest or the first-built.** The Loot module was the first plugin config module built, so it uses `fetchProfiles()`/`profiles` while the other 8 use `fetchConfigs()`/`configs`. The first implementation defines its own naming before a convention exists. Every aggregation layer (landing pages, batch operations, monitoring dashboards) will hit this drift. A 30-second recon across all N modules before writing the aggregator prevents a mid-implementation refactor.
|
||||||
|
|
||||||
|
19. **UI scaling problems are invisible when you're adding one item at a time — they only become obvious in aggregate.** Nine plugin config sidebar entries were added across multiple sessions, each one reasonable in isolation. Nobody noticed the sidebar was becoming unusable until all nine were there. When building a repeatable pattern (nav items, config modules, API endpoints), build the aggregation layer early — ideally when N hits 3 or 4 — not after it's already painful.
|
||||||
|
|
||||||
|
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||||
|
|
||||||
|
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ import { MigrationModule } from './modules/migration/migration.module';
|
|||||||
import { ChangelogModule } from './modules/changelog/changelog.module';
|
import { ChangelogModule } from './modules/changelog/changelog.module';
|
||||||
import { FilesModule } from './modules/files/files.module';
|
import { FilesModule } from './modules/files/files.module';
|
||||||
import { LootModule } from './modules/loot/loot.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';
|
||||||
|
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||||
|
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||||
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -107,6 +115,14 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
LootModule,
|
LootModule,
|
||||||
|
TeleportModule,
|
||||||
|
GatherModule,
|
||||||
|
AutoDoorsModule,
|
||||||
|
KitsModule,
|
||||||
|
FurnaceSplitterModule,
|
||||||
|
BetterChatModule,
|
||||||
|
TimedExecuteModule,
|
||||||
|
RaidableBasesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// 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/betterchat-config.entity.ts
Normal file
33
backend-nest/src/entities/betterchat-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('betterchat_configs')
|
||||||
|
export class BetterChatConfig {
|
||||||
|
@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/furnacesplitter-config.entity.ts
Normal file
33
backend-nest/src/entities/furnacesplitter-config.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/kits-config.entity.ts
Normal file
33
backend-nest/src/entities/kits-config.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
33
backend-nest/src/entities/raidablebases-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('raidablebases_configs')
|
||||||
|
export class RaidableBasesConfig {
|
||||||
|
@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/teleport-config.entity.ts
Normal file
33
backend-nest/src/entities/teleport-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('teleport_configs')
|
||||||
|
export class TeleportConfig {
|
||||||
|
@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/timedexecute-config.entity.ts
Normal file
33
backend-nest/src/entities/timedexecute-config.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { License } from './license.entity';
|
||||||
|
|
||||||
|
@Entity('timedexecute_configs')
|
||||||
|
export class TimedExecuteConfig {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/betterchat/betterchat.controller.ts
Normal file
80
backend-nest/src/modules/betterchat/betterchat.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 { BetterChatService } from './betterchat.service';
|
||||||
|
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||||
|
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||||
|
import { ImportBetterChatConfigDto } from './dto/import-betterchat-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('betterchat')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('betterchat')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class BetterChatController {
|
||||||
|
constructor(private readonly betterChatService: BetterChatService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('betterchat.view')
|
||||||
|
@ApiOperation({ summary: 'List BetterChat configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.betterChatService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('betterchat.view')
|
||||||
|
@ApiOperation({ summary: 'Get full BetterChat config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Create BetterChat config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateBetterChatConfigDto) {
|
||||||
|
return this.betterChatService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Update BetterChat config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateBetterChatConfigDto,
|
||||||
|
) {
|
||||||
|
return this.betterChatService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete BetterChat config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy BetterChat config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.betterChatService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('betterchat.manage')
|
||||||
|
@ApiOperation({ summary: 'Import BetterChat.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportBetterChatConfigDto) {
|
||||||
|
return this.betterChatService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
14
backend-nest/src/modules/betterchat/betterchat.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BetterChatController } from './betterchat.controller';
|
||||||
|
import { BetterChatService } from './betterchat.service';
|
||||||
|
import { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([BetterChatConfig])],
|
||||||
|
controllers: [BetterChatController],
|
||||||
|
providers: [BetterChatService, NatsService],
|
||||||
|
exports: [BetterChatService],
|
||||||
|
})
|
||||||
|
export class BetterChatModule {}
|
||||||
180
backend-nest/src/modules/betterchat/betterchat.service.ts
Normal file
180
backend-nest/src/modules/betterchat/betterchat.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 { BetterChatConfig } from '../../entities/betterchat-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { CreateBetterChatConfigDto } from './dto/create-betterchat-config.dto';
|
||||||
|
import { UpdateBetterChatConfigDto } from './dto/update-betterchat-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BetterChatService {
|
||||||
|
private readonly logger = new Logger(BetterChatService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BetterChatConfig)
|
||||||
|
private readonly repo: Repository<BetterChatConfig>,
|
||||||
|
private readonly natsService: NatsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.repo.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.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateBetterChatConfigDto) {
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateBetterChatConfigDto) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat 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.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.repo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('BetterChat config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('BetterChat config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write BetterChat.json via file manager NATS
|
||||||
|
await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_save',
|
||||||
|
path: 'server://oxide/config/BetterChat.json',
|
||||||
|
content: jsonString,
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload BetterChat plugin via RCON
|
||||||
|
await this.natsService.publish(
|
||||||
|
`corrosion.${licenseId}.cmd.server`,
|
||||||
|
{
|
||||||
|
action: 'command',
|
||||||
|
command: 'oxide.reload BetterChat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.repo.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 BetterChat config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy BetterChat config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import BetterChat.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read BetterChat.json from server via file manager NATS
|
||||||
|
const response = await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_preview',
|
||||||
|
path: 'server://oxide/config/BetterChat.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 config row
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import BetterChat config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import BetterChat 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 CreateBetterChatConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default Chat Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard BetterChat 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 ImportBetterChatConfigDto {
|
||||||
|
@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 UpdateBetterChatConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Chat 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 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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal file
19
backend-nest/src/modules/kits/dto/create-kits-config.dto.ts
Normal 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>;
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal file
14
backend-nest/src/modules/kits/dto/import-kits-config.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal file
25
backend-nest/src/modules/kits/dto/update-kits-config.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/kits/kits.controller.ts
Normal file
80
backend-nest/src/modules/kits/kits.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 { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/kits/kits.module.ts
Normal file
14
backend-nest/src/modules/kits/kits.module.ts
Normal 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 {}
|
||||||
180
backend-nest/src/modules/kits/kits.service.ts
Normal file
180
backend-nest/src/modules/kits/kits.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 { 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateRaidableBasesConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default RaidableBases Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard RaidableBases 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 ImportRaidableBasesConfigDto {
|
||||||
|
@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 UpdateRaidableBasesConfigDto {
|
||||||
|
@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,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { RaidableBasesService } from './raidablebases.service';
|
||||||
|
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||||
|
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||||
|
import { ImportRaidableBasesConfigDto } from './dto/import-raidablebases-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('raidablebases')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('raidablebases')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class RaidableBasesController {
|
||||||
|
constructor(private readonly raidableBasesService: RaidableBasesService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('raidablebases.view')
|
||||||
|
@ApiOperation({ summary: 'List RaidableBases configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.raidableBasesService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('raidablebases.view')
|
||||||
|
@ApiOperation({ summary: 'Get full RaidableBases config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.raidableBasesService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('raidablebases.manage')
|
||||||
|
@ApiOperation({ summary: 'Create RaidableBases config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateRaidableBasesConfigDto) {
|
||||||
|
return this.raidableBasesService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('raidablebases.manage')
|
||||||
|
@ApiOperation({ summary: 'Update RaidableBases config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateRaidableBasesConfigDto,
|
||||||
|
) {
|
||||||
|
return this.raidableBasesService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('raidablebases.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete RaidableBases config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.raidableBasesService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('raidablebases.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy RaidableBases config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.raidableBasesService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('raidablebases.manage')
|
||||||
|
@ApiOperation({ summary: 'Import RaidableBases.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportRaidableBasesConfigDto) {
|
||||||
|
return this.raidableBasesService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RaidableBasesController } from './raidablebases.controller';
|
||||||
|
import { RaidableBasesService } from './raidablebases.service';
|
||||||
|
import { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([RaidableBasesConfig])],
|
||||||
|
controllers: [RaidableBasesController],
|
||||||
|
providers: [RaidableBasesService, NatsService],
|
||||||
|
exports: [RaidableBasesService],
|
||||||
|
})
|
||||||
|
export class RaidableBasesModule {}
|
||||||
180
backend-nest/src/modules/raidablebases/raidablebases.service.ts
Normal file
180
backend-nest/src/modules/raidablebases/raidablebases.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 { RaidableBasesConfig } from '../../entities/raidablebases-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { CreateRaidableBasesConfigDto } from './dto/create-raidablebases-config.dto';
|
||||||
|
import { UpdateRaidableBasesConfigDto } from './dto/update-raidablebases-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RaidableBasesService {
|
||||||
|
private readonly logger = new Logger(RaidableBasesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(RaidableBasesConfig)
|
||||||
|
private readonly raidableBasesRepo: Repository<RaidableBasesConfig>,
|
||||||
|
private readonly natsService: NatsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.raidableBasesRepo.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.raidableBasesRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('RaidableBases config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateRaidableBasesConfigDto) {
|
||||||
|
const config = this.raidableBasesRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.raidableBasesRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateRaidableBasesConfigDto) {
|
||||||
|
const config = await this.raidableBasesRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('RaidableBases 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.raidableBasesRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.raidableBasesRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('RaidableBases config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.raidableBasesRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('RaidableBases config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write RaidableBases.json via file manager NATS
|
||||||
|
await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_save',
|
||||||
|
path: 'server://oxide/config/RaidableBases.json',
|
||||||
|
content: jsonString,
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload RaidableBases plugin via RCON
|
||||||
|
await this.natsService.publish(
|
||||||
|
`corrosion.${licenseId}.cmd.server`,
|
||||||
|
{
|
||||||
|
action: 'command',
|
||||||
|
command: 'oxide.reload RaidableBases',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.raidableBasesRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.raidableBasesRepo.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 RaidableBases config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy RaidableBases config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import RaidableBases.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read RaidableBases.json from server via file manager NATS
|
||||||
|
const response = await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_preview',
|
||||||
|
path: 'server://oxide/config/RaidableBases.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 RaidableBases config row
|
||||||
|
const config = this.raidableBasesRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.raidableBasesRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import RaidableBases config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import RaidableBases config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,4 +73,11 @@ export class ServersController {
|
|||||||
) {
|
) {
|
||||||
return await this.serversService.deployServer(licenseId, dto);
|
return await this.serversService.deployServer(licenseId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('install-oxide')
|
||||||
|
@RequirePermission('server.manage')
|
||||||
|
@ApiOperation({ summary: 'Install Oxide/uMod via companion agent' })
|
||||||
|
async installOxide(@CurrentTenant() licenseId: string) {
|
||||||
|
return await this.serversService.installOxide(licenseId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,12 @@ export class ServersService {
|
|||||||
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
await this.natsService.sendDeployCommand(licenseId, { ...dto });
|
||||||
return { message: 'Deployment started' };
|
return { message: 'Deployment started' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Oxide/uMod via companion agent
|
||||||
|
*/
|
||||||
|
async installOxide(licenseId: string) {
|
||||||
|
await this.natsService.sendOxideInstallCommand(licenseId);
|
||||||
|
return { message: 'Oxide installation started' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsOptional, IsObject, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateTeleportConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard NTeleportation 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 ImportTeleportConfigDto {
|
||||||
|
@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 UpdateTeleportConfigDto {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
80
backend-nest/src/modules/teleport/teleport.controller.ts
Normal file
80
backend-nest/src/modules/teleport/teleport.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 { TeleportService } from './teleport.service';
|
||||||
|
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||||
|
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||||
|
import { ImportTeleportConfigDto } from './dto/import-teleport-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('teleport')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('teleport')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class TeleportController {
|
||||||
|
constructor(private readonly teleportService: TeleportService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('teleport.view')
|
||||||
|
@ApiOperation({ summary: 'List teleport configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.teleportService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('teleport.view')
|
||||||
|
@ApiOperation({ summary: 'Get full teleport config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.teleportService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('teleport.manage')
|
||||||
|
@ApiOperation({ summary: 'Create teleport config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTeleportConfigDto) {
|
||||||
|
return this.teleportService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('teleport.manage')
|
||||||
|
@ApiOperation({ summary: 'Update teleport config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateTeleportConfigDto,
|
||||||
|
) {
|
||||||
|
return this.teleportService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('teleport.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete teleport config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.teleportService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('teleport.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy teleport config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.teleportService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('teleport.manage')
|
||||||
|
@ApiOperation({ summary: 'Import NTeleportation.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTeleportConfigDto) {
|
||||||
|
return this.teleportService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
14
backend-nest/src/modules/teleport/teleport.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TeleportController } from './teleport.controller';
|
||||||
|
import { TeleportService } from './teleport.service';
|
||||||
|
import { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TeleportConfig])],
|
||||||
|
controllers: [TeleportController],
|
||||||
|
providers: [TeleportService, NatsService],
|
||||||
|
exports: [TeleportService],
|
||||||
|
})
|
||||||
|
export class TeleportModule {}
|
||||||
180
backend-nest/src/modules/teleport/teleport.service.ts
Normal file
180
backend-nest/src/modules/teleport/teleport.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 { TeleportConfig } from '../../entities/teleport-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { CreateTeleportConfigDto } from './dto/create-teleport-config.dto';
|
||||||
|
import { UpdateTeleportConfigDto } from './dto/update-teleport-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeleportService {
|
||||||
|
private readonly logger = new Logger(TeleportService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TeleportConfig)
|
||||||
|
private readonly teleportRepo: Repository<TeleportConfig>,
|
||||||
|
private readonly natsService: NatsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.teleportRepo.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.teleportRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Teleport config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateTeleportConfigDto) {
|
||||||
|
const config = this.teleportRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.teleportRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateTeleportConfigDto) {
|
||||||
|
const config = await this.teleportRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Teleport 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.teleportRepo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.teleportRepo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('Teleport config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.teleportRepo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('Teleport config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write NTeleportation.json via file manager NATS
|
||||||
|
await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_save',
|
||||||
|
path: 'server://oxide/config/NTeleportation.json',
|
||||||
|
content: jsonString,
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload NTeleportation plugin via RCON
|
||||||
|
await this.natsService.publish(
|
||||||
|
`corrosion.${licenseId}.cmd.server`,
|
||||||
|
{
|
||||||
|
action: 'command',
|
||||||
|
command: 'oxide.reload NTeleportation',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.teleportRepo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.teleportRepo.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 teleport config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy teleport config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import NTeleportation.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read NTeleportation.json from server via file manager NATS
|
||||||
|
const response = await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_preview',
|
||||||
|
path: 'server://oxide/config/NTeleportation.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 teleport config row
|
||||||
|
const config = this.teleportRepo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.teleportRepo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import teleport config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import teleport 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 CreateTimedExecuteConfigDto {
|
||||||
|
@ApiProperty({ example: 'Default Timer Config' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
config_name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Standard TimedExecute 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 ImportTimedExecuteConfigDto {
|
||||||
|
@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 UpdateTimedExecuteConfigDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Updated Timer 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,80 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { TimedExecuteService } from './timedexecute.service';
|
||||||
|
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||||
|
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||||
|
import { ImportTimedExecuteConfigDto } from './dto/import-timedexecute-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('timedexecute')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('timedexecute')
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
|
export class TimedExecuteController {
|
||||||
|
constructor(private readonly timedExecuteService: TimedExecuteService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermission('timedexecute.view')
|
||||||
|
@ApiOperation({ summary: 'List TimedExecute configs (summaries)' })
|
||||||
|
getConfigs(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.timedExecuteService.getConfigs(licenseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/:id')
|
||||||
|
@RequirePermission('timedexecute.view')
|
||||||
|
@ApiOperation({ summary: 'Get full TimedExecute config with data' })
|
||||||
|
getConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.timedExecuteService.getConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
@RequirePermission('timedexecute.manage')
|
||||||
|
@ApiOperation({ summary: 'Create TimedExecute config' })
|
||||||
|
createConfig(@CurrentTenant() licenseId: string, @Body() dto: CreateTimedExecuteConfigDto) {
|
||||||
|
return this.timedExecuteService.createConfig(licenseId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs/:id')
|
||||||
|
@RequirePermission('timedexecute.manage')
|
||||||
|
@ApiOperation({ summary: 'Update TimedExecute config' })
|
||||||
|
updateConfig(
|
||||||
|
@CurrentTenant() licenseId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateTimedExecuteConfigDto,
|
||||||
|
) {
|
||||||
|
return this.timedExecuteService.updateConfig(licenseId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
@RequirePermission('timedexecute.manage')
|
||||||
|
@ApiOperation({ summary: 'Delete TimedExecute config' })
|
||||||
|
deleteConfig(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.timedExecuteService.deleteConfig(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs/:id/apply')
|
||||||
|
@RequirePermission('timedexecute.manage')
|
||||||
|
@ApiOperation({ summary: 'Deploy TimedExecute config to server' })
|
||||||
|
applyToServer(@CurrentTenant() licenseId: string, @Param('id') id: string) {
|
||||||
|
return this.timedExecuteService.applyToServer(licenseId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import-from-server')
|
||||||
|
@RequirePermission('timedexecute.manage')
|
||||||
|
@ApiOperation({ summary: 'Import TimedExecute.json from server via NATS' })
|
||||||
|
importFromServer(@CurrentTenant() licenseId: string, @Body() dto: ImportTimedExecuteConfigDto) {
|
||||||
|
return this.timedExecuteService.importFromServer(licenseId, dto.config_name, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal file
14
backend-nest/src/modules/timedexecute/timedexecute.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TimedExecuteController } from './timedexecute.controller';
|
||||||
|
import { TimedExecuteService } from './timedexecute.service';
|
||||||
|
import { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TimedExecuteConfig])],
|
||||||
|
controllers: [TimedExecuteController],
|
||||||
|
providers: [TimedExecuteService, NatsService],
|
||||||
|
exports: [TimedExecuteService],
|
||||||
|
})
|
||||||
|
export class TimedExecuteModule {}
|
||||||
180
backend-nest/src/modules/timedexecute/timedexecute.service.ts
Normal file
180
backend-nest/src/modules/timedexecute/timedexecute.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 { TimedExecuteConfig } from '../../entities/timedexecute-config.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
import { CreateTimedExecuteConfigDto } from './dto/create-timedexecute-config.dto';
|
||||||
|
import { UpdateTimedExecuteConfigDto } from './dto/update-timedexecute-config.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimedExecuteService {
|
||||||
|
private readonly logger = new Logger(TimedExecuteService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TimedExecuteConfig)
|
||||||
|
private readonly repo: Repository<TimedExecuteConfig>,
|
||||||
|
private readonly natsService: NatsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List configs for a license (summaries — no JSONB) */
|
||||||
|
async getConfigs(licenseId: string) {
|
||||||
|
const configs = await this.repo.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.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('TimedExecute config not found');
|
||||||
|
return { config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new config */
|
||||||
|
async createConfig(licenseId: string, dto: CreateTimedExecuteConfigDto) {
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: dto.config_name,
|
||||||
|
description: dto.description || null,
|
||||||
|
config_data: dto.config_data || {},
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing config */
|
||||||
|
async updateConfig(licenseId: string, configId: string, dto: UpdateTimedExecuteConfigDto) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('TimedExecute 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.repo.save(config);
|
||||||
|
return { config: saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config */
|
||||||
|
async deleteConfig(licenseId: string, configId: string) {
|
||||||
|
const result = await this.repo.delete({ id: configId, license_id: licenseId });
|
||||||
|
if (result.affected === 0) throw new NotFoundException('TimedExecute config not found');
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy config to game server via NATS */
|
||||||
|
async applyToServer(licenseId: string, configId: string) {
|
||||||
|
const config = await this.repo.findOne({
|
||||||
|
where: { id: configId, license_id: licenseId },
|
||||||
|
});
|
||||||
|
if (!config) throw new NotFoundException('TimedExecute config not found');
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config.config_data, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write TimedExecute.json via file manager NATS
|
||||||
|
await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_save',
|
||||||
|
path: 'server://oxide/config/TimedExecute.json',
|
||||||
|
content: jsonString,
|
||||||
|
},
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload TimedExecute plugin via RCON
|
||||||
|
await this.natsService.publish(
|
||||||
|
`corrosion.${licenseId}.cmd.server`,
|
||||||
|
{
|
||||||
|
action: 'command',
|
||||||
|
command: 'oxide.reload TimedExecute',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark this config as active, deactivate others
|
||||||
|
await this.repo.update({ license_id: licenseId }, { is_active: false });
|
||||||
|
await this.repo.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 TimedExecute config: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to deploy TimedExecute config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import TimedExecute.json from game server via NATS */
|
||||||
|
async importFromServer(licenseId: string, configName: string, description?: string) {
|
||||||
|
try {
|
||||||
|
// Read TimedExecute.json from server via file manager NATS
|
||||||
|
const response = await this.natsService.request(
|
||||||
|
`corrosion.${licenseId}.files.cmd`,
|
||||||
|
{
|
||||||
|
func: 'fm_preview',
|
||||||
|
path: 'server://oxide/config/TimedExecute.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 config row
|
||||||
|
const config = this.repo.create({
|
||||||
|
license_id: licenseId,
|
||||||
|
config_name: configName,
|
||||||
|
description: description || 'Imported from server',
|
||||||
|
config_data: configData,
|
||||||
|
});
|
||||||
|
const saved = await this.repo.save(config);
|
||||||
|
|
||||||
|
return { config: saved };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Failed to import TimedExecute config from server: ${(error as Error).message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Failed to import TimedExecute config — agent may be offline',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ export class NatsBridgeService implements OnModuleInit {
|
|||||||
this.emit(licenseId, 'deploy_status', data);
|
this.emit(licenseId, 'deploy_status', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.nats.subscribe('corrosion.*.oxide.status', (data, subject) => {
|
||||||
|
const licenseId = subject.split('.')[1];
|
||||||
|
this.emit(licenseId, 'oxide_status', data);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log('NATS bridge subscriptions initialized');
|
this.logger.log('NATS bridge subscriptions initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,4 +79,12 @@ export class NatsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publish an Oxide install command to a specific license's companion agent */
|
||||||
|
async sendOxideInstallCommand(licenseId: string): Promise<void> {
|
||||||
|
await this.publish(`corrosion.${licenseId}.cmd.oxide`, {
|
||||||
|
action: 'install_oxide',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/migrations/014_teleport_configs.sql
Normal file
12
backend/migrations/014_teleport_configs.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Teleport configuration profiles for NTeleportation integration
|
||||||
|
CREATE TABLE teleport_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_teleport_configs_license ON teleport_configs(license_id);
|
||||||
11
backend/migrations/015_gather_configs.sql
Normal file
11
backend/migrations/015_gather_configs.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS gather_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_gather_configs_license ON gather_configs(license_id);
|
||||||
11
backend/migrations/016_kits_configs.sql
Normal file
11
backend/migrations/016_kits_configs.sql
Normal file
@@ -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);
|
||||||
11
backend/migrations/017_betterchat_configs.sql
Normal file
11
backend/migrations/017_betterchat_configs.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS betterchat_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_betterchat_configs_license ON betterchat_configs(license_id);
|
||||||
11
backend/migrations/018_autodoors_configs.sql
Normal file
11
backend/migrations/018_autodoors_configs.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS autodoors_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_autodoors_configs_license ON autodoors_configs(license_id);
|
||||||
11
backend/migrations/019_furnacesplitter_configs.sql
Normal file
11
backend/migrations/019_furnacesplitter_configs.sql
Normal file
@@ -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);
|
||||||
11
backend/migrations/020_timedexecute_configs.sql
Normal file
11
backend/migrations/020_timedexecute_configs.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS timedexecute_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_timedexecute_configs_license ON timedexecute_configs(license_id);
|
||||||
11
backend/migrations/021_raidablebases_configs.sql
Normal file
11
backend/migrations/021_raidablebases_configs.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS raidablebases_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_raidablebases_configs_license ON raidablebases_configs(license_id);
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
||||||
|
"github.com/vigilcyber/corrosion-companion/internal/oxide"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/rcon"
|
"github.com/vigilcyber/corrosion-companion/internal/rcon"
|
||||||
@@ -32,14 +33,15 @@ type DaemonConfig struct {
|
|||||||
|
|
||||||
// Daemon manages the companion agent's main operations
|
// Daemon manages the companion agent's main operations
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
nc *nats.Conn
|
nc *nats.Conn
|
||||||
cfg *DaemonConfig
|
cfg *DaemonConfig
|
||||||
gameServer *process.GameServer
|
gameServer *process.GameServer
|
||||||
fileOps *files.Operations
|
fileOps *files.Operations
|
||||||
fm *filemanager.FileManager
|
fm *filemanager.FileManager
|
||||||
updater *update.Updater
|
updater *update.Updater
|
||||||
deployer *deploy.Deployer
|
deployer *deploy.Deployer
|
||||||
subscriptions []*nats.Subscription
|
oxideInstaller *oxide.OxideInstaller
|
||||||
|
subscriptions []*nats.Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeartbeatPayload represents the data sent in heartbeat messages
|
// HeartbeatPayload represents the data sent in heartbeat messages
|
||||||
@@ -56,6 +58,7 @@ type HeartbeatPayload struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
ServerInstalled bool `json:"server_installed"`
|
ServerInstalled bool `json:"server_installed"`
|
||||||
|
OxideInstalled bool `json:"oxide_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
||||||
@@ -74,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) {
|
|||||||
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
|
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
|
||||||
|
type restartAdapter struct {
|
||||||
|
gs *process.GameServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *restartAdapter) Restart() error {
|
||||||
|
return a.gs.Restart()
|
||||||
|
}
|
||||||
|
|
||||||
// NewDaemon creates a new daemon instance
|
// NewDaemon creates a new daemon instance
|
||||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||||
@@ -82,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
|||||||
updater := update.NewUpdater(cfg.Version)
|
updater := update.NewUpdater(cfg.Version)
|
||||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||||
|
restarter := &restartAdapter{gs: gameServer}
|
||||||
|
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
gameServer: gameServer,
|
gameServer: gameServer,
|
||||||
fileOps: fileOps,
|
fileOps: fileOps,
|
||||||
fm: fm,
|
fm: fm,
|
||||||
updater: updater,
|
updater: updater,
|
||||||
deployer: deployer,
|
deployer: deployer,
|
||||||
|
oxideInstaller: oxideInst,
|
||||||
}
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
@@ -125,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to Oxide install commands
|
||||||
|
if err := d.subscribeOxideInstall(); err != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||||
if err := d.subscribeFileManager(); err != nil {
|
if err := d.subscribeFileManager(); err != nil {
|
||||||
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||||
@@ -389,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subscribeOxideInstall subscribes to Oxide installation commands
|
||||||
|
func (d *Daemon) subscribeOxideInstall() error {
|
||||||
|
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
|
||||||
|
|
||||||
|
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
|
log.Println("Received Oxide install command")
|
||||||
|
|
||||||
|
// Run installation in goroutine (it's long-running)
|
||||||
|
go func() {
|
||||||
|
if err := d.oxideInstaller.Install(); err != nil {
|
||||||
|
log.Printf("Oxide installation failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Oxide installation completed successfully")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Immediately acknowledge the command
|
||||||
|
d.respondSuccess(msg, map[string]interface{}{
|
||||||
|
"status": "accepted",
|
||||||
|
"message": "Oxide installation started, progress will be published to oxide.status",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.subscriptions = append(d.subscriptions, sub)
|
||||||
|
log.Printf("Subscribed to: %s", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleFileOperation processes file operation requests
|
// handleFileOperation processes file operation requests
|
||||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||||
// Parse common fields
|
// Parse common fields
|
||||||
@@ -459,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
|
|||||||
OS: runtime.GOOS,
|
OS: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
||||||
|
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
data, err := json.Marshal(payload)
|
||||||
|
|||||||
250
companion-agent/internal/oxide/installer.go
Normal file
250
companion-agent/internal/oxide/installer.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package oxide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GameServerRestarter abstracts the game server process manager so the installer
|
||||||
|
// can restart the server after extracting Oxide files.
|
||||||
|
type GameServerRestarter interface {
|
||||||
|
Restart() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
|
||||||
|
type OxideInstaller struct {
|
||||||
|
nc *nats.Conn
|
||||||
|
licenseID string
|
||||||
|
installDir string
|
||||||
|
gameServer GameServerRestarter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOxideInstaller creates a new OxideInstaller instance.
|
||||||
|
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
|
||||||
|
return &OxideInstaller{
|
||||||
|
nc: nc,
|
||||||
|
licenseID: licenseID,
|
||||||
|
installDir: installDir,
|
||||||
|
gameServer: gs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// githubRelease represents the relevant fields from the GitHub Releases API response.
|
||||||
|
type githubRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Assets []githubAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install performs the full Oxide installation pipeline:
|
||||||
|
// 1. Fetch latest release info from GitHub
|
||||||
|
// 2. Download the zip
|
||||||
|
// 3. Extract over {installDir}/server/
|
||||||
|
// 4. Restart the game server
|
||||||
|
func (o *OxideInstaller) Install() error {
|
||||||
|
// Stage 1: Fetch latest release
|
||||||
|
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
|
||||||
|
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
|
||||||
|
|
||||||
|
release, err := o.fetchLatestRelease()
|
||||||
|
if err != nil {
|
||||||
|
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
|
||||||
|
return fmt.Errorf("fetch release failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(release.Assets) == 0 {
|
||||||
|
err := fmt.Errorf("no assets found in release %s", release.TagName)
|
||||||
|
o.publishStatus("failed", 0, "No download assets in release", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := release.Assets[0].BrowserDownloadURL
|
||||||
|
version := release.TagName
|
||||||
|
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
|
||||||
|
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
|
||||||
|
|
||||||
|
// Stage 2: Download zip
|
||||||
|
log.Printf("Oxide: downloading %s", downloadURL)
|
||||||
|
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
|
||||||
|
|
||||||
|
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
|
||||||
|
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
|
||||||
|
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
|
||||||
|
return fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
log.Printf("Oxide: download complete")
|
||||||
|
o.publishStatus("downloading", 100, "Download complete")
|
||||||
|
|
||||||
|
// Stage 3: Extract over server directory
|
||||||
|
serverDir := filepath.Join(o.installDir, "server")
|
||||||
|
log.Printf("Oxide: extracting to %s", serverDir)
|
||||||
|
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
|
||||||
|
|
||||||
|
if err := o.extractZip(tmpPath, serverDir); err != nil {
|
||||||
|
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
|
||||||
|
return fmt.Errorf("extract failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Oxide: extraction complete")
|
||||||
|
o.publishStatus("installing", 100, "Oxide files extracted")
|
||||||
|
|
||||||
|
// Stage 4: Restart server
|
||||||
|
log.Printf("Oxide: restarting server")
|
||||||
|
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
|
||||||
|
|
||||||
|
if err := o.gameServer.Restart(); err != nil {
|
||||||
|
o.publishStatus("failed", 0, "Server restart failed", err.Error())
|
||||||
|
return fmt.Errorf("server restart failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Oxide: server restarted, installation complete")
|
||||||
|
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
|
||||||
|
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GitHub API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var release githubRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile downloads a URL to a local file path.
|
||||||
|
func (o *OxideInstaller) downloadFile(url, destPath string) error {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP GET failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("failed to write download: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZip extracts a zip file to a destination directory, overwriting existing files.
|
||||||
|
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
|
||||||
|
// and create the oxide/ folder structure.
|
||||||
|
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open zip: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
targetPath := filepath.Join(destDir, f.Name)
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
|
||||||
|
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(outFile, rc)
|
||||||
|
rc.Close()
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
|
||||||
|
// but do not fail the installation — losing a progress update is not fatal.
|
||||||
|
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
|
||||||
|
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
|
||||||
|
|
||||||
|
status := OxideStatus{
|
||||||
|
Stage: stage,
|
||||||
|
Progress: progress,
|
||||||
|
Message: message,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errDetail) > 0 && errDetail[0] != "" {
|
||||||
|
status.Error = errDetail[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(status)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to marshal oxide status: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.nc.Publish(subject, data); err != nil {
|
||||||
|
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
companion-agent/internal/oxide/status.go
Normal file
31
companion-agent/internal/oxide/status.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package oxide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OxideStatus represents a progress update published to NATS during Oxide installation.
|
||||||
|
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
|
||||||
|
type OxideStatus struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid installation stages:
|
||||||
|
// fetching_release - Querying GitHub API for latest Oxide.Rust release
|
||||||
|
// downloading - Downloading the Oxide zip file
|
||||||
|
// installing - Extracting zip over server directory
|
||||||
|
// restarting - Restarting the game server to load Oxide
|
||||||
|
// complete - Oxide installation finished successfully
|
||||||
|
// failed - Installation failed at some stage
|
||||||
|
|
||||||
|
// CheckOxideInstalled returns true if the oxide/ directory exists in the
|
||||||
|
// server installation directory, indicating that Oxide/uMod has been installed.
|
||||||
|
func CheckOxideInstalled(installDir string) bool {
|
||||||
|
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Crosshair,
|
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
@@ -38,26 +37,59 @@ const auth = useAuthStore()
|
|||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
const navItems = [
|
type NavItem = { name: string; path: string; icon: any; permission: string | null }
|
||||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
|
type NavSection = { label: string; items: NavItem[] }
|
||||||
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
|
|
||||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
const navSections: NavSection[] = [
|
||||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
{
|
||||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
label: '',
|
||||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
items: [
|
||||||
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
|
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
|
||||||
{ 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' },
|
{
|
||||||
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
label: 'Server',
|
||||||
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
items: [
|
||||||
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
|
||||||
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
||||||
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||||
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||||
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
||||||
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
],
|
||||||
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
},
|
||||||
|
{
|
||||||
|
label: 'Plugin Configs',
|
||||||
|
items: [
|
||||||
|
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||||
|
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||||
|
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [
|
||||||
|
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||||
|
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
||||||
|
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
||||||
|
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [
|
||||||
|
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
||||||
|
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
||||||
|
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
||||||
|
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
||||||
|
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
@@ -82,10 +114,14 @@ function closeSidebar() {
|
|||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShowNavItem(item: typeof navItems[0]): boolean {
|
function canShowNavItem(item: NavItem): boolean {
|
||||||
if (!item.permission) return true
|
if (!item.permission) return true
|
||||||
return auth.hasPermission(item.permission)
|
return auth.hasPermission(item.permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVisibleItems(section: NavSection): boolean {
|
||||||
|
return section.items.some(canShowNavItem)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -148,29 +184,35 @@ function canShowNavItem(item: typeof navItems[0]): boolean {
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex-1 overflow-y-auto py-2">
|
<nav class="flex-1 overflow-y-auto py-2">
|
||||||
<RouterLink
|
<template v-for="section in navSections" :key="section.label">
|
||||||
v-for="item in navItems"
|
<template v-if="hasVisibleItems(section)">
|
||||||
v-show="canShowNavItem(item)"
|
<!-- Section Header -->
|
||||||
:key="item.path"
|
<div v-if="section.label" class="mt-4 mb-1 px-4">
|
||||||
:to="item.path"
|
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
|
||||||
@click="closeSidebar"
|
</div>
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
|
||||||
:class="isActive(item.path)
|
<!-- Section Items -->
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
<RouterLink
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
v-for="item in section.items"
|
||||||
>
|
v-show="canShowNavItem(item)"
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
:key="item.path"
|
||||||
{{ item.name }}
|
:to="item.path"
|
||||||
</RouterLink>
|
@click="closeSidebar"
|
||||||
|
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
||||||
|
:class="isActive(item.path)
|
||||||
|
? 'bg-oxide-500/10 text-oxide-400'
|
||||||
|
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-4 h-4" />
|
||||||
|
{{ item.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Platform Admin Section (super-admin only) -->
|
<!-- Platform Admin Section (super-admin only) -->
|
||||||
<template v-if="auth.isSuperAdmin">
|
<template v-if="auth.isSuperAdmin">
|
||||||
<div class="mt-4 mb-2 px-4">
|
<div class="mt-4 mb-1 px-4">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
||||||
<div class="flex-1 border-t border-neutral-700" />
|
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
|
||||||
<div class="flex-1 border-t border-neutral-700" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in adminNavItems"
|
v-for="item in adminNavItems"
|
||||||
|
|||||||
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal file
195
frontend/src/components/teleport/PermissionGroupEditor.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
configData: Record<string, any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:configData': [configData: Record<string, any>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const newGroupName = ref('')
|
||||||
|
|
||||||
|
// Merge all VIP maps by key name to compute the unified group list
|
||||||
|
const groups = computed(() => {
|
||||||
|
const homesLimits: Record<string, number> = props.configData?.Home?.VIPHomesLimits || {}
|
||||||
|
const cooldowns: Record<string, number> = props.configData?.TPR?.VIPCooldowns || {}
|
||||||
|
const countdowns: Record<string, number> = props.configData?.TPR?.VIPCountdowns || {}
|
||||||
|
const dailyLimits: Record<string, number> = props.configData?.TPR?.VIPDailyLimits || {}
|
||||||
|
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(homesLimits),
|
||||||
|
...Object.keys(cooldowns),
|
||||||
|
...Object.keys(countdowns),
|
||||||
|
...Object.keys(dailyLimits),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Array.from(allKeys).map(name => ({
|
||||||
|
name,
|
||||||
|
homesLimit: homesLimits[name] ?? 5,
|
||||||
|
cooldown: cooldowns[name] ?? 300,
|
||||||
|
countdown: countdowns[name] ?? 5,
|
||||||
|
dailyLimit: dailyLimits[name] ?? 10,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function ensurePaths(data: Record<string, any>) {
|
||||||
|
if (!data.Home) data.Home = {}
|
||||||
|
if (!data.Home.VIPHomesLimits) data.Home.VIPHomesLimits = {}
|
||||||
|
if (!data.TPR) data.TPR = {}
|
||||||
|
if (!data.TPR.VIPCooldowns) data.TPR.VIPCooldowns = {}
|
||||||
|
if (!data.TPR.VIPCountdowns) data.TPR.VIPCountdowns = {}
|
||||||
|
if (!data.TPR.VIPDailyLimits) data.TPR.VIPDailyLimits = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup() {
|
||||||
|
const name = newGroupName.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
// Check if already exists
|
||||||
|
if (groups.value.some(g => g.name === name)) return
|
||||||
|
|
||||||
|
const updated = { ...props.configData }
|
||||||
|
ensurePaths(updated)
|
||||||
|
updated.Home.VIPHomesLimits[name] = 5
|
||||||
|
updated.TPR.VIPCooldowns[name] = 300
|
||||||
|
updated.TPR.VIPCountdowns[name] = 5
|
||||||
|
updated.TPR.VIPDailyLimits[name] = 10
|
||||||
|
emit('update:configData', updated)
|
||||||
|
newGroupName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGroup(name: string) {
|
||||||
|
if (!confirm(`Remove VIP group "${name}"?`)) return
|
||||||
|
const updated = { ...props.configData }
|
||||||
|
ensurePaths(updated)
|
||||||
|
delete updated.Home.VIPHomesLimits[name]
|
||||||
|
delete updated.TPR.VIPCooldowns[name]
|
||||||
|
delete updated.TPR.VIPCountdowns[name]
|
||||||
|
delete updated.TPR.VIPDailyLimits[name]
|
||||||
|
emit('update:configData', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(groupName: string, field: string, value: number) {
|
||||||
|
const updated = { ...props.configData }
|
||||||
|
ensurePaths(updated)
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'homesLimit':
|
||||||
|
updated.Home.VIPHomesLimits[groupName] = value
|
||||||
|
break
|
||||||
|
case 'cooldown':
|
||||||
|
updated.TPR.VIPCooldowns[groupName] = value
|
||||||
|
break
|
||||||
|
case 'countdown':
|
||||||
|
updated.TPR.VIPCountdowns[groupName] = value
|
||||||
|
break
|
||||||
|
case 'dailyLimit':
|
||||||
|
updated.TPR.VIPDailyLimits[groupName] = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:configData', updated)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Group -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="newGroupName"
|
||||||
|
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||||
|
@keydown.enter="addGroup"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="addGroup"
|
||||||
|
:disabled="!newGroupName.trim()"
|
||||||
|
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||||
|
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Table -->
|
||||||
|
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800">
|
||||||
|
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
||||||
|
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
||||||
|
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
||||||
|
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
||||||
|
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
||||||
|
<th class="w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.name"
|
||||||
|
class="border-b border-neutral-800/50"
|
||||||
|
>
|
||||||
|
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="group.homesLimit"
|
||||||
|
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="group.cooldown"
|
||||||
|
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="group.countdown"
|
||||||
|
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="group.dailyLimit"
|
||||||
|
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<button
|
||||||
|
@click="removeGroup(group.name)"
|
||||||
|
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
76
frontend/src/components/teleport/WarpEditor.vue
Normal file
76
frontend/src/components/teleport/WarpEditor.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
warps: Record<string, { x: number; y: number; z: number }>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:warps': [warps: Record<string, { x: number; y: number; z: number }>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const newWarpName = ref('')
|
||||||
|
|
||||||
|
function addWarp() {
|
||||||
|
const name = newWarpName.value.trim()
|
||||||
|
if (!name || props.warps[name]) return
|
||||||
|
const updated = { ...props.warps, [name]: { x: 0, y: 0, z: 0 } }
|
||||||
|
emit('update:warps', updated)
|
||||||
|
newWarpName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWarp(name: string) {
|
||||||
|
const updated = { ...props.warps }
|
||||||
|
delete updated[name]
|
||||||
|
emit('update:warps', updated)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
||||||
|
|
||||||
|
<!-- Add Warp -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="newWarpName"
|
||||||
|
placeholder="Warp name..."
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||||
|
@keydown.enter="addWarp"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="addWarp"
|
||||||
|
:disabled="!newWarpName.trim()"
|
||||||
|
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warp List -->
|
||||||
|
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
||||||
|
No warps defined. Add warps here and set coordinates in-game.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(coords, name) in warps"
|
||||||
|
:key="name"
|
||||||
|
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
||||||
|
<span class="text-neutral-500 text-xs ml-3">
|
||||||
|
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="removeWarp(name as string)"
|
||||||
|
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -110,11 +110,56 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'plugin-configs',
|
||||||
|
name: 'plugin-configs',
|
||||||
|
component: () => import('@/views/admin/PluginConfigsView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'loot-builder',
|
path: 'loot-builder',
|
||||||
name: 'loot-builder',
|
name: 'loot-builder',
|
||||||
component: () => import('@/views/admin/LootBuilderView.vue'),
|
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'teleport-config',
|
||||||
|
name: 'teleport-config',
|
||||||
|
component: () => import('@/views/admin/TeleportConfigView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gather-manager',
|
||||||
|
name: 'gather-manager',
|
||||||
|
component: () => import('@/views/admin/GatherManagerView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'autodoors',
|
||||||
|
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: 'raidable-bases',
|
||||||
|
name: 'raidable-bases',
|
||||||
|
component: () => import('@/views/admin/RaidableBasesView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
|
|||||||
145
frontend/src/stores/autodoors.ts
Normal file
145
frontend/src/stores/autodoors.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { AutoDoorsConfigSummary, AutoDoorsConfigFull, AutoDoorsApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useAutoDoorsStore = defineStore('autodoors', () => {
|
||||||
|
const configs = ref<AutoDoorsConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<AutoDoorsConfigFull | null>(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: AutoDoorsConfigSummary[] }>('/autodoors/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: AutoDoorsConfigFull }>(`/autodoors/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: AutoDoorsConfigFull }>('/autodoors/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(`/autodoors/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(`/autodoors/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<AutoDoorsApplyResult>(`/autodoors/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: AutoDoorsConfigFull }>('/autodoors/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/betterchat.ts
Normal file
145
frontend/src/stores/betterchat.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { BetterChatConfigSummary, BetterChatConfigFull, BetterChatApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useBetterChatStore = defineStore('betterchat', () => {
|
||||||
|
const configs = ref<BetterChatConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<BetterChatConfigFull | null>(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: BetterChatConfigSummary[] }>('/betterchat/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: BetterChatConfigFull }>(`/betterchat/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: BetterChatConfigFull }>('/betterchat/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(`/betterchat/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(`/betterchat/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<BetterChatApplyResult>(`/betterchat/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: BetterChatConfigFull }>('/betterchat/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/furnacesplitter.ts
Normal file
145
frontend/src/stores/furnacesplitter.ts
Normal file
@@ -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<FurnaceSplitterConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<FurnaceSplitterConfigFull | null>(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<FurnaceSplitterApplyResult>(`/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/gather.ts
Normal file
145
frontend/src/stores/gather.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { GatherConfigSummary, GatherConfigFull, GatherApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useGatherStore = defineStore('gather', () => {
|
||||||
|
const configs = ref<GatherConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<GatherConfigFull | null>(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: GatherConfigSummary[] }>('/gather/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: GatherConfigFull }>(`/gather/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: GatherConfigFull }>('/gather/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(`/gather/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(`/gather/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<GatherApplyResult>(`/gather/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: GatherConfigFull }>('/gather/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/kits.ts
Normal file
145
frontend/src/stores/kits.ts
Normal file
@@ -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<KitsConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<KitsConfigFull | null>(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<KitsApplyResult>(`/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/raidablebases.ts
Normal file
145
frontend/src/stores/raidablebases.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { RaidableBasesConfigSummary, RaidableBasesConfigFull, RaidableBasesApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useRaidableBasesStore = defineStore('raidablebases', () => {
|
||||||
|
const configs = ref<RaidableBasesConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<RaidableBasesConfigFull | null>(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: RaidableBasesConfigSummary[] }>('/raidablebases/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: RaidableBasesConfigFull }>(`/raidablebases/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: RaidableBasesConfigFull }>('/raidablebases/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(`/raidablebases/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(`/raidablebases/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<RaidableBasesApplyResult>(`/raidablebases/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: RaidableBasesConfigFull }>('/raidablebases/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -64,6 +64,15 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installOxide() {
|
||||||
|
try {
|
||||||
|
await api.post('/servers/install-oxide')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to start Oxide installation:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateDeploymentStatus(status: DeploymentStatus) {
|
function updateDeploymentStatus(status: DeploymentStatus) {
|
||||||
deploymentStatus.value = status
|
deploymentStatus.value = status
|
||||||
if (status.stage === 'online' || status.stage === 'failed') {
|
if (status.stage === 'online' || status.stage === 'failed') {
|
||||||
@@ -94,6 +103,7 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
stopServer,
|
stopServer,
|
||||||
restartServer,
|
restartServer,
|
||||||
deployServer,
|
deployServer,
|
||||||
|
installOxide,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
clearDeploymentStatus,
|
clearDeploymentStatus,
|
||||||
updateStats,
|
updateStats,
|
||||||
|
|||||||
145
frontend/src/stores/teleport.ts
Normal file
145
frontend/src/stores/teleport.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { TeleportConfigSummary, TeleportConfigFull, TeleportApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useTeleportStore = defineStore('teleport', () => {
|
||||||
|
const configs = ref<TeleportConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<TeleportConfigFull | null>(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: TeleportConfigSummary[] }>('/teleport/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: TeleportConfigFull }>(`/teleport/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: TeleportConfigFull }>('/teleport/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(`/teleport/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(`/teleport/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<TeleportApplyResult>(`/teleport/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: TeleportConfigFull }>('/teleport/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
145
frontend/src/stores/timedexecute.ts
Normal file
145
frontend/src/stores/timedexecute.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import type { TimedExecuteConfigSummary, TimedExecuteConfigFull, TimedExecuteApplyResult } from '@/types'
|
||||||
|
|
||||||
|
export const useTimedExecuteStore = defineStore('timedexecute', () => {
|
||||||
|
const configs = ref<TimedExecuteConfigSummary[]>([])
|
||||||
|
const currentConfig = ref<TimedExecuteConfigFull | null>(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: TimedExecuteConfigSummary[] }>('/timedexecute/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: TimedExecuteConfigFull }>(`/timedexecute/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: TimedExecuteConfigFull }>('/timedexecute/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(`/timedexecute/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(`/timedexecute/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<TimedExecuteApplyResult>(`/timedexecute/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: TimedExecuteConfigFull }>('/timedexecute/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -519,3 +519,219 @@ export interface LootApplyResult {
|
|||||||
profile_name: string
|
profile_name: string
|
||||||
multiplier: number
|
multiplier: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teleport Config types — NTeleportation integration
|
||||||
|
export interface TeleportConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeleportConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeleportApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatherManager Config types
|
||||||
|
export interface GatherConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatherConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatherApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoDoors Config types
|
||||||
|
export interface AutoDoorsConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoDoorsConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoDoorsApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kits Config types
|
||||||
|
export interface KitsConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KitsConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KitsApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FurnaceSplitter Config types
|
||||||
|
export interface FurnaceSplitterConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FurnaceSplitterConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FurnaceSplitterApplyResult {
|
||||||
|
success: boolean
|
||||||
|
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<string, any>
|
||||||
|
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<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimedExecuteApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaidableBases Config types
|
||||||
|
export interface RaidableBasesConfigSummary {
|
||||||
|
id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RaidableBasesConfigFull {
|
||||||
|
id: string
|
||||||
|
license_id: string
|
||||||
|
config_name: string
|
||||||
|
description: string | null
|
||||||
|
config_data: Record<string, any>
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RaidableBasesApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
config_name: string
|
||||||
|
}
|
||||||
|
|||||||
595
frontend/src/views/admin/AutoDoorsView.vue
Normal file
595
frontend/src/views/admin/AutoDoorsView.vue
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAutoDoorsStore } from '@/stores/autodoors'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
DoorOpen,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useAutoDoorsStore()
|
||||||
|
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
|
||||||
|
// Door types from the AutoDoors plugin
|
||||||
|
const doorTypes = [
|
||||||
|
{ key: 'door.hinged.wood', label: 'Wooden Door', displayName: 'Wooden Door' },
|
||||||
|
{ key: 'door.hinged.metal', label: 'Sheet Metal Door', displayName: 'Sheet Metal Door' },
|
||||||
|
{ key: 'door.hinged.toptier', label: 'Armored Door', displayName: 'Armored Door' },
|
||||||
|
{ key: 'door.double.hinged.wood', label: 'Double Wooden Door', displayName: 'Double Wooden Door' },
|
||||||
|
{ key: 'door.double.hinged.metal', label: 'Double Sheet Metal Door', displayName: 'Double Sheet Metal Door' },
|
||||||
|
{ key: 'door.double.hinged.toptier', label: 'Double Armored Door', displayName: 'Double Armored Door' },
|
||||||
|
{ key: 'floor.ladder.hatch', label: 'Ladder Hatch', displayName: 'Ladder Hatch' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission group helpers ---
|
||||||
|
|
||||||
|
function getPermissionGroups(): Array<{ name: string; delay: number }> {
|
||||||
|
const groups = getConfigValue('PermissionGroups', {})
|
||||||
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
|
return Object.entries(groups).map(([name, delay]) => ({
|
||||||
|
name,
|
||||||
|
delay: Number(delay) || 5,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPermissionGroup() {
|
||||||
|
const groups = getConfigValue('PermissionGroups', {})
|
||||||
|
const newGroups = { ...groups, '': 5 }
|
||||||
|
setConfigValue('PermissionGroups', newGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePermissionGroupName(oldName: string, newName: string) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
const groups = getConfigValue('PermissionGroups', {})
|
||||||
|
const delay = groups[oldName] ?? 5
|
||||||
|
const newGroups: Record<string, number> = {}
|
||||||
|
for (const [key, val] of Object.entries(groups)) {
|
||||||
|
if (key === oldName) {
|
||||||
|
newGroups[newName] = delay
|
||||||
|
} else {
|
||||||
|
newGroups[key] = val as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConfigValue('PermissionGroups', newGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePermissionGroupDelay(name: string, delay: number) {
|
||||||
|
setConfigValue(`PermissionGroups.${name}`, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePermissionGroup(name: string) {
|
||||||
|
const groups = getConfigValue('PermissionGroups', {})
|
||||||
|
const newGroups = { ...groups }
|
||||||
|
delete newGroups[name]
|
||||||
|
setConfigValue('PermissionGroups', newGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this AutoDoors config to the server? This will overwrite the current AutoDoors config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Auto Doors</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<DoorOpen class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No AutoDoors Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Settings Section -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delay Settings -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Default Delay (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Time before door auto-closes</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
step="1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Minimum Delay (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Lowest delay a player can set</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
step="1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Maximum Delay (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Highest delay a player can set</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
step="1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Toggles -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Default Enabled</label>
|
||||||
|
<p class="text-xs text-neutral-500">Auto-close enabled for new players by default</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('GlobalSettings.defaultEnabled', !getConfigValue('GlobalSettings.defaultEnabled', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Allow Unowned Doors</label>
|
||||||
|
<p class="text-xs text-neutral-500">Auto-close doors that the player does not own</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('GlobalSettings.useUnownedDoor', !getConfigValue('GlobalSettings.useUnownedDoor', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Exclude Door Controller</label>
|
||||||
|
<p class="text-xs text-neutral-500">Skip doors that have a Code Lock or Key Lock</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('GlobalSettings.excludeDoorController', !getConfigValue('GlobalSettings.excludeDoorController', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Cancel on Player Death</label>
|
||||||
|
<p class="text-xs text-neutral-500">Cancel auto-close if the player dies</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('GlobalSettings.cancelOnKill', !getConfigValue('GlobalSettings.cancelOnKill', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Use Permissions</label>
|
||||||
|
<p class="text-xs text-neutral-500">Require Oxide permission to use auto-close</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('UsePermissions', !getConfigValue('UsePermissions', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('UsePermissions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('UsePermissions', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Clear Data on Map Wipe</label>
|
||||||
|
<p class="text-xs text-neutral-500">Reset all player preferences on map wipe</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('ClearDataOnWipe', !getConfigValue('ClearDataOnWipe', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('ClearDataOnWipe', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('ClearDataOnWipe', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Door Types Section -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DoorOpen class="w-4 h-4 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Door Types</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500">Enable or disable auto-close for each door type.</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="door in doorTypes"
|
||||||
|
:key="door.key"
|
||||||
|
class="flex items-center justify-between py-2 border-b border-neutral-800 last:border-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">{{ door.label }}</label>
|
||||||
|
<p class="text-xs text-neutral-500 font-mono">{{ door.key }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue(`DoorSettings.${door.key}.enabled`, !getConfigValue(`DoorSettings.${door.key}.enabled`, true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permission Groups Section -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission Group Overrides</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="addPermissionGroup"
|
||||||
|
class="flex items-center gap-1 px-3 py-1 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3" />
|
||||||
|
Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500">Override the default delay for specific Oxide permission groups.</p>
|
||||||
|
|
||||||
|
<div v-if="getPermissionGroups().length === 0" class="text-sm text-neutral-500 text-center py-4">
|
||||||
|
No permission group overrides configured.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(group, index) in getPermissionGroups()"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="group.name"
|
||||||
|
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="Group name (e.g. vip)"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="group.delay"
|
||||||
|
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-2 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500">sec</span>
|
||||||
|
<button
|
||||||
|
@click="removePermissionGroup(group.name)"
|
||||||
|
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New AutoDoors Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. 5 Second Close"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current AutoDoors config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
790
frontend/src/views/admin/BetterChatView.vue
Normal file
790
frontend/src/views/admin/BetterChatView.vue
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useBetterChatStore } from '@/stores/betterchat'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
MessageSquare,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Edit,
|
||||||
|
X,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useBetterChatStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'groups' | 'settings'>('groups')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const showGroupModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
const editingGroupIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'groups', label: 'Chat Groups', icon: MessageSquare },
|
||||||
|
{ key: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Default group template matching BetterChat actual format
|
||||||
|
const defaultGroup = {
|
||||||
|
GroupName: 'newgroup',
|
||||||
|
Priority: 0,
|
||||||
|
Title: {
|
||||||
|
Text: '[Player]',
|
||||||
|
Color: '#55aaff',
|
||||||
|
Size: 15,
|
||||||
|
Hidden: false,
|
||||||
|
HiddenIfNotPrimary: false,
|
||||||
|
},
|
||||||
|
Username: {
|
||||||
|
Color: '#55aaff',
|
||||||
|
Size: 15,
|
||||||
|
},
|
||||||
|
Message: {
|
||||||
|
Color: '#ffffff',
|
||||||
|
Size: 15,
|
||||||
|
},
|
||||||
|
Format: {
|
||||||
|
Chat: '{Title} {Username}: {Message}',
|
||||||
|
Console: '{Title} {Username}: {Message}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing group state
|
||||||
|
const editGroup = ref<Record<string, any>>({ ...JSON.parse(JSON.stringify(defaultGroup)) })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat Groups helpers ---
|
||||||
|
|
||||||
|
const chatGroups = computed<Record<string, any>[]>(() => {
|
||||||
|
if (!store.currentConfig?.config_data) return []
|
||||||
|
// BetterChat stores groups in oxide/data, but config has a ChatGroup array
|
||||||
|
// The actual config format uses a top-level array for groups in the data file
|
||||||
|
// We support both: config_data as array or config_data.ChatGroups as array
|
||||||
|
if (Array.isArray(store.currentConfig.config_data)) {
|
||||||
|
return store.currentConfig.config_data
|
||||||
|
}
|
||||||
|
return getConfigValue('ChatGroups', []) as Record<string, any>[]
|
||||||
|
})
|
||||||
|
|
||||||
|
function addGroup() {
|
||||||
|
editingGroupIndex.value = null
|
||||||
|
editGroup.value = JSON.parse(JSON.stringify(defaultGroup))
|
||||||
|
showGroupModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGroupAt(index: number) {
|
||||||
|
editingGroupIndex.value = index
|
||||||
|
editGroup.value = JSON.parse(JSON.stringify(chatGroups.value[index]))
|
||||||
|
showGroupModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGroup() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
|
||||||
|
const groups = [...chatGroups.value]
|
||||||
|
if (editingGroupIndex.value !== null) {
|
||||||
|
groups[editingGroupIndex.value] = JSON.parse(JSON.stringify(editGroup.value))
|
||||||
|
} else {
|
||||||
|
groups.push(JSON.parse(JSON.stringify(editGroup.value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(store.currentConfig.config_data)) {
|
||||||
|
store.currentConfig.config_data = groups as any
|
||||||
|
} else {
|
||||||
|
store.currentConfig.config_data.ChatGroups = groups
|
||||||
|
}
|
||||||
|
store.markDirty()
|
||||||
|
showGroupModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroup(index: number) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Remove this chat group?')) return
|
||||||
|
|
||||||
|
const groups = [...chatGroups.value]
|
||||||
|
groups.splice(index, 1)
|
||||||
|
|
||||||
|
if (Array.isArray(store.currentConfig.config_data)) {
|
||||||
|
store.currentConfig.config_data = groups as any
|
||||||
|
} else {
|
||||||
|
store.currentConfig.config_data.ChatGroups = groups
|
||||||
|
}
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this BetterChat config to the server? This will overwrite the current config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Better Chat</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<MessageSquare class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No BetterChat Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key as typeof activeTab"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-oxide-500 text-oxide-400'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-4 h-4" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Groups Tab -->
|
||||||
|
<div v-if="activeTab === 'groups'" class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Chat Groups</h3>
|
||||||
|
<button
|
||||||
|
@click="addGroup"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chatGroups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
|
||||||
|
<p class="text-neutral-500">No chat groups configured. Add a group to get started.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(group, index) in chatGroups"
|
||||||
|
:key="index"
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="text-sm font-semibold text-neutral-200">{{ group.GroupName || group.groupName || 'Unnamed' }}</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 bg-neutral-800 text-neutral-400 rounded">Priority: {{ group.Priority ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Title:</span>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<span
|
||||||
|
class="inline-block w-3 h-3 rounded border border-neutral-600"
|
||||||
|
:style="{ backgroundColor: group.Title?.Color || group.TitleColor || '#55aaff' }"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-300">{{ group.Title?.Text || group.Title || '[Player]' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name Color -->
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Name Color:</span>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<span
|
||||||
|
class="inline-block w-3 h-3 rounded border border-neutral-600"
|
||||||
|
:style="{ backgroundColor: group.Username?.Color || group.NameColor || '#55aaff' }"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-300">{{ group.Username?.Color || group.NameColor || '#55aaff' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Color -->
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Message Color:</span>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<span
|
||||||
|
class="inline-block w-3 h-3 rounded border border-neutral-600"
|
||||||
|
:style="{ backgroundColor: group.Message?.Color || group.MessageColor || '#ffffff' }"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-300">{{ group.Message?.Color || group.MessageColor || '#ffffff' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format -->
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Format:</span>
|
||||||
|
<p class="text-neutral-300 mt-0.5 truncate">{{ group.Format?.Chat || group.Format || '{Title} {Username}: {Message}' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
@click="editGroupAt(index)"
|
||||||
|
class="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteGroup(index)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div v-else-if="activeTab === 'settings'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Word Filter Enabled -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Word Filter</label>
|
||||||
|
<p class="text-xs text-neutral-500">Enable profanity/word filtering in chat</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Word Filter.Enabled', !getConfigValue('Word Filter.Enabled', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Word Filter.Enabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Word Filter.Enabled', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anti Flood Enabled -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Anti Flood</label>
|
||||||
|
<p class="text-xs text-neutral-500">Prevent message spamming</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Anti Flood.Enabled', !getConfigValue('Anti Flood.Enabled', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Anti Flood.Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Anti Flood.Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reverse Title Order -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Reverse Title Order</label>
|
||||||
|
<p class="text-xs text-neutral-500">Reverse the display order of chat titles</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('General.Reverse Title Order', !getConfigValue('General.Reverse Title Order', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('General.Reverse Title Order', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('General.Reverse Title Order', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Use Custom Replacement -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Use Custom Replacement</label>
|
||||||
|
<p class="text-xs text-neutral-500">Use a custom word instead of * for filtered words</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Word Filter.Use Custom Replacement', !getConfigValue('Word Filter.Use Custom Replacement', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Word Filter.Use Custom Replacement', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Word Filter.Use Custom Replacement', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable Player Tagging -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Enable Player Tagging</label>
|
||||||
|
<p class="text-xs text-neutral-500">Allow @mentions to highlight players in chat</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('General.Enable Player Tagging', !getConfigValue('General.Enable Player Tagging', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('General.Enable Player Tagging', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('General.Enable Player Tagging', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number / Text Inputs -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Anti Flood Seconds</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Minimum seconds between messages</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
:value="getConfigValue('Anti Flood.Seconds', 1.5)"
|
||||||
|
@input="setConfigValue('Anti Flood.Seconds', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Minimal Tag Characters</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Minimum characters for player tag matching</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('General.Minimal Characters', 2)"
|
||||||
|
@input="setConfigValue('General.Minimal Characters', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Filter Replacement</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Character used to replace filtered words</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="getConfigValue('Word Filter.Replacement', '*')"
|
||||||
|
@input="setConfigValue('Word Filter.Replacement', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Custom Replacement Word</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Custom word to replace filtered content</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="getConfigValue('Word Filter.Custom Replacement', 'Unicorn')"
|
||||||
|
@input="setConfigValue('Word Filter.Custom Replacement', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New BetterChat Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. Default Chat Settings"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current BetterChat config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Edit Modal -->
|
||||||
|
<div v-if="showGroupModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showGroupModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100">
|
||||||
|
{{ editingGroupIndex !== null ? 'Edit Group' : 'Add Group' }}
|
||||||
|
</h2>
|
||||||
|
<button @click="showGroupModal = false" class="text-neutral-400 hover:text-neutral-200">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Group Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Group Name</label>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.GroupName"
|
||||||
|
placeholder="e.g. default, admin, vip"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Priority</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-1">Higher priority groups display first (0 = highest)</p>
|
||||||
|
<input
|
||||||
|
v-model.number="editGroup.Priority"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Title Text</label>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Title.Text"
|
||||||
|
placeholder="e.g. [Admin]"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colors Row -->
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Title Color</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
v-model="editGroup.Title.Color"
|
||||||
|
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Title.Color"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Name Color</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
v-model="editGroup.Username.Color"
|
||||||
|
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Username.Color"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Message Color</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
v-model="editGroup.Message.Color"
|
||||||
|
class="w-8 h-8 rounded border border-neutral-700 bg-transparent cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Message.Color"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Font Sizes -->
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Title Size</label>
|
||||||
|
<input
|
||||||
|
v-model.number="editGroup.Title.Size"
|
||||||
|
type="number"
|
||||||
|
min="8"
|
||||||
|
max="30"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Name Size</label>
|
||||||
|
<input
|
||||||
|
v-model.number="editGroup.Username.Size"
|
||||||
|
type="number"
|
||||||
|
min="8"
|
||||||
|
max="30"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Message Size</label>
|
||||||
|
<input
|
||||||
|
v-model.number="editGroup.Message.Size"
|
||||||
|
type="number"
|
||||||
|
min="8"
|
||||||
|
max="30"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format Strings -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Chat Format</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-1">Variables: {Title} {Username} {Message}</p>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Format.Chat"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Console Format</label>
|
||||||
|
<input
|
||||||
|
v-model="editGroup.Format.Console"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden toggles -->
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editGroup.Title.Hidden" class="rounded bg-neutral-800 border-neutral-600" />
|
||||||
|
Hidden
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="editGroup.Title.HiddenIfNotPrimary" class="rounded bg-neutral-800 border-neutral-600" />
|
||||||
|
Hidden if not primary
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button @click="showGroupModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="saveGroup"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 text-sm"
|
||||||
|
>
|
||||||
|
{{ editingGroupIndex !== null ? 'Update' : 'Add' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
371
frontend/src/views/admin/FurnaceSplitterView.vue
Normal file
371
frontend/src/views/admin/FurnaceSplitterView.vue
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Flame,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useFurnaceSplitterStore()
|
||||||
|
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Furnace types with display names
|
||||||
|
const furnaceTypes = [
|
||||||
|
{ key: 'furnace', label: 'Small Furnace', description: 'Standard furnace for smelting ores' },
|
||||||
|
{ key: 'furnace.large', label: 'Large Furnace', description: 'Large furnace with more slots' },
|
||||||
|
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
|
||||||
|
{ key: 'refinery_small_deployed', label: 'Small Oil Refinery', description: 'Refines crude oil into low grade fuel' },
|
||||||
|
{ key: 'skull_fire_pit', label: 'Skull Fire Pit', description: 'Decorative fire pit for cooking' },
|
||||||
|
{ key: 'hobobarrel_static', label: 'Hobo Barrel', description: 'Barrel fire for cooking' },
|
||||||
|
{ key: 'electricfurnace.deployed', label: 'Electric Furnace', description: 'Electricity-powered furnace' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this FurnaceSplitter config to the server? This will overwrite the current config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Furnace Splitter Config</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Flame class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No FurnaceSplitter Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Furnace Splitter Settings -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<SettingsIcon class="w-5 h-5 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Splitter Settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global enabled -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Enabled</label>
|
||||||
|
<p class="text-xs text-neutral-500">Globally enable or disable furnace splitting</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Enabled', !getConfigValue('Enabled', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-Furnace Type Settings -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Flame class="w-5 h-5 text-neutral-400" />
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Furnace Type Settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="furnace in furnaceTypes"
|
||||||
|
:key="furnace.key"
|
||||||
|
class="bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-neutral-200">{{ furnace.label }}</h4>
|
||||||
|
<p class="text-xs text-neutral-500">{{ furnace.description }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue(`Furnaces.${furnace.key}.Enabled`, !getConfigValue(`Furnaces.${furnace.key}.Enabled`, true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Default Split Stacks</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
|
||||||
|
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
placeholder="0 = fill all slots"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Fuel Multiplier</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
|
||||||
|
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permission Groups -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission</h3>
|
||||||
|
<p class="text-xs text-neutral-500">
|
||||||
|
The permission <code class="text-neutral-300 bg-neutral-800 px-1 rounded">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New FurnaceSplitter Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. Default Furnace Settings"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current FurnaceSplitter config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
534
frontend/src/views/admin/GatherManagerView.vue
Normal file
534
frontend/src/views/admin/GatherManagerView.vue
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useGatherStore } from '@/stores/gather'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Pickaxe,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useGatherStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'resources' | 'advanced'>('resources')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'resources', label: 'Resource Rates', icon: Pickaxe },
|
||||||
|
{ key: 'advanced', label: 'Advanced', icon: SettingsIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Resource definitions for the main gather tab
|
||||||
|
const gatherResources = [
|
||||||
|
{ key: 'Wood', label: 'Wood' },
|
||||||
|
{ key: 'Stones', label: 'Stones' },
|
||||||
|
{ key: 'Metal Ore', label: 'Metal Ore' },
|
||||||
|
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
||||||
|
{ key: 'HQM Ore', label: 'HQM Ore' },
|
||||||
|
{ key: 'Cloth', label: 'Cloth' },
|
||||||
|
{ key: 'Leather', label: 'Leather' },
|
||||||
|
{ key: 'Animal Fat', label: 'Animal Fat' },
|
||||||
|
{ key: 'Bone Fragments', label: 'Bone Fragments' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Advanced resource categories
|
||||||
|
const pickupResources = [
|
||||||
|
{ key: 'Wood', label: 'Wood' },
|
||||||
|
{ key: 'Stones', label: 'Stones' },
|
||||||
|
{ key: 'Metal Ore', label: 'Metal Ore' },
|
||||||
|
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const quarryResources = [
|
||||||
|
{ key: 'HQM Ore', label: 'HQM Ore' },
|
||||||
|
{ key: 'Metal Ore', label: 'Metal Ore' },
|
||||||
|
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
||||||
|
{ key: 'Stones', label: 'Stones' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const excavatorResources = [
|
||||||
|
{ key: 'HQM Ore', label: 'HQM Ore' },
|
||||||
|
{ key: 'Metal Ore', label: 'Metal Ore' },
|
||||||
|
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
||||||
|
{ key: 'Stones', label: 'Stones' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const surveyResources = [
|
||||||
|
{ key: 'Metal Ore', label: 'Metal Ore' },
|
||||||
|
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
||||||
|
{ key: 'Stones', label: 'Stones' },
|
||||||
|
{ key: 'HQM Ore', label: 'HQM Ore' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ label: '1x', value: 1 },
|
||||||
|
{ label: '2x', value: 2 },
|
||||||
|
{ label: '3x', value: 3 },
|
||||||
|
{ label: '5x', value: 5 },
|
||||||
|
{ label: '10x', value: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = 1): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Preset handler ---
|
||||||
|
|
||||||
|
function applyPreset(multiplier: number) {
|
||||||
|
for (const resource of gatherResources) {
|
||||||
|
setConfigValue(`GatherResourceModifiers.${resource.key}`, multiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this gather config to the server? This will overwrite the current GatherManager config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Gather Rates</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Pickaxe class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Gather Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key as typeof activeTab"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-oxide-500 text-oxide-400'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-4 h-4" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Rates Tab -->
|
||||||
|
<div v-if="activeTab === 'resources'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Gather Resource Modifiers</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-neutral-500 mr-2">Presets:</span>
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset.value"
|
||||||
|
@click="applyPreset(preset.value)"
|
||||||
|
class="px-3 py-1 text-xs bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="resource in gatherResources"
|
||||||
|
:key="resource.key"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500 w-4">x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Tab -->
|
||||||
|
<div v-else-if="activeTab === 'advanced'" class="space-y-6">
|
||||||
|
<!-- Pickup Resource Modifiers -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Pickup Resource Modifiers</h3>
|
||||||
|
<p class="text-xs text-neutral-500">Modify rates for resources picked up from the ground (small rocks, wood piles).</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="resource in pickupResources"
|
||||||
|
:key="resource.key"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500 w-4">x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quarry Resource Modifiers -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Quarry Resource Modifiers</h3>
|
||||||
|
<p class="text-xs text-neutral-500">Scale resource output from Mining Quarries.</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="resource in quarryResources"
|
||||||
|
:key="resource.key"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500 w-4">x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Excavator Resource Modifiers -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Excavator Resource Modifiers</h3>
|
||||||
|
<p class="text-xs text-neutral-500">Scale resource output from the Giant Excavator.</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="resource in excavatorResources"
|
||||||
|
:key="resource.key"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500 w-4">x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Survey Resource Modifiers -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Survey Charge Resource Modifiers</h3>
|
||||||
|
<p class="text-xs text-neutral-500">Modify resource amounts from Survey Charge grenades.</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="resource in surveyResources"
|
||||||
|
:key="resource.key"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
class="flex-1 accent-oxide-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500 w-4">x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Gather Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. 3x Gather Rates"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current GatherManager config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
778
frontend/src/views/admin/KitsView.vue
Normal file
778
frontend/src/views/admin/KitsView.vue
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useKitsStore } from '@/stores/kits'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Package,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Edit,
|
||||||
|
X,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useKitsStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'kits' | 'editor' | 'settings'>('kits')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
const editingKitIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'kits', label: 'Kits List', icon: Package },
|
||||||
|
{ key: 'editor', label: 'Kit Editor', icon: Edit },
|
||||||
|
{ key: 'settings', label: 'Settings', icon: SettingsIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kit List helpers ---
|
||||||
|
|
||||||
|
const kitsList = computed(() => {
|
||||||
|
return getConfigValue('Kits', []) as any[]
|
||||||
|
})
|
||||||
|
|
||||||
|
function addKit() {
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
kits.push({
|
||||||
|
Name: `New Kit ${kits.length + 1}`,
|
||||||
|
Description: '',
|
||||||
|
Permission: '',
|
||||||
|
Cooldown: 600,
|
||||||
|
MaxUses: 0,
|
||||||
|
IsHidden: false,
|
||||||
|
Items: [],
|
||||||
|
})
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
editingKitIndex.value = kits.length - 1
|
||||||
|
activeTab.value = 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
function editKit(index: number) {
|
||||||
|
editingKitIndex.value = index
|
||||||
|
activeTab.value = 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteKit(index: number) {
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
if (!confirm(`Delete kit "${kits[index]?.Name}"? This cannot be undone.`)) return
|
||||||
|
kits.splice(index, 1)
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
if (editingKitIndex.value === index) {
|
||||||
|
editingKitIndex.value = null
|
||||||
|
activeTab.value = 'kits'
|
||||||
|
} else if (editingKitIndex.value !== null && editingKitIndex.value > index) {
|
||||||
|
editingKitIndex.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kit Editor helpers ---
|
||||||
|
|
||||||
|
const currentKit = computed(() => {
|
||||||
|
if (editingKitIndex.value === null) return null
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
return kits[editingKitIndex.value] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
function setKitField(field: string, value: any) {
|
||||||
|
if (editingKitIndex.value === null) return
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
if (kits[editingKitIndex.value]) {
|
||||||
|
kits[editingKitIndex.value][field] = value
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKitItem() {
|
||||||
|
if (editingKitIndex.value === null) return
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
const kit = kits[editingKitIndex.value]
|
||||||
|
if (!kit) return
|
||||||
|
if (!kit.Items) kit.Items = []
|
||||||
|
kit.Items.push({
|
||||||
|
ShortName: '',
|
||||||
|
Amount: 1,
|
||||||
|
SkinId: 0,
|
||||||
|
Container: 'main',
|
||||||
|
Position: -1,
|
||||||
|
})
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKitItem(itemIndex: number) {
|
||||||
|
if (editingKitIndex.value === null) return
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
const kit = kits[editingKitIndex.value]
|
||||||
|
if (!kit?.Items) return
|
||||||
|
kit.Items.splice(itemIndex, 1)
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKitItemField(itemIndex: number, field: string, value: any) {
|
||||||
|
if (editingKitIndex.value === null) return
|
||||||
|
const kits = getConfigValue('Kits', []) as any[]
|
||||||
|
const kit = kits[editingKitIndex.value]
|
||||||
|
if (!kit?.Items?.[itemIndex]) return
|
||||||
|
kit.Items[itemIndex][field] = value
|
||||||
|
setConfigValue('Kits', kits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
editingKitIndex.value = null
|
||||||
|
activeTab.value = 'kits'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this kits config to the server? This will overwrite the current Kits config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Kits Config</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Kits Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key as typeof activeTab"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-oxide-500 text-oxide-400'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-4 h-4" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kits List Tab -->
|
||||||
|
<div v-if="activeTab === 'kits'" class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Defined Kits</h3>
|
||||||
|
<button
|
||||||
|
@click="addKit"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Kit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="kitsList.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
|
||||||
|
<p class="text-neutral-500">No kits defined yet. Add your first kit or import from server.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="(kit, index) in kitsList"
|
||||||
|
:key="index"
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-xl p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-neutral-200">{{ kit.Name || 'Unnamed Kit' }}</h4>
|
||||||
|
<p v-if="kit.Description" class="text-xs text-neutral-500 mt-1">{{ kit.Description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="editKit(index)"
|
||||||
|
class="p-1.5 text-neutral-400 hover:text-oxide-400 transition-colors"
|
||||||
|
title="Edit kit"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteKit(index)"
|
||||||
|
class="p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete kit"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Permission:</span>
|
||||||
|
<span class="text-neutral-300 ml-1">{{ kit.Permission || 'None' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Cooldown:</span>
|
||||||
|
<span class="text-neutral-300 ml-1">{{ kit.Cooldown || 0 }}s</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Max Uses:</span>
|
||||||
|
<span class="text-neutral-300 ml-1">{{ kit.MaxUses || 0 }} {{ (kit.MaxUses || 0) === 0 ? '(unlimited)' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-500">Items:</span>
|
||||||
|
<span class="text-neutral-300 ml-1">{{ (kit.Items || []).length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="kit.IsHidden" class="text-xs text-yellow-500/80">
|
||||||
|
Hidden (requires permission)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kit Editor Tab -->
|
||||||
|
<div v-else-if="activeTab === 'editor'" class="space-y-6">
|
||||||
|
<div v-if="!currentKit" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center">
|
||||||
|
<p class="text-neutral-500">Select a kit from the Kits List tab to edit it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Kit Metadata -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Kit Details</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Kit Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="currentKit.Name"
|
||||||
|
@input="setKitField('Name', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
placeholder="e.g. Starter Kit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Permission</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="currentKit.Permission"
|
||||||
|
@input="setKitField('Permission', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
placeholder="e.g. kits.vip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
:value="currentKit.Description"
|
||||||
|
@input="setKitField('Description', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
rows="2"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
placeholder="Kit description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="currentKit.Cooldown || 0"
|
||||||
|
@input="setKitField('Cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Max Uses (0 = unlimited)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="currentKit.MaxUses || 0"
|
||||||
|
@input="setKitField('MaxUses', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between pt-6">
|
||||||
|
<label class="text-sm text-neutral-200">Hidden (no perm = hidden)</label>
|
||||||
|
<button
|
||||||
|
@click="setKitField('IsHidden', !currentKit.IsHidden)"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="currentKit.IsHidden ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="currentKit.IsHidden ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kit Items -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Kit Items</h3>
|
||||||
|
<button
|
||||||
|
@click="addKitItem"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!currentKit.Items || currentKit.Items.length === 0" class="text-center py-4">
|
||||||
|
<p class="text-neutral-500 text-sm">No items in this kit. Click "Add Item" to get started.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, itemIdx) in (currentKit.Items as any[])"
|
||||||
|
:key="itemIdx"
|
||||||
|
class="flex items-center gap-3 bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div class="flex-1 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Short Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="item.ShortName"
|
||||||
|
@input="setKitItemField(itemIdx, 'ShortName', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
|
||||||
|
placeholder="e.g. rifle.ak"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Amount</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.Amount || 1"
|
||||||
|
@input="setKitItemField(itemIdx, 'Amount', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="1"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Skin ID</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.SkinId || 0"
|
||||||
|
@input="setKitItemField(itemIdx, 'SkinId', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Container</label>
|
||||||
|
<select
|
||||||
|
:value="item.Container || 'main'"
|
||||||
|
@change="setKitItemField(itemIdx, 'Container', ($event.target as HTMLSelectElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-neutral-200 text-xs"
|
||||||
|
>
|
||||||
|
<option value="main">Main</option>
|
||||||
|
<option value="wear">Wear</option>
|
||||||
|
<option value="belt">Belt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="removeKitItem(itemIdx)"
|
||||||
|
class="p-1.5 text-neutral-400 hover:text-red-400 transition-colors flex-shrink-0"
|
||||||
|
title="Remove item"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div v-else-if="activeTab === 'settings'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Kit Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Kit chat command -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Kit Chat Command</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">The chat command players use to access kits</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="getConfigValue('Kit chat command', 'kit')"
|
||||||
|
@input="setConfigValue('Kit chat command', ($event.target as HTMLInputElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Currency -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Currency for Purchase Costs</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Scrap, Economics, or ServerRewards</p>
|
||||||
|
<select
|
||||||
|
:value="getConfigValue('Currency used for purchase costs (Scrap, Economics, ServerRewards)', 'Scrap')"
|
||||||
|
@change="setConfigValue('Currency used for purchase costs (Scrap, Economics, ServerRewards)', ($event.target as HTMLSelectElement).value)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="Scrap">Scrap</option>
|
||||||
|
<option value="Economics">Economics</option>
|
||||||
|
<option value="ServerRewards">ServerRewards</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Log kits given -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Log Kits Given</label>
|
||||||
|
<p class="text-xs text-neutral-500">Log when kits are claimed by players</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Log kits given', !getConfigValue('Log kits given', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Log kits given', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Log kits given', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wipe data on wipe -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Wipe Player Data on Server Wipe</label>
|
||||||
|
<p class="text-xs text-neutral-500">Reset kit cooldowns and usage when server wipes</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Wipe player data when the server is wiped', !getConfigValue('Wipe player data when the server is wiped', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Wipe player data when the server is wiped', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Wipe player data when the server is wiped', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Use UI Menu -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Use Kits UI Menu</label>
|
||||||
|
<p class="text-xs text-neutral-500">Show the in-game kits UI menu to players</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Use the Kits UI menu', !getConfigValue('Use the Kits UI menu', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Use the Kits UI menu', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Use the Kits UI menu', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Allow autokits toggle -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Allow Auto-Kit Toggle</label>
|
||||||
|
<p class="text-xs text-neutral-500">Let players toggle auto-kits on spawn</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Allow players to toggle auto-kits on spawn', !getConfigValue('Allow players to toggle auto-kits on spawn', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Allow players to toggle auto-kits on spawn', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Allow players to toggle auto-kits on spawn', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show kits without perm -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Show Kits Without Permission</label>
|
||||||
|
<p class="text-xs text-neutral-500">Show permission-locked kits to players who lack them</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Show kits with permissions assigned to players without the permission', !getConfigValue('Show kits with permissions assigned to players without the permission', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Show kits with permissions assigned to players without the permission', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Show kits with permissions assigned to players without the permission', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin ignore restrictions -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Admins Ignore Restrictions</label>
|
||||||
|
<p class="text-xs text-neutral-500">Players with admin perm skip cooldown and usage limits</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Players with the admin permission ignore usage restrictions', !getConfigValue('Players with the admin permission ignore usage restrictions', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Players with the admin permission ignore usage restrictions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Players with the admin permission ignore usage restrictions', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-kits -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Auto-Kits (ordered by priority)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Comma-separated list of kit names given on respawn</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="(getConfigValue('Autokits ordered by priority', []) as string[]).join(', ')"
|
||||||
|
@input="setConfigValue('Autokits ordered by priority', ($event.target as HTMLInputElement).value.split(',').map((s: string) => s.trim()).filter((s: string) => s))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
placeholder="e.g. StarterKit, VIPKit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Kits Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. Default Kits"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current Kits config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Kits"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
185
frontend/src/views/admin/PluginConfigsView.vue
Normal file
185
frontend/src/views/admin/PluginConfigsView.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useLootStore } from '@/stores/loot'
|
||||||
|
import { useTeleportStore } from '@/stores/teleport'
|
||||||
|
import { useGatherStore } from '@/stores/gather'
|
||||||
|
import { useAutoDoorsStore } from '@/stores/autodoors'
|
||||||
|
import { useKitsStore } from '@/stores/kits'
|
||||||
|
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
||||||
|
import { useBetterChatStore } from '@/stores/betterchat'
|
||||||
|
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
||||||
|
import { useRaidableBasesStore } from '@/stores/raidablebases'
|
||||||
|
import {
|
||||||
|
Crosshair,
|
||||||
|
Navigation2,
|
||||||
|
Pickaxe,
|
||||||
|
DoorOpen,
|
||||||
|
Gift,
|
||||||
|
Flame,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
Swords,
|
||||||
|
Search,
|
||||||
|
ArrowRight,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const lootStore = useLootStore()
|
||||||
|
const teleportStore = useTeleportStore()
|
||||||
|
const gatherStore = useGatherStore()
|
||||||
|
const autoDoorsStore = useAutoDoorsStore()
|
||||||
|
const kitsStore = useKitsStore()
|
||||||
|
const furnaceSplitterStore = useFurnaceSplitterStore()
|
||||||
|
const betterChatStore = useBetterChatStore()
|
||||||
|
const timedExecuteStore = useTimedExecuteStore()
|
||||||
|
const raidableBasesStore = useRaidableBasesStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
interface PluginDef {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: any
|
||||||
|
path: string
|
||||||
|
permission: string
|
||||||
|
getConfigs: () => any[]
|
||||||
|
fetchFn: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins: PluginDef[] = [
|
||||||
|
{ key: 'loot', name: 'Loot Tables', description: 'Configure loot container drop tables and item probabilities', icon: Crosshair, path: '/loot-builder', permission: 'loot.view', getConfigs: () => lootStore.profiles, fetchFn: () => lootStore.fetchProfiles() },
|
||||||
|
{ key: 'teleport', name: 'Teleport', description: 'Home locations, TPR cooldowns, and VIP teleport settings', icon: Navigation2, path: '/teleport-config', permission: 'teleport.view', getConfigs: () => teleportStore.configs, fetchFn: () => teleportStore.fetchConfigs() },
|
||||||
|
{ key: 'gather', name: 'Gather Rates', description: 'Resource gathering multipliers and pickup rates', icon: Pickaxe, path: '/gather-manager', permission: 'gather.view', getConfigs: () => gatherStore.configs, fetchFn: () => gatherStore.fetchConfigs() },
|
||||||
|
{ key: 'autodoors', name: 'Auto Doors', description: 'Automatic door closing delays and permissions', icon: DoorOpen, path: '/autodoors', permission: 'autodoors.view', getConfigs: () => autoDoorsStore.configs, fetchFn: () => autoDoorsStore.fetchConfigs() },
|
||||||
|
{ key: 'kits', name: 'Kits', description: 'Player kits with items, cooldowns, and permissions', icon: Gift, path: '/kits', permission: 'kits.view', getConfigs: () => kitsStore.configs, fetchFn: () => kitsStore.fetchConfigs() },
|
||||||
|
{ key: 'furnacesplitter', name: 'Furnace Splitter', description: 'Automatic furnace ore splitting and smelting config', icon: Flame, path: '/furnace-splitter', permission: 'furnacesplitter.view', getConfigs: () => furnaceSplitterStore.configs, fetchFn: () => furnaceSplitterStore.fetchConfigs() },
|
||||||
|
{ key: 'betterchat', name: 'Better Chat', description: 'Chat formatting, group colors, and title prefixes', icon: MessageSquare, path: '/better-chat', permission: 'betterchat.view', getConfigs: () => betterChatStore.configs, fetchFn: () => betterChatStore.fetchConfigs() },
|
||||||
|
{ key: 'timedexecute', name: 'Timed Execute', description: 'Scheduled, real-time, and event-driven command execution', icon: Clock, path: '/timed-execute', permission: 'timedexecute.view', getConfigs: () => timedExecuteStore.configs, fetchFn: () => timedExecuteStore.fetchConfigs() },
|
||||||
|
{ key: 'raidablebases', name: 'Raidable Bases', description: 'PVE raid events, difficulty, NPCs, and loot settings', icon: Swords, path: '/raidable-bases', permission: 'raidablebases.view', getConfigs: () => raidableBasesStore.configs, fetchFn: () => raidableBasesStore.fetchConfigs() },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visiblePlugins = computed(() =>
|
||||||
|
plugins.filter(p => auth.hasPermission(p.permission))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredPlugins = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return visiblePlugins.value
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return visiblePlugins.value.filter(
|
||||||
|
p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatus(plugin: PluginDef): { label: string; color: string } {
|
||||||
|
const configs = plugin.getConfigs()
|
||||||
|
if (!configs || configs.length === 0) return { label: 'Not Configured', color: 'neutral' }
|
||||||
|
const hasActive = configs.some((c: any) => c.is_active)
|
||||||
|
if (hasActive) return { label: 'Active', color: 'green' }
|
||||||
|
return { label: 'Configured', color: 'blue' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigCount(plugin: PluginDef): string {
|
||||||
|
const configs = plugin.getConfigs()
|
||||||
|
if (!configs || configs.length === 0) return 'No profiles'
|
||||||
|
return `${configs.length} profile${configs.length !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const fetches = visiblePlugins.value.map(p => p.fetchFn().catch(() => {}))
|
||||||
|
await Promise.all(fetches)
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 max-w-7xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Plugin Configs</h1>
|
||||||
|
<p class="text-neutral-400 mt-1">Configure and manage your server plugins</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative mb-6">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search plugins..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 bg-neutral-900 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-oxide-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="i in visiblePlugins.length"
|
||||||
|
:key="i"
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 animate-pulse"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-4 w-24 bg-neutral-800 rounded" />
|
||||||
|
<div class="h-3 w-16 bg-neutral-800 rounded mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 w-full bg-neutral-800 rounded mt-3" />
|
||||||
|
<div class="h-3 w-2/3 bg-neutral-800 rounded mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards -->
|
||||||
|
<div v-else-if="filteredPlugins.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="plugin in filteredPlugins"
|
||||||
|
:key="plugin.key"
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors group"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-neutral-800 rounded-lg flex items-center justify-center group-hover:bg-oxide-500/10 transition-colors">
|
||||||
|
<component :is="plugin.icon" class="w-5 h-5 text-neutral-400 group-hover:text-oxide-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-white">{{ plugin.name }}</h3>
|
||||||
|
<span class="text-xs text-neutral-500">{{ getConfigCount(plugin) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium px-2 py-0.5 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500/10 text-green-400': getStatus(plugin).color === 'green',
|
||||||
|
'bg-blue-500/10 text-blue-400': getStatus(plugin).color === 'blue',
|
||||||
|
'bg-neutral-800 text-neutral-500': getStatus(plugin).color === 'neutral',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ getStatus(plugin).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-neutral-400 mb-4 line-clamp-2">{{ plugin.description }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="router.push(plugin.path)"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-3 py-2 bg-neutral-800 hover:bg-oxide-500/10 text-neutral-300 hover:text-oxide-400 text-xs font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
<ArrowRight class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else class="text-center py-12">
|
||||||
|
<p class="text-neutral-500">No plugins match your search.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
1143
frontend/src/views/admin/RaidableBasesView.vue
Normal file
1143
frontend/src/views/admin/RaidableBasesView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
@@ -34,6 +35,8 @@ const setupTab = ref<'linux' | 'windows'>('linux')
|
|||||||
const windowsCopied = ref(false)
|
const windowsCopied = ref(false)
|
||||||
const showDeployForm = ref(false)
|
const showDeployForm = ref(false)
|
||||||
const deployLoading = ref(false)
|
const deployLoading = ref(false)
|
||||||
|
const oxideStatus = ref<{ stage: string; progress: number; message: string; error?: string } | null>(null)
|
||||||
|
const isInstallingOxide = ref(false)
|
||||||
|
|
||||||
const deployForm = ref<DeploymentConfig>({
|
const deployForm = ref<DeploymentConfig>({
|
||||||
server_name: 'My Rust Server',
|
server_name: 'My Rust Server',
|
||||||
@@ -141,6 +144,42 @@ function getStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'f
|
|||||||
return 'pending'
|
return 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oxideStages = [
|
||||||
|
{ key: 'fetching_release', label: 'Check Latest Release' },
|
||||||
|
{ key: 'downloading', label: 'Download Oxide' },
|
||||||
|
{ key: 'installing', label: 'Extract Files' },
|
||||||
|
{ key: 'restarting', label: 'Restart Server' },
|
||||||
|
{ key: 'complete', label: 'Complete' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function getOxideStageState(stageKey: string): 'pending' | 'active' | 'complete' | 'failed' {
|
||||||
|
if (!oxideStatus.value) return 'pending'
|
||||||
|
const status = oxideStatus.value
|
||||||
|
if (status.stage === 'failed') {
|
||||||
|
const currentStages = oxideStages
|
||||||
|
const idx = currentStages.findIndex(s => s.key === stageKey)
|
||||||
|
// Find which stage was active when failure occurred — approximate from message
|
||||||
|
// For failed state, mark all stages before current as complete
|
||||||
|
return idx === 0 ? 'failed' : 'pending'
|
||||||
|
}
|
||||||
|
const currentIdx = oxideStages.findIndex(s => s.key === status.stage)
|
||||||
|
const thisIdx = oxideStages.findIndex(s => s.key === stageKey)
|
||||||
|
if (thisIdx < currentIdx) return 'complete'
|
||||||
|
if (thisIdx === currentIdx) return status.stage === 'complete' ? 'complete' : 'active'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installOxide() {
|
||||||
|
isInstallingOxide.value = true
|
||||||
|
oxideStatus.value = null
|
||||||
|
try {
|
||||||
|
await server.installOxide()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start Oxide installation')
|
||||||
|
isInstallingOxide.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
server_name: '',
|
server_name: '',
|
||||||
max_players: 0,
|
max_players: 0,
|
||||||
@@ -207,6 +246,12 @@ onMounted(async () => {
|
|||||||
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
||||||
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'event' && msg.event === 'oxide_status') {
|
||||||
|
oxideStatus.value = msg.data as { stage: string; progress: number; message: string; error?: string }
|
||||||
|
if (msg.data && (msg.data as any).stage === 'complete' || (msg.data as any).stage === 'failed') {
|
||||||
|
isInstallingOxide.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -544,6 +589,82 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Install Oxide/uMod -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-5">
|
||||||
|
<Puzzle class="w-4 h-4 text-oxide-400" />
|
||||||
|
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Install Oxide / uMod</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Installation Progress Tracker -->
|
||||||
|
<div v-if="oxideStatus || isInstallingOxide" class="mb-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="stage in oxideStages"
|
||||||
|
:key="stage.key"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<!-- Stage indicator -->
|
||||||
|
<div class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||||
|
:class="{
|
||||||
|
'bg-neutral-800 text-neutral-600': getOxideStageState(stage.key) === 'pending',
|
||||||
|
'bg-amber-500/20 text-amber-400': getOxideStageState(stage.key) === 'active',
|
||||||
|
'bg-green-500/20 text-green-400': getOxideStageState(stage.key) === 'complete',
|
||||||
|
'bg-red-500/20 text-red-400': getOxideStageState(stage.key) === 'failed',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="getOxideStageState(stage.key) === 'active'" class="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<Check v-else-if="getOxideStageState(stage.key) === 'complete'" class="w-3.5 h-3.5" />
|
||||||
|
<AlertTriangle v-else-if="getOxideStageState(stage.key) === 'failed'" class="w-3.5 h-3.5" />
|
||||||
|
<span v-else class="w-1.5 h-1.5 rounded-full bg-neutral-600" />
|
||||||
|
</div>
|
||||||
|
<!-- Stage label -->
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-neutral-600': getOxideStageState(stage.key) === 'pending',
|
||||||
|
'text-amber-300 font-medium': getOxideStageState(stage.key) === 'active',
|
||||||
|
'text-green-400': getOxideStageState(stage.key) === 'complete',
|
||||||
|
'text-red-400': getOxideStageState(stage.key) === 'failed',
|
||||||
|
}"
|
||||||
|
>{{ stage.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message -->
|
||||||
|
<div v-if="oxideStatus?.message" class="mt-4 px-3 py-2 bg-neutral-800/50 rounded-lg">
|
||||||
|
<p class="text-xs text-neutral-400">{{ oxideStatus.message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div v-if="oxideStatus?.error" class="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||||
|
<p class="text-xs text-red-400">{{ oxideStatus.error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retry button on failure -->
|
||||||
|
<button
|
||||||
|
v-if="oxideStatus?.stage === 'failed'"
|
||||||
|
@click="oxideStatus = null; installOxide()"
|
||||||
|
class="mt-3 flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw class="w-4 h-4" />
|
||||||
|
Retry Installation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install Button (shown when not installing) -->
|
||||||
|
<div v-else class="text-center py-4">
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">Install or update Oxide/uMod. Required for all plugins including CorrosionCompanion.</p>
|
||||||
|
<button
|
||||||
|
@click="installOxide()"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Puzzle class="w-4 h-4" />
|
||||||
|
Install Oxide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
691
frontend/src/views/admin/TeleportConfigView.vue
Normal file
691
frontend/src/views/admin/TeleportConfigView.vue
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useTeleportStore } from '@/stores/teleport'
|
||||||
|
import PermissionGroupEditor from '@/components/teleport/PermissionGroupEditor.vue'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Navigation2,
|
||||||
|
Home,
|
||||||
|
Users,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useTeleportStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'general' | 'homes' | 'tpr' | 'vip'>('general')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'general', label: 'General', icon: SettingsIcon },
|
||||||
|
{ key: 'homes', label: 'Homes', icon: Home },
|
||||||
|
{ key: 'tpr', label: 'TPR', icon: Navigation2 },
|
||||||
|
{ key: 'vip', label: 'VIP Groups', icon: Users },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this teleport config to the server? This will overwrite the current NTeleportation config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionGroupUpdate(updatedData: Record<string, any>) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
store.currentConfig.config_data = updatedData
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Teleport Config</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Navigation2 class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Teleport Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key as typeof activeTab"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-oxide-500 text-oxide-400'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-4 h-4" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General Tab -->
|
||||||
|
<div v-if="activeTab === 'general'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">General Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- UseEconomics -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Use Economics</label>
|
||||||
|
<p class="text-xs text-neutral-500">Charge players for teleports via Economics plugin</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.UseEconomics', !getConfigValue('Settings.UseEconomics', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.UseEconomics', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.UseEconomics', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UseServerRewards -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Use Server Rewards</label>
|
||||||
|
<p class="text-xs text-neutral-500">Charge players via ServerRewards plugin</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.UseServerRewards', !getConfigValue('Settings.UseServerRewards', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.UseServerRewards', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.UseServerRewards', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CheckBoundaries -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Cave/Water boundary checks</label>
|
||||||
|
<p class="text-xs text-neutral-500">Prevent teleporting into caves or underwater</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.CheckBoundaries', !getConfigValue('Settings.CheckBoundaries', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.CheckBoundaries', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- InterruptTPOnHostile -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Cancel TP if hostile timer</label>
|
||||||
|
<p class="text-xs text-neutral-500">Cancel pending teleport if player becomes hostile</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.InterruptTPOnHostile', !getConfigValue('Settings.InterruptTPOnHostile', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.InterruptTPOnHostile', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WipeHomesOnUpgrade -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Wipe homes on map update</label>
|
||||||
|
<p class="text-xs text-neutral-500">Clear all home locations when the map changes</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.WipeHomesOnUpgrade', !getConfigValue('Settings.WipeHomesOnUpgrade', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.WipeHomesOnUpgrade', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PlayersOnlyCannotTeleport -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Players Only Cannot Teleport</label>
|
||||||
|
<p class="text-xs text-neutral-500">Restrict teleport to specific player groups only</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Settings.PlayersOnlyCannotTeleport', !getConfigValue('Settings.PlayersOnlyCannotTeleport', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Settings.PlayersOnlyCannotTeleport', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Cooldown (number) -->
|
||||||
|
<div class="max-w-sm">
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Global cooldown (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Minimum time between any teleport commands</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('Settings.GlobalTeleportCooldown', 0)"
|
||||||
|
@input="setConfigValue('Settings.GlobalTeleportCooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Homes Tab -->
|
||||||
|
<div v-else-if="activeTab === 'homes'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Home Teleport Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- UsableOutOfBuildingBlocked -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Can use outside building privilege</label>
|
||||||
|
<p class="text-xs text-neutral-500">Allow home teleport even without building privilege</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Home.UsableOutOfBuildingBlocked', !getConfigValue('Home.UsableOutOfBuildingBlocked', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Home.UsableOutOfBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ForceOnTopOfFoundation -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Force home on foundation</label>
|
||||||
|
<p class="text-xs text-neutral-500">Homes can only be set on a foundation block</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Home.ForceOnTopOfFoundation', !getConfigValue('Home.ForceOnTopOfFoundation', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Home.ForceOnTopOfFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CheckFoundationForOwner -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Verify foundation ownership</label>
|
||||||
|
<p class="text-xs text-neutral-500">Only allow homes on foundations the player owns</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Home.CheckFoundationForOwner', !getConfigValue('Home.CheckFoundationForOwner', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Home.CheckFoundationForOwner', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AllowAboveFoundation -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Allow Above Foundation</label>
|
||||||
|
<p class="text-xs text-neutral-500">Allow setting homes above foundation level</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Home.AllowAboveFoundation', !getConfigValue('Home.AllowAboveFoundation', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Home.AllowAboveFoundation', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CupOwnerAllowOnBuildingBlocked -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Cupboard Owner Allow on Building Blocked</label>
|
||||||
|
<p class="text-xs text-neutral-500">Allow TC owners to teleport even when building blocked</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('Home.CupOwnerAllowOnBuildingBlocked', !getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('Home.CupOwnerAllowOnBuildingBlocked', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number Inputs -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Homes Limit</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Default max homes per player</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('Home.HomesLimit', 3)"
|
||||||
|
@input="setConfigValue('Home.HomesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Max home teleports per day</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('Home.DefaultDailyLimit', 5)"
|
||||||
|
@input="setConfigValue('Home.DefaultDailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Time between home teleports</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('Home.DefaultCooldown', 600)"
|
||||||
|
@input="setConfigValue('Home.DefaultCooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('Home.DefaultCountdown', 5)"
|
||||||
|
@input="setConfigValue('Home.DefaultCountdown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TPR Tab -->
|
||||||
|
<div v-else-if="activeTab === 'tpr'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Teleport Request Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- BlockTPAOnCeiling -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Block TP accept on ceiling</label>
|
||||||
|
<p class="text-xs text-neutral-500">Prevent accepting a TP while on a ceiling tile</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('TPR.BlockTPAOnCeiling', !getConfigValue('TPR.BlockTPAOnCeiling', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('TPR.BlockTPAOnCeiling', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OffsetTPRTarget -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Offset teleport target position</label>
|
||||||
|
<p class="text-xs text-neutral-500">Slightly offset the teleport landing position</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('TPR.OffsetTPRTarget', !getConfigValue('TPR.OffsetTPRTarget', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('TPR.OffsetTPRTarget', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AutoAcceptEnabled -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-neutral-200">Auto Accept Enabled</label>
|
||||||
|
<p class="text-xs text-neutral-500">Automatically accept incoming TP requests</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('TPR.AutoAcceptEnabled', !getConfigValue('TPR.AutoAcceptEnabled', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('TPR.AutoAcceptEnabled', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number Inputs -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Cooldown (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Cooldown between TPR requests</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('TPR.Cooldown', 600)"
|
||||||
|
@input="setConfigValue('TPR.Cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Countdown (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Countdown before teleport</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('TPR.Countdown', 5)"
|
||||||
|
@input="setConfigValue('TPR.Countdown', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Daily Limit</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">Max TPR per day</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('TPR.DailyLimit', 5)"
|
||||||
|
@input="setConfigValue('TPR.DailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-200 mb-1">Request Duration (seconds)</label>
|
||||||
|
<p class="text-xs text-neutral-500 mb-2">How long a TPR request lasts</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="getConfigValue('TPR.RequestDuration', 30)"
|
||||||
|
@input="setConfigValue('TPR.RequestDuration', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
min="0"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIP Groups Tab -->
|
||||||
|
<div v-else-if="activeTab === 'vip'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||||
|
<PermissionGroupEditor
|
||||||
|
:config-data="store.currentConfig.config_data"
|
||||||
|
@update:config-data="handlePermissionGroupUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Teleport Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. Default TP Settings"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current NTeleportation config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
655
frontend/src/views/admin/TimedExecuteView.vue
Normal file
655
frontend/src/views/admin/TimedExecuteView.vue
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Play,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Clock,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
UserPlus,
|
||||||
|
UserMinus,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const store = useTimedExecuteStore()
|
||||||
|
|
||||||
|
const activeTab = ref<'timed' | 'realtime' | 'connect' | 'disconnect'>('timed')
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const newConfigName = ref('')
|
||||||
|
const newConfigDesc = ref('')
|
||||||
|
const importConfigName = ref('')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'timed', label: 'Timed Commands', icon: Clock },
|
||||||
|
{ key: 'realtime', label: 'Real-Time', icon: SettingsIcon },
|
||||||
|
{ key: 'connect', label: 'On Connect', icon: UserPlus },
|
||||||
|
{ key: 'disconnect', label: 'On Disconnect', icon: UserMinus },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConfigs()
|
||||||
|
if (store.configs.length > 0 && store.configs[0]) {
|
||||||
|
await store.loadConfig(store.configs[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Config data helpers ---
|
||||||
|
|
||||||
|
function getConfigValue(path: string, defaultValue: any = false): any {
|
||||||
|
if (!store.currentConfig?.config_data) return defaultValue
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return defaultValue
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
return current ?? defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfigValue(path: string, value: any) {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!store.currentConfig.config_data) store.currentConfig.config_data = {}
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: any = store.currentConfig.config_data
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const part = parts[i]!
|
||||||
|
if (current[part] == null || typeof current[part] !== 'object') {
|
||||||
|
current[part] = {}
|
||||||
|
}
|
||||||
|
current = current[part]
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]!] = value
|
||||||
|
store.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TimerRepeat helpers ---
|
||||||
|
|
||||||
|
const timerRepeatEntries = computed(() => {
|
||||||
|
const obj = getConfigValue('TimerRepeat', {}) as Record<string, number>
|
||||||
|
return Object.entries(obj).map(([command, interval]) => ({ command, interval }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function addTimerRepeat() {
|
||||||
|
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
|
||||||
|
obj['new.command'] = 300
|
||||||
|
setConfigValue('TimerRepeat', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimerRepeatCommand(oldCmd: string, newCmd: string) {
|
||||||
|
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
|
||||||
|
const interval = obj[oldCmd] ?? 300
|
||||||
|
delete obj[oldCmd]
|
||||||
|
obj[newCmd] = interval
|
||||||
|
setConfigValue('TimerRepeat', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimerRepeatInterval(cmd: string, interval: number) {
|
||||||
|
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
|
||||||
|
obj[cmd] = interval
|
||||||
|
setConfigValue('TimerRepeat', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTimerRepeat(cmd: string) {
|
||||||
|
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
|
||||||
|
delete obj[cmd]
|
||||||
|
setConfigValue('TimerRepeat', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPresetTimer(command: string, interval: number) {
|
||||||
|
const obj = { ...getConfigValue('TimerRepeat', {}) as Record<string, number> }
|
||||||
|
obj[command] = interval
|
||||||
|
setConfigValue('TimerRepeat', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RealTime-Timer helpers ---
|
||||||
|
|
||||||
|
const realTimeEntries = computed(() => {
|
||||||
|
const obj = getConfigValue('RealTime-Timer', {}) as Record<string, string>
|
||||||
|
return Object.entries(obj).map(([time, command]) => ({ time, command }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function addRealTimeEntry() {
|
||||||
|
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
|
||||||
|
obj['12:00:00'] = 'say Scheduled message'
|
||||||
|
setConfigValue('RealTime-Timer', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRealTimeTime(oldTime: string, newTime: string) {
|
||||||
|
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
|
||||||
|
const command = obj[oldTime] ?? ''
|
||||||
|
delete obj[oldTime]
|
||||||
|
obj[newTime] = command
|
||||||
|
setConfigValue('RealTime-Timer', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRealTimeCommand(time: string, command: string) {
|
||||||
|
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
|
||||||
|
obj[time] = command
|
||||||
|
setConfigValue('RealTime-Timer', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRealTimeEntry(time: string) {
|
||||||
|
const obj = { ...getConfigValue('RealTime-Timer', {}) as Record<string, string> }
|
||||||
|
delete obj[time]
|
||||||
|
setConfigValue('RealTime-Timer', obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OnPlayerConnected helpers ---
|
||||||
|
|
||||||
|
const connectCommands = computed(() => {
|
||||||
|
return (getConfigValue('OnPlayerConnectCommands', []) as string[])
|
||||||
|
})
|
||||||
|
|
||||||
|
function addConnectCommand() {
|
||||||
|
const cmds = [...connectCommands.value, '']
|
||||||
|
setConfigValue('OnPlayerConnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectCommand(index: number, value: string) {
|
||||||
|
const cmds = [...connectCommands.value]
|
||||||
|
cmds[index] = value
|
||||||
|
setConfigValue('OnPlayerConnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnectCommand(index: number) {
|
||||||
|
const cmds = [...connectCommands.value]
|
||||||
|
cmds.splice(index, 1)
|
||||||
|
setConfigValue('OnPlayerConnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OnPlayerDisconnected helpers ---
|
||||||
|
|
||||||
|
const disconnectCommands = computed(() => {
|
||||||
|
return (getConfigValue('OnPlayerDisconnectCommands', []) as string[])
|
||||||
|
})
|
||||||
|
|
||||||
|
function addDisconnectCommand() {
|
||||||
|
const cmds = [...disconnectCommands.value, '']
|
||||||
|
setConfigValue('OnPlayerDisconnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisconnectCommand(index: number, value: string) {
|
||||||
|
const cmds = [...disconnectCommands.value]
|
||||||
|
cmds[index] = value
|
||||||
|
setConfigValue('OnPlayerDisconnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDisconnectCommand(index: number) {
|
||||||
|
const cmds = [...disconnectCommands.value]
|
||||||
|
cmds.splice(index, 1)
|
||||||
|
setConfigValue('OnPlayerDisconnectCommands', cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Action handlers ---
|
||||||
|
|
||||||
|
async function handleConfigChange(id: string) {
|
||||||
|
if (store.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await store.loadConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateConfig() {
|
||||||
|
if (!newConfigName.value.trim()) return
|
||||||
|
const config = await store.createConfig(
|
||||||
|
newConfigName.value.trim(),
|
||||||
|
newConfigDesc.value.trim() || undefined,
|
||||||
|
)
|
||||||
|
if (config) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newConfigName.value = ''
|
||||||
|
newConfigDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfig() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm(`Delete "${store.currentConfig.config_name}"? This cannot be undone.`)) return
|
||||||
|
await store.deleteConfig(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!store.currentConfig) return
|
||||||
|
if (!confirm('Apply this TimedExecute config to the server? This will overwrite the current config.')) return
|
||||||
|
if (store.isDirty) {
|
||||||
|
await store.saveCurrentConfig()
|
||||||
|
}
|
||||||
|
await store.applyToServer(store.currentConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importConfigName.value.trim()) return
|
||||||
|
const config = await store.importFromServer(importConfigName.value.trim())
|
||||||
|
if (config) {
|
||||||
|
showImportModal.value = false
|
||||||
|
importConfigName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Timed Execute</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Selector + Action Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Config Selector -->
|
||||||
|
<select
|
||||||
|
v-if="store.configs.length > 0"
|
||||||
|
:value="store.currentConfig?.id || ''"
|
||||||
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
|
{{ c.config_name }}
|
||||||
|
<template v-if="c.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply to Server -->
|
||||||
|
<button
|
||||||
|
@click="handleApply"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import from Server -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Config Selected -->
|
||||||
|
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Clock class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No TimedExecute Config Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Editor -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-neutral-800">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
@click="activeTab = tab.key as typeof activeTab"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === tab.key
|
||||||
|
? 'border-oxide-500 text-oxide-400'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-4 h-4" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timed Commands Tab -->
|
||||||
|
<div v-if="activeTab === 'timed'" class="space-y-4">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Timer Repeat</h3>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">Commands executed repeatedly at set intervals (seconds)</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Enable toggle -->
|
||||||
|
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('EnableTimerRepeat', !getConfigValue('EnableTimerRepeat', true))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('EnableTimerRepeat', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('EnableTimerRepeat', true) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Buttons -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-xs text-neutral-500">Quick add:</span>
|
||||||
|
<button
|
||||||
|
@click="addPresetTimer('server.save', 300)"
|
||||||
|
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
server.save (5m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="addPresetTimer('say Server restart warning!', 3600)"
|
||||||
|
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
Restart warning (1h)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="addPresetTimer('oxide.reload *', 7200)"
|
||||||
|
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
Reload plugins (2h)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entries -->
|
||||||
|
<div v-if="timerRepeatEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
||||||
|
No timed commands configured. Add a command or use a preset above.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in timerRepeatEntries"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="entry.command"
|
||||||
|
@change="updateTimerRepeatCommand(entry.command, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="console command"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
:value="entry.interval"
|
||||||
|
@input="updateTimerRepeatInterval(entry.command, Number(($event.target as HTMLInputElement).value))"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="w-24 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-neutral-200 text-sm text-right"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-neutral-500">sec</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="removeTimerRepeat(entry.command)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="addTimerRepeat"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-Time Tab -->
|
||||||
|
<div v-else-if="activeTab === 'realtime'" class="space-y-4">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Real-Time Timer</h3>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">Commands executed at specific times of day (HH:MM:SS)</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
|
||||||
|
<button
|
||||||
|
@click="setConfigValue('EnableRealTime-Timer', !getConfigValue('EnableRealTime-Timer', false))"
|
||||||
|
class="relative w-11 h-6 rounded-full transition-colors"
|
||||||
|
:class="getConfigValue('EnableRealTime-Timer', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
||||||
|
:class="getConfigValue('EnableRealTime-Timer', false) ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="realTimeEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
||||||
|
No real-time commands configured. Add a time-based command below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in realTimeEntries"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="entry.time"
|
||||||
|
@change="updateRealTimeTime(entry.time, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="HH:MM:SS"
|
||||||
|
class="w-32 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="entry.command"
|
||||||
|
@change="updateRealTimeCommand(entry.time, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="console command"
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeRealTimeEntry(entry.time)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="addRealTimeEntry"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Time Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- On Connect Tab -->
|
||||||
|
<div v-else-if="activeTab === 'connect'" class="space-y-4">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Connect</h3>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player joins the server</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="connectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
||||||
|
No connect commands configured. Add a command below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(cmd, index) in connectCommands"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="cmd"
|
||||||
|
@change="updateConnectCommand(index, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder='e.g. say Welcome {player.name}!'
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeConnectCommand(index)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="addConnectCommand"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- On Disconnect Tab -->
|
||||||
|
<div v-else-if="activeTab === 'disconnect'" class="space-y-4">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Disconnect</h3>
|
||||||
|
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player leaves the server</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="disconnectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
||||||
|
No disconnect commands configured. Add a command below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(cmd, index) in disconnectCommands"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="cmd"
|
||||||
|
@change="updateDisconnectCommand(index, ($event.target as HTMLInputElement).value)"
|
||||||
|
placeholder='e.g. say {player.name} has left'
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeDisconnectCommand(index)"
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="addDisconnectCommand"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Config Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New TimedExecute Config</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newConfigName"
|
||||||
|
placeholder="e.g. Default Timer Settings"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateConfig"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newConfigDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this config for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateConfig"
|
||||||
|
:disabled="!newConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import from Server Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
||||||
|
<p class="text-sm text-neutral-400 mb-4">
|
||||||
|
Import the current TimedExecute config from your live server. This will create a new config profile.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importConfigName"
|
||||||
|
placeholder="e.g. Imported Server Config"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importConfigName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
1555
plugin/CorrosionTeleportGUI.cs
Normal file
1555
plugin/CorrosionTeleportGUI.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user