feat: Wire execution engines for schedules, alerts, wipes, and module install
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>
This commit is contained in:
Vantz Stockwell
2026-02-21 16:02:49 -05:00
parent 9240feedaf
commit cbb3ba6586
7 changed files with 564 additions and 58 deletions

View File

@@ -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<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) {
// Create default config if not exists
config = this.alertConfigRepo.create({
license_id: licenseId,
population_drop_enabled: true,