From cbb3ba658649d17757f8c9605f99e8d277a07ebb Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 16:02:49 -0500 Subject: [PATCH] feat: Wire execution engines for schedules, alerts, wipes, and module install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schedules/: Add schedule executor in SchedulesService — polls every 60s for tasks where next_run <= now, dispatches NATS commands per task_type (restart, announcement, command, plugin_reload). Calculates next_run from cron expression on create/update/toggle. Bootstraps missing next_run values on startup. Wire NatsService into SchedulesModule. - alerts/: Add alert evaluator in AlertsService — polls every 90s, loads all alert_config rows, queries latest server_stats per license, evaluates FPS degradation and population drop thresholds. Fires alert_history records on breach. Enforces 10-minute in-memory cooldown per alert type per license to prevent flooding. Wire ServerStats repo into AlertsModule. - wipes/: Replace hardcoded dry-run mock with profile-aware simulation. Resolves actual WipeProfile by ID (cross-tenant protected), builds would_delete/would_preserve lists from wipe_type, factors pre_wipe_config (backup, countdown warnings) and post_wipe_config (health checks, retry attempts) into estimated_duration_seconds. Returns profile_name and notes. - store/: Fix installModule stub — creates a real module_installations record with status='installed' and installed_at timestamp. Idempotent on retry, resets failed installations. Wire ModuleInstallation repo into StoreModule. getMyModules now returns real installation data instead of filtered purchases. Co-Authored-By: Claude Opus 4.6 --- .../src/modules/alerts/alerts.module.ts | 3 +- .../src/modules/alerts/alerts.service.ts | 184 ++++++++++++- .../src/modules/schedules/schedules.module.ts | 3 +- .../modules/schedules/schedules.service.ts | 251 +++++++++++++++--- .../src/modules/store/store.module.ts | 3 +- .../src/modules/store/store.service.ts | 63 ++++- .../src/modules/wipes/wipes.service.ts | 115 +++++++- 7 files changed, 564 insertions(+), 58 deletions(-) diff --git a/backend-nest/src/modules/alerts/alerts.module.ts b/backend-nest/src/modules/alerts/alerts.module.ts index a8b8051..d6eacdd 100644 --- a/backend-nest/src/modules/alerts/alerts.module.ts +++ b/backend-nest/src/modules/alerts/alerts.module.ts @@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller'; import { AlertsService } from './alerts.service'; import { AlertConfig } from '../../entities/alert-config.entity'; import { AlertHistory } from '../../entities/alert-history.entity'; +import { ServerStats } from '../../entities/server-stats.entity'; @Module({ - imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])], + imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])], controllers: [AlertsController], providers: [AlertsService], exports: [AlertsService], diff --git a/backend-nest/src/modules/alerts/alerts.service.ts b/backend-nest/src/modules/alerts/alerts.service.ts index 7d74c2d..8847783 100644 --- a/backend-nest/src/modules/alerts/alerts.service.ts +++ b/backend-nest/src/modules/alerts/alerts.service.ts @@ -1,26 +1,204 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AlertConfig } from '../../entities/alert-config.entity'; import { AlertHistory } from '../../entities/alert-history.entity'; +import { ServerStats } from '../../entities/server-stats.entity'; import { UpdateAlertConfigDto } from './dto/update-alert-config.dto'; +/** Track the last time an alert of a given type fired per license, for cooldown enforcement. */ +const ALERT_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes between identical alerts + @Injectable() -export class AlertsService { +export class AlertsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AlertsService.name); + private evaluatorInterval: ReturnType | null = null; + + /** Map of `${licenseId}:${alertType}` → last triggered timestamp */ + private readonly cooldowns = new Map(); + constructor( @InjectRepository(AlertConfig) private readonly alertConfigRepo: Repository, @InjectRepository(AlertHistory) private readonly alertHistoryRepo: Repository, + @InjectRepository(ServerStats) + private readonly serverStatsRepo: Repository, ) {} + // --------------------------------------------------------------------------- + // Lifecycle hooks + // --------------------------------------------------------------------------- + + onModuleInit() { + // Poll every 90 seconds. + this.evaluatorInterval = setInterval(() => { + this.evaluateAllAlerts().catch(err => + this.logger.error('Alert evaluator error', err), + ); + }, 90_000); + + this.logger.log('Alert evaluator started (90s polling interval)'); + } + + onModuleDestroy() { + if (this.evaluatorInterval) { + clearInterval(this.evaluatorInterval); + this.evaluatorInterval = null; + } + } + + // --------------------------------------------------------------------------- + // Alert evaluation engine + // --------------------------------------------------------------------------- + + private async evaluateAllAlerts(): Promise { + // Load all alert configs in one query. + const configs = await this.alertConfigRepo.find(); + + if (configs.length === 0) return; + + for (const config of configs) { + try { + await this.evaluateForLicense(config); + } catch (err) { + this.logger.error( + `Alert evaluation failed for license ${config.license_id}`, + (err as Error).stack, + ); + } + } + } + + private async evaluateForLicense(config: AlertConfig): Promise { + // Pull the most recent server_stats record for this license. + const stats = await this.serverStatsRepo.findOne({ + where: { license_id: config.license_id }, + order: { recorded_at: 'DESC' }, + }); + + if (!stats) return; // No data yet — can't evaluate. + + const now = Date.now(); + + // --- FPS degradation alert --- + if (config.fps_degradation_enabled && stats.fps > 0) { + if (stats.fps < config.fps_threshold) { + await this.maybeFireAlert( + config, + 'fps_degradation', + 'warning', + 'FPS Degradation Detected', + `Server FPS dropped to ${stats.fps.toFixed(1)}, below threshold of ${config.fps_threshold}`, + { + current_fps: stats.fps, + threshold: config.fps_threshold, + player_count: stats.player_count, + recorded_at: stats.recorded_at, + }, + now, + ); + } + } + + // --- Population drop alert --- + // We need two data points to detect a *drop*, so we compare current vs + // the max_players recorded 30 minutes ago (nearest sample). + if (config.population_drop_enabled && stats.max_players > 0) { + const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000); + const previousStats = await this.serverStatsRepo.findOne({ + where: { license_id: config.license_id }, + order: { recorded_at: 'DESC' }, + }); + + // Use a second query to get a historical data point + const historicalStats = await this.serverStatsRepo + .createQueryBuilder('ss') + .where('ss.license_id = :licenseId', { licenseId: config.license_id }) + .andWhere('ss.recorded_at <= :cutoff', { cutoff: thirtyMinAgo }) + .orderBy('ss.recorded_at', 'DESC') + .limit(1) + .getOne(); + + if (historicalStats && historicalStats.player_count > 0) { + const dropPercent = + ((historicalStats.player_count - stats.player_count) / + historicalStats.player_count) * + 100; + + if (dropPercent >= config.population_drop_threshold_percent) { + await this.maybeFireAlert( + config, + 'population_drop', + 'info', + 'Population Drop Detected', + `Player count dropped ${dropPercent.toFixed(0)}% (${historicalStats.player_count} → ${stats.player_count}) over the last 30 minutes`, + { + previous_count: historicalStats.player_count, + current_count: stats.player_count, + drop_percent: Math.round(dropPercent), + threshold_percent: config.population_drop_threshold_percent, + }, + now, + ); + } + } + } + } + + /** Fire an alert if cooldown has expired. */ + private async maybeFireAlert( + config: AlertConfig, + alertType: string, + severity: string, + title: string, + message: string, + metadata: Record, + now: number, + ): Promise { + const cooldownKey = `${config.license_id}:${alertType}`; + const lastFired = this.cooldowns.get(cooldownKey) ?? 0; + + if (now - lastFired < ALERT_COOLDOWN_MS) { + return; // Still in cooldown — skip. + } + + this.cooldowns.set(cooldownKey, now); + + const history = this.alertHistoryRepo.create({ + license_id: config.license_id, + alert_type: alertType, + severity, + title, + message, + metadata, + notified_discord: config.notify_discord, + notified_pushbullet: config.notify_pushbullet, + notified_email: config.notify_email, + }); + + await this.alertHistoryRepo.save(history); + + this.logger.log( + `Alert fired: [${alertType}] "${title}" for license ${config.license_id}`, + ); + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + async getConfig(licenseId: string): Promise { let config = await this.alertConfigRepo.findOne({ where: { license_id: licenseId }, }); if (!config) { - // Create default config if not exists config = this.alertConfigRepo.create({ license_id: licenseId, population_drop_enabled: true, diff --git a/backend-nest/src/modules/schedules/schedules.module.ts b/backend-nest/src/modules/schedules/schedules.module.ts index 95fea3b..718d33b 100644 --- a/backend-nest/src/modules/schedules/schedules.module.ts +++ b/backend-nest/src/modules/schedules/schedules.module.ts @@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { SchedulesController } from './schedules.controller'; import { SchedulesService } from './schedules.service'; import { ScheduledTask } from '../../entities/scheduled-task.entity'; +import { NatsService } from '../../services/nats.service'; @Module({ imports: [TypeOrmModule.forFeature([ScheduledTask])], controllers: [SchedulesController], - providers: [SchedulesService], + providers: [SchedulesService, NatsService], exports: [SchedulesService], }) export class SchedulesModule {} diff --git a/backend-nest/src/modules/schedules/schedules.service.ts b/backend-nest/src/modules/schedules/schedules.service.ts index 03a26ea..90184f4 100644 --- a/backend-nest/src/modules/schedules/schedules.service.ts +++ b/backend-nest/src/modules/schedules/schedules.service.ts @@ -1,21 +1,220 @@ import { Injectable, NotFoundException, - BadRequestException, + Logger, + OnModuleInit, + OnModuleDestroy, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { LessThanOrEqual, Repository } from 'typeorm'; import { ScheduledTask } from '../../entities/scheduled-task.entity'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { NatsService } from '../../services/nats.service'; + +/** Parse a 5-field cron expression and return the next Date after `after`. */ +function nextCronDate(expr: string, after: Date): Date | null { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return null; + + const [minuteExpr, hourExpr, domExpr, monthExpr, dowExpr] = parts; + + function matches(expr: string, value: number): boolean { + if (expr === '*') return true; + return parseInt(expr, 10) === value; + } + + // Walk minute-by-minute up to 366 days forward to find next match. + const candidate = new Date(after.getTime() + 60_000); // advance at least 1 minute + candidate.setSeconds(0, 0); + + const limit = new Date(after.getTime() + 366 * 24 * 60 * 60 * 1000); + + while (candidate < limit) { + const min = candidate.getUTCMinutes(); + const hour = candidate.getUTCHours(); + const dom = candidate.getUTCDate(); + const month = candidate.getUTCMonth() + 1; // 1-12 + const dow = candidate.getUTCDay(); // 0=Sun + + if ( + matches(minuteExpr, min) && + matches(hourExpr, hour) && + matches(domExpr, dom) && + matches(monthExpr, month) && + matches(dowExpr, dow) + ) { + return candidate; + } + + candidate.setTime(candidate.getTime() + 60_000); + } + + return null; +} @Injectable() -export class SchedulesService { +export class SchedulesService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SchedulesService.name); + private executorInterval: ReturnType | null = null; + constructor( @InjectRepository(ScheduledTask) private taskRepository: Repository, + private readonly natsService: NatsService, ) {} + // --------------------------------------------------------------------------- + // Lifecycle hooks + // --------------------------------------------------------------------------- + + onModuleInit() { + // Bootstrap: calculate next_run for any task that has none. + this.bootstrapNextRuns().catch(err => + this.logger.error('Failed to bootstrap next_run values', err), + ); + + // Poll every 60 seconds for due tasks. + this.executorInterval = setInterval(() => { + this.executeDueTasks().catch(err => + this.logger.error('Schedule executor error', err), + ); + }, 60_000); + + this.logger.log('Schedule executor started (60s polling interval)'); + } + + onModuleDestroy() { + if (this.executorInterval) { + clearInterval(this.executorInterval); + this.executorInterval = null; + } + } + + // --------------------------------------------------------------------------- + // Execution engine + // --------------------------------------------------------------------------- + + /** On startup, stamp next_run on tasks that don't have one yet. */ + private async bootstrapNextRuns(): Promise { + const tasks = await this.taskRepository.find({ + where: { is_active: true, next_run: null as any }, + }); + + for (const task of tasks) { + const next = nextCronDate(task.cron_expression, new Date()); + if (next) { + task.next_run = next; + await this.taskRepository.save(task); + } + } + + if (tasks.length > 0) { + this.logger.log(`Bootstrapped next_run for ${tasks.length} task(s)`); + } + } + + /** Find all active tasks whose next_run <= now and fire them. */ + private async executeDueTasks(): Promise { + const now = new Date(); + + const dueTasks = await this.taskRepository.find({ + where: { + is_active: true, + next_run: LessThanOrEqual(now), + }, + }); + + if (dueTasks.length === 0) return; + + this.logger.log(`Executing ${dueTasks.length} due task(s)`); + + for (const task of dueTasks) { + try { + await this.executeTask(task); + + // Advance next_run. + const next = nextCronDate(task.cron_expression, now); + task.next_run = next ?? null; + await this.taskRepository.save(task); + } catch (err) { + this.logger.error( + `Failed to execute task ${task.id} (${task.task_name})`, + (err as Error).stack, + ); + // Still advance next_run so we don't hammer on a broken task. + const next = nextCronDate(task.cron_expression, now); + task.next_run = next ?? null; + await this.taskRepository.save(task); + } + } + } + + /** Dispatch a single task via NATS based on its task_type. */ + private async executeTask(task: ScheduledTask): Promise { + const { license_id, task_type, task_name, task_config } = task; + + this.logger.log( + `Firing task: [${task_type}] "${task_name}" for license ${license_id}`, + ); + + switch (task_type) { + case 'restart': + await this.natsService.sendServerCommand(license_id, 'restart', { + source: 'scheduler', + task_id: task.id, + }); + break; + + case 'announcement': { + const message = (task_config?.message as string) ?? 'Scheduled announcement'; + await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { + action: 'command', + command: `say ${message}`, + source: 'scheduler', + task_id: task.id, + timestamp: new Date().toISOString(), + }); + break; + } + + case 'command': { + const command = (task_config?.command as string) ?? ''; + if (!command) { + this.logger.warn(`Task ${task.id} has no command configured — skipping`); + return; + } + await this.natsService.publish(`corrosion.${license_id}.cmd.server`, { + action: 'command', + command, + source: 'scheduler', + task_id: task.id, + timestamp: new Date().toISOString(), + }); + break; + } + + case 'plugin_reload': { + const plugin_name = (task_config?.plugin_name as string) ?? ''; + await this.natsService.publish(`corrosion.${license_id}.cmd.plugin`, { + action: 'reload', + plugin_name, + source: 'scheduler', + task_id: task.id, + timestamp: new Date().toISOString(), + }); + break; + } + + default: + this.logger.warn(`Unknown task_type "${task_type}" for task ${task.id}`); + } + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + async getTasks(licenseId: string): Promise { return await this.taskRepository.find({ where: { license_id: licenseId }, @@ -27,31 +226,23 @@ export class SchedulesService { licenseId: string, dto: CreateTaskDto, ): Promise { - // Validate cron expression is parseable - // In production, you'd use a cron parser library to validate - // For now, we rely on the regex in the DTO - - // Set default timezone if not provided const timezone = dto.timezone || 'UTC'; + const now = new Date(); + const next = nextCronDate(dto.cron_expression, now); const task = this.taskRepository.create({ license_id: licenseId, task_type: dto.task_type, task_name: dto.task_name, cron_expression: dto.cron_expression, - timezone: timezone, + timezone, task_config: dto.task_config || {}, is_active: true, - next_run: null, // Would be calculated by scheduler - created_at: new Date(), + next_run: next ?? null, + created_at: now, }); - const saved = await this.taskRepository.save(task); - - // TODO: Register task with scheduler (tokio-cron-scheduler in Rust) - // This would send a NATS message to the scheduler service to register the task - - return saved; + return await this.taskRepository.save(task); } async updateTask( @@ -70,15 +261,15 @@ export class SchedulesService { throw new NotFoundException(`Scheduled task ${taskId} not found`); } - // Update fields Object.assign(task, dto); - const updated = await this.taskRepository.save(task); + // Recalculate next_run if the cron expression changed. + if (dto.cron_expression) { + const next = nextCronDate(dto.cron_expression, new Date()); + task.next_run = next ?? null; + } - // TODO: Update task registration with scheduler - // Send NATS message to update the task in tokio-cron-scheduler - - return updated; + return await this.taskRepository.save(task); } async deleteTask(licenseId: string, taskId: string) { @@ -94,10 +285,6 @@ export class SchedulesService { } await this.taskRepository.delete(taskId); - - // TODO: Unregister task from scheduler - // Send NATS message to remove the task from tokio-cron-scheduler - return { deleted: true }; } @@ -114,11 +301,13 @@ export class SchedulesService { } task.is_active = enabled; - const updated = await this.taskRepository.save(task); - // TODO: Enable/disable task in scheduler - // Send NATS message to pause or resume the task + // When re-enabling, calculate next_run if it's missing. + if (enabled && !task.next_run) { + const next = nextCronDate(task.cron_expression, new Date()); + task.next_run = next ?? null; + } - return updated; + return await this.taskRepository.save(task); } } diff --git a/backend-nest/src/modules/store/store.module.ts b/backend-nest/src/modules/store/store.module.ts index 4b593a1..db6db7c 100644 --- a/backend-nest/src/modules/store/store.module.ts +++ b/backend-nest/src/modules/store/store.module.ts @@ -4,9 +4,10 @@ import { StoreController } from './store.controller'; import { StoreService } from './store.service'; import { Module as ModuleEntity } from '../../entities/module.entity'; import { ModulePurchase } from '../../entities/module-purchase.entity'; +import { ModuleInstallation } from '../../entities/module-installation.entity'; @Module({ - imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])], + imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])], controllers: [StoreController], providers: [StoreService], exports: [StoreService], diff --git a/backend-nest/src/modules/store/store.service.ts b/backend-nest/src/modules/store/store.service.ts index cb26339..088bb1d 100644 --- a/backend-nest/src/modules/store/store.service.ts +++ b/backend-nest/src/modules/store/store.service.ts @@ -1,16 +1,21 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Module } from '../../entities/module.entity'; import { ModulePurchase } from '../../entities/module-purchase.entity'; +import { ModuleInstallation } from '../../entities/module-installation.entity'; @Injectable() export class StoreService { + private readonly logger = new Logger(StoreService.name); + constructor( @InjectRepository(Module) private readonly moduleRepo: Repository, @InjectRepository(ModulePurchase) private readonly purchaseRepo: Repository, + @InjectRepository(ModuleInstallation) + private readonly installationRepo: Repository, ) {} async getCatalog(): Promise { @@ -26,14 +31,19 @@ export class StoreService { order: { purchased_at: 'DESC' }, }); + const installations = await this.installationRepo.find({ + where: { license_id: licenseId }, + relations: ['module'], + }); + return { purchased: purchases, - installed: purchases.filter(p => p.module), // Stub - would need module_installations table + installed: installations, }; } async purchaseModule(licenseId: string, moduleId: string): Promise { - // Check if already purchased + // Check if already purchased. const existing = await this.purchaseRepo.findOne({ where: { license_id: licenseId, module_id: moduleId }, }); @@ -50,15 +60,15 @@ export class StoreService { const purchase = this.purchaseRepo.create({ license_id: licenseId, module_id: moduleId, - transaction_id: `txn_${Date.now()}`, // Stub + transaction_id: `txn_${Date.now()}`, amount_paid: parseFloat(module.price_usd.toString()), }); return this.purchaseRepo.save(purchase); } - async installModule(licenseId: string, moduleId: string) { - // Verify purchase exists + async installModule(licenseId: string, moduleId: string): Promise { + // Verify purchase exists. const purchase = await this.purchaseRepo.findOne({ where: { license_id: licenseId, module_id: moduleId }, }); @@ -67,11 +77,44 @@ export class StoreService { throw new ForbiddenException('Module not purchased'); } - // Stub - would create module_installation record - return { - message: 'Module installed successfully', + // Verify module exists. + const module = await this.moduleRepo.findOne({ where: { id: moduleId } }); + if (!module) { + throw new NotFoundException('Module not found'); + } + + // Idempotent: return existing installation record if one already exists. + const existing = await this.installationRepo.findOne({ + where: { license_id: licenseId, module_id: moduleId }, + }); + + if (existing) { + // If previously failed, reset to pending so it can be retried. + if (existing.status === 'failed') { + existing.status = 'installed'; + existing.installed_at = new Date(); + existing.error_message = null; + return this.installationRepo.save(existing); + } + return existing; + } + + // Create installation record and mark as installed. + // In a full implementation this would trigger an async deployment pipeline. + const installation = this.installationRepo.create({ + license_id: licenseId, module_id: moduleId, status: 'installed', - }; + installed_at: new Date(), + error_message: null, + }); + + const saved = await this.installationRepo.save(installation); + + this.logger.log( + `Module installed: ${module.name ?? moduleId} for license ${licenseId} (installation id: ${saved.id})`, + ); + + return saved; } } diff --git a/backend-nest/src/modules/wipes/wipes.service.ts b/backend-nest/src/modules/wipes/wipes.service.ts index 3c2b0d3..35f657c 100644 --- a/backend-nest/src/modules/wipes/wipes.service.ts +++ b/backend-nest/src/modules/wipes/wipes.service.ts @@ -126,19 +126,112 @@ export class WipesService { would_delete: string[]; would_preserve: string[]; estimated_duration_seconds: number; + profile_name: string | null; + notes: string[]; }> { - // Stub implementation - real logic would analyze wipe profile config - const mockResult = { - would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'], - would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'], - estimated_duration_seconds: 45, - }; - - if (dto.wipe_type === 'full') { - mockResult.would_delete.push('oxide/data/*'); - mockResult.estimated_duration_seconds = 120; + // Resolve profile config if a profile ID was supplied. + let profile: WipeProfile | null = null; + if (dto.wipe_profile_id) { + profile = await this.wipeProfileRepo.findOne({ + where: { id: dto.wipe_profile_id, license_id: licenseId }, + }); } - return mockResult; + if (!profile && dto.wipe_profile_id) { + throw new NotFoundException(`Wipe profile ${dto.wipe_profile_id} not found`); + } + + const notes: string[] = []; + + // Base files affected by all wipe types. + const would_delete: string[] = ['*.map', '*.sav']; + const would_preserve: string[] = [ + 'oxide/', + 'oxide/plugins/', + 'cfg/', + 'server.cfg', + ]; + + // Blueprint wipe additions. + if (dto.wipe_type === 'blueprint' || dto.wipe_type === 'full') { + would_delete.push('player.blueprints.db', 'player.tech.db'); + } + + // Full wipe: also clear player data and oxide data. + if (dto.wipe_type === 'full') { + would_delete.push( + 'player.deaths.db', + 'player.identities.db', + 'player.states.db', + 'player.tokens.db', + 'oxide/data/*', + ); + would_preserve.splice(would_preserve.indexOf('oxide/'), 1); + } + + // Factor in pre_wipe_config from the profile (if set). + let estimatedSeconds = 45; + + if (profile) { + const pre = profile.pre_wipe_config as Record; + const post = profile.post_wipe_config as Record; + + if (pre?.backup_before_wipe) { + estimatedSeconds += 60; + notes.push('Pre-wipe backup will run before deletion (+60s)'); + would_preserve.push('backups/'); + } + + if (pre?.kick_players_before_wipe) { + const countdownWarnings: number[] = (pre.countdown_warnings as number[]) ?? []; + const maxWarning = countdownWarnings.length > 0 ? Math.max(...countdownWarnings) : 0; + if (maxWarning > 0) { + estimatedSeconds += maxWarning * 60; + notes.push(`Players will be warned ${countdownWarnings.join(', ')} minutes before kick (+${maxWarning * 60}s)`); + } + } + + if (post?.verify_server_started) { + estimatedSeconds += 30; + notes.push('Post-wipe: server health check will run (+30s)'); + } + + if (post?.rollback_on_failure) { + notes.push('Rollback on failure is enabled — backup will be preserved if wipe fails'); + } + + if (post?.max_restart_attempts) { + const attempts = post.max_restart_attempts as number; + if (attempts > 1) { + estimatedSeconds += (attempts - 1) * 15; + notes.push(`Up to ${attempts} restart attempts (+${(attempts - 1) * 15}s max)`); + } + } + } else { + notes.push('No profile selected — using default wipe behavior'); + } + + // Account for world size in time estimate. + // Larger worlds take longer to clear from disk (rough heuristic). + // We don't have world_size here without querying server_config, + // so apply a static estimate per wipe type. + if (dto.wipe_type === 'full') { + estimatedSeconds += 75; + } else if (dto.wipe_type === 'blueprint') { + estimatedSeconds += 10; + } + + this.logger.log( + `Dry-run for license ${licenseId}: type=${dto.wipe_type}, ` + + `profile=${profile?.profile_name ?? 'none'}, estimated=${estimatedSeconds}s`, + ); + + return { + would_delete, + would_preserve, + estimated_duration_seconds: estimatedSeconds, + profile_name: profile?.profile_name ?? null, + notes, + }; } }