All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- 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 <noreply@anthropic.com>
244 lines
7.7 KiB
TypeScript
244 lines
7.7 KiB
TypeScript
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<typeof setInterval> | null = null;
|
|
|
|
/** Map of `${licenseId}:${alertType}` → last triggered timestamp */
|
|
private readonly cooldowns = new Map<string, number>();
|
|
|
|
constructor(
|
|
@InjectRepository(AlertConfig)
|
|
private readonly alertConfigRepo: Repository<AlertConfig>,
|
|
@InjectRepository(AlertHistory)
|
|
private readonly alertHistoryRepo: Repository<AlertHistory>,
|
|
@InjectRepository(ServerStats)
|
|
private readonly serverStatsRepo: Repository<ServerStats>,
|
|
) {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<void> {
|
|
// 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<void> {
|
|
// 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<string, any>,
|
|
now: number,
|
|
): Promise<void> {
|
|
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<AlertConfig> {
|
|
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<AlertConfig> {
|
|
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<AlertHistory[]> {
|
|
return this.alertHistoryRepo.find({
|
|
where: { license_id: licenseId },
|
|
order: { triggered_at: 'DESC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
}
|