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 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) { config = this.alertConfigRepo.create({ license_id: licenseId, population_drop_enabled: true, population_drop_threshold_percent: 30, fps_degradation_enabled: true, fps_threshold: 30, notify_discord: true, notify_pushbullet: false, notify_email: false, }); await this.alertConfigRepo.save(config); } return config; } async updateConfig(licenseId: string, dto: UpdateAlertConfigDto): Promise { let config = await this.alertConfigRepo.findOne({ where: { license_id: licenseId }, }); if (!config) { config = this.alertConfigRepo.create({ license_id: licenseId, ...dto, }); } else { Object.assign(config, dto); config.updated_at = new Date(); } return this.alertConfigRepo.save(config); } async getHistory(licenseId: string, limit: number = 50): Promise { return this.alertHistoryRepo.find({ where: { license_id: licenseId }, order: { triggered_at: 'DESC' }, take: limit, }); } }