feat: Wire execution engines for schedules, alerts, wipes, and module install
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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:
@@ -4,9 +4,10 @@ import { AlertsController } from './alerts.controller';
|
|||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory])],
|
imports: [TypeOrmModule.forFeature([AlertConfig, AlertHistory, ServerStats])],
|
||||||
controllers: [AlertsController],
|
controllers: [AlertsController],
|
||||||
providers: [AlertsService],
|
providers: [AlertsService],
|
||||||
exports: [AlertsService],
|
exports: [AlertsService],
|
||||||
|
|||||||
@@ -1,26 +1,204 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AlertConfig } from '../../entities/alert-config.entity';
|
import { AlertConfig } from '../../entities/alert-config.entity';
|
||||||
import { AlertHistory } from '../../entities/alert-history.entity';
|
import { AlertHistory } from '../../entities/alert-history.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { UpdateAlertConfigDto } from './dto/update-alert-config.dto';
|
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()
|
@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(
|
constructor(
|
||||||
@InjectRepository(AlertConfig)
|
@InjectRepository(AlertConfig)
|
||||||
private readonly alertConfigRepo: Repository<AlertConfig>,
|
private readonly alertConfigRepo: Repository<AlertConfig>,
|
||||||
@InjectRepository(AlertHistory)
|
@InjectRepository(AlertHistory)
|
||||||
private readonly alertHistoryRepo: Repository<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> {
|
async getConfig(licenseId: string): Promise<AlertConfig> {
|
||||||
let config = await this.alertConfigRepo.findOne({
|
let config = await this.alertConfigRepo.findOne({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
// Create default config if not exists
|
|
||||||
config = this.alertConfigRepo.create({
|
config = this.alertConfigRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
population_drop_enabled: true,
|
population_drop_enabled: true,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { SchedulesController } from './schedules.controller';
|
import { SchedulesController } from './schedules.controller';
|
||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||||
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
imports: [TypeOrmModule.forFeature([ScheduledTask])],
|
||||||
controllers: [SchedulesController],
|
controllers: [SchedulesController],
|
||||||
providers: [SchedulesService],
|
providers: [SchedulesService, NatsService],
|
||||||
exports: [SchedulesService],
|
exports: [SchedulesService],
|
||||||
})
|
})
|
||||||
export class SchedulesModule {}
|
export class SchedulesModule {}
|
||||||
|
|||||||
@@ -1,21 +1,220 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
import { ScheduledTask } from '../../entities/scheduled-task.entity';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-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()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(SchedulesService.name);
|
||||||
|
private executorInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ScheduledTask)
|
@InjectRepository(ScheduledTask)
|
||||||
private taskRepository: Repository<ScheduledTask>,
|
private taskRepository: Repository<ScheduledTask>,
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ScheduledTask[]> {
|
async getTasks(licenseId: string): Promise<ScheduledTask[]> {
|
||||||
return await this.taskRepository.find({
|
return await this.taskRepository.find({
|
||||||
where: { license_id: licenseId },
|
where: { license_id: licenseId },
|
||||||
@@ -27,31 +226,23 @@ export class SchedulesService {
|
|||||||
licenseId: string,
|
licenseId: string,
|
||||||
dto: CreateTaskDto,
|
dto: CreateTaskDto,
|
||||||
): Promise<ScheduledTask> {
|
): Promise<ScheduledTask> {
|
||||||
// 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 timezone = dto.timezone || 'UTC';
|
||||||
|
const now = new Date();
|
||||||
|
const next = nextCronDate(dto.cron_expression, now);
|
||||||
|
|
||||||
const task = this.taskRepository.create({
|
const task = this.taskRepository.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
task_type: dto.task_type,
|
task_type: dto.task_type,
|
||||||
task_name: dto.task_name,
|
task_name: dto.task_name,
|
||||||
cron_expression: dto.cron_expression,
|
cron_expression: dto.cron_expression,
|
||||||
timezone: timezone,
|
timezone,
|
||||||
task_config: dto.task_config || {},
|
task_config: dto.task_config || {},
|
||||||
is_active: true,
|
is_active: true,
|
||||||
next_run: null, // Would be calculated by scheduler
|
next_run: next ?? null,
|
||||||
created_at: new Date(),
|
created_at: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.taskRepository.save(task);
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTask(
|
async updateTask(
|
||||||
@@ -70,15 +261,15 @@ export class SchedulesService {
|
|||||||
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
throw new NotFoundException(`Scheduled task ${taskId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update fields
|
|
||||||
Object.assign(task, dto);
|
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
|
return await this.taskRepository.save(task);
|
||||||
// Send NATS message to update the task in tokio-cron-scheduler
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTask(licenseId: string, taskId: string) {
|
async deleteTask(licenseId: string, taskId: string) {
|
||||||
@@ -94,10 +285,6 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.taskRepository.delete(taskId);
|
await this.taskRepository.delete(taskId);
|
||||||
|
|
||||||
// TODO: Unregister task from scheduler
|
|
||||||
// Send NATS message to remove the task from tokio-cron-scheduler
|
|
||||||
|
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +301,13 @@ export class SchedulesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
task.is_active = enabled;
|
task.is_active = enabled;
|
||||||
const updated = await this.taskRepository.save(task);
|
|
||||||
|
|
||||||
// TODO: Enable/disable task in scheduler
|
// When re-enabling, calculate next_run if it's missing.
|
||||||
// Send NATS message to pause or resume the task
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { StoreController } from './store.controller';
|
|||||||
import { StoreService } from './store.service';
|
import { StoreService } from './store.service';
|
||||||
import { Module as ModuleEntity } from '../../entities/module.entity';
|
import { Module as ModuleEntity } from '../../entities/module.entity';
|
||||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||||
|
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase])],
|
imports: [TypeOrmModule.forFeature([ModuleEntity, ModulePurchase, ModuleInstallation])],
|
||||||
controllers: [StoreController],
|
controllers: [StoreController],
|
||||||
providers: [StoreService],
|
providers: [StoreService],
|
||||||
exports: [StoreService],
|
exports: [StoreService],
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Module } from '../../entities/module.entity';
|
import { Module } from '../../entities/module.entity';
|
||||||
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
import { ModulePurchase } from '../../entities/module-purchase.entity';
|
||||||
|
import { ModuleInstallation } from '../../entities/module-installation.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StoreService {
|
export class StoreService {
|
||||||
|
private readonly logger = new Logger(StoreService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Module)
|
@InjectRepository(Module)
|
||||||
private readonly moduleRepo: Repository<Module>,
|
private readonly moduleRepo: Repository<Module>,
|
||||||
@InjectRepository(ModulePurchase)
|
@InjectRepository(ModulePurchase)
|
||||||
private readonly purchaseRepo: Repository<ModulePurchase>,
|
private readonly purchaseRepo: Repository<ModulePurchase>,
|
||||||
|
@InjectRepository(ModuleInstallation)
|
||||||
|
private readonly installationRepo: Repository<ModuleInstallation>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCatalog(): Promise<Module[]> {
|
async getCatalog(): Promise<Module[]> {
|
||||||
@@ -26,14 +31,19 @@ export class StoreService {
|
|||||||
order: { purchased_at: 'DESC' },
|
order: { purchased_at: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const installations = await this.installationRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
relations: ['module'],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
purchased: purchases,
|
purchased: purchases,
|
||||||
installed: purchases.filter(p => p.module), // Stub - would need module_installations table
|
installed: installations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
async purchaseModule(licenseId: string, moduleId: string): Promise<ModulePurchase> {
|
||||||
// Check if already purchased
|
// Check if already purchased.
|
||||||
const existing = await this.purchaseRepo.findOne({
|
const existing = await this.purchaseRepo.findOne({
|
||||||
where: { license_id: licenseId, module_id: moduleId },
|
where: { license_id: licenseId, module_id: moduleId },
|
||||||
});
|
});
|
||||||
@@ -50,15 +60,15 @@ export class StoreService {
|
|||||||
const purchase = this.purchaseRepo.create({
|
const purchase = this.purchaseRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
module_id: moduleId,
|
module_id: moduleId,
|
||||||
transaction_id: `txn_${Date.now()}`, // Stub
|
transaction_id: `txn_${Date.now()}`,
|
||||||
amount_paid: parseFloat(module.price_usd.toString()),
|
amount_paid: parseFloat(module.price_usd.toString()),
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.purchaseRepo.save(purchase);
|
return this.purchaseRepo.save(purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
async installModule(licenseId: string, moduleId: string) {
|
async installModule(licenseId: string, moduleId: string): Promise<ModuleInstallation> {
|
||||||
// Verify purchase exists
|
// Verify purchase exists.
|
||||||
const purchase = await this.purchaseRepo.findOne({
|
const purchase = await this.purchaseRepo.findOne({
|
||||||
where: { license_id: licenseId, module_id: moduleId },
|
where: { license_id: licenseId, module_id: moduleId },
|
||||||
});
|
});
|
||||||
@@ -67,11 +77,44 @@ export class StoreService {
|
|||||||
throw new ForbiddenException('Module not purchased');
|
throw new ForbiddenException('Module not purchased');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stub - would create module_installation record
|
// Verify module exists.
|
||||||
return {
|
const module = await this.moduleRepo.findOne({ where: { id: moduleId } });
|
||||||
message: 'Module installed successfully',
|
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,
|
module_id: moduleId,
|
||||||
status: 'installed',
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,19 +126,112 @@ export class WipesService {
|
|||||||
would_delete: string[];
|
would_delete: string[];
|
||||||
would_preserve: string[];
|
would_preserve: string[];
|
||||||
estimated_duration_seconds: number;
|
estimated_duration_seconds: number;
|
||||||
|
profile_name: string | null;
|
||||||
|
notes: string[];
|
||||||
}> {
|
}> {
|
||||||
// Stub implementation - real logic would analyze wipe profile config
|
// Resolve profile config if a profile ID was supplied.
|
||||||
const mockResult = {
|
let profile: WipeProfile | null = null;
|
||||||
would_delete: ['*.sav', '*.db', 'player.deaths.db', 'player.identities.db'],
|
if (dto.wipe_profile_id) {
|
||||||
would_preserve: ['oxide/', 'oxide/plugins/', 'oxide/data/', 'backups/'],
|
profile = await this.wipeProfileRepo.findOne({
|
||||||
estimated_duration_seconds: 45,
|
where: { id: dto.wipe_profile_id, license_id: licenseId },
|
||||||
};
|
});
|
||||||
|
|
||||||
if (dto.wipe_type === 'full') {
|
|
||||||
mockResult.would_delete.push('oxide/data/*');
|
|
||||||
mockResult.estimated_duration_seconds = 120;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string, any>;
|
||||||
|
const post = profile.post_wipe_config as Record<string, any>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user