feat: Complete NestJS backend scaffold — 22 modules, 39 entities, WebSocket gateway
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full backend rewrite from Rust/Axum to NestJS/TypeScript. - 22 feature modules (auth, servers, wipes, maps, plugins, players, console, chat, team, notifications, settings, schedules, analytics, alerts, status, store, webstore, admin, setup, migration, users, licenses) - 39 TypeORM entities matching PostgreSQL schema (12 migrations) - Common infrastructure: JWT/RBAC guards, decorators, exception filter - NATS service with pub/sub/request-reply - Socket.IO WebSocket gateway with NATS bridge - Docker: NestJS Dockerfile + updated docker-compose.yml - Zero compile errors (npx tsc --noEmit clean) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
214
backend-nest/src/modules/analytics/analytics.service.ts
Normal file
214
backend-nest/src/modules/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||
import { ServerStats } from '../../entities/server-stats.entity';
|
||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||
import { PlayerSession } from '../../entities/player-session.entity';
|
||||
import { MapLibrary } from '../../entities/map-library.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@InjectRepository(ServerStatsHourly)
|
||||
private readonly statsHourlyRepo: Repository<ServerStatsHourly>,
|
||||
@InjectRepository(ServerStats)
|
||||
private readonly statsRepo: Repository<ServerStats>,
|
||||
@InjectRepository(WipeHistory)
|
||||
private readonly wipeHistoryRepo: Repository<WipeHistory>,
|
||||
@InjectRepository(PlayerSession)
|
||||
private readonly playerSessionRepo: Repository<PlayerSession>,
|
||||
@InjectRepository(MapLibrary)
|
||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||
) {}
|
||||
|
||||
async getSummary(licenseId: string, rangeHours: number) {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
const stats = await this.statsHourlyRepo
|
||||
.createQueryBuilder('stats')
|
||||
.select('MAX(stats.max_players)', 'peak_players')
|
||||
.addSelect('AVG(stats.avg_players)', 'avg_players')
|
||||
.addSelect('AVG(stats.uptime_percentage)', 'uptime_percentage')
|
||||
.where('stats.license_id = :licenseId', { licenseId })
|
||||
.andWhere('stats.hour >= :cutoff', { cutoff })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
peak_players: stats?.peak_players || 0,
|
||||
avg_players: parseFloat(stats?.avg_players || 0),
|
||||
uptime_percentage: parseFloat(stats?.uptime_percentage || 0),
|
||||
unique_players: null, // Not implemented yet
|
||||
};
|
||||
}
|
||||
|
||||
async getTimeseries(licenseId: string, rangeHours: number, granularity: 'raw' | 'hourly') {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
if (granularity === 'hourly') {
|
||||
const data = await this.statsHourlyRepo.find({
|
||||
where: { license_id: licenseId, hour: MoreThan(cutoff) },
|
||||
order: { hour: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
timestamps: data.map(d => d.hour),
|
||||
player_count: data.map(d => d.avg_players),
|
||||
fps: data.map(d => d.avg_fps),
|
||||
entity_count: data.map(d => d.avg_entities),
|
||||
memory_usage_mb: data.map(() => null), // Not in schema
|
||||
};
|
||||
} else {
|
||||
const data = await this.statsRepo.find({
|
||||
where: { license_id: licenseId, recorded_at: MoreThan(cutoff) },
|
||||
order: { recorded_at: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
timestamps: data.map(d => d.recorded_at),
|
||||
player_count: data.map(d => d.player_count),
|
||||
fps: data.map(d => d.fps),
|
||||
entity_count: data.map(d => d.entity_count),
|
||||
memory_usage_mb: data.map(d => d.memory_usage_mb),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getWipePerformance(licenseId: string, rangeHours: number) {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
const wipes = await this.wipeHistoryRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
status: 'success',
|
||||
started_at: MoreThan(cutoff),
|
||||
},
|
||||
order: { started_at: 'DESC' },
|
||||
});
|
||||
|
||||
const durations = wipes
|
||||
.filter(w => w.started_at && w.completed_at)
|
||||
.map(w => (w.completed_at!.getTime() - w.started_at!.getTime()) / 1000);
|
||||
|
||||
return {
|
||||
total_wipes: wipes.length,
|
||||
avg_duration_seconds: durations.length > 0
|
||||
? durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
: 0,
|
||||
min_duration_seconds: durations.length > 0 ? Math.min(...durations) : 0,
|
||||
max_duration_seconds: durations.length > 0 ? Math.max(...durations) : 0,
|
||||
wipe_types: wipes.reduce((acc, w) => {
|
||||
acc[w.wipe_type] = (acc[w.wipe_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
};
|
||||
}
|
||||
|
||||
async getMapAnalytics(licenseId: string, rangeHours: number) {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
const mapUsage = await this.wipeHistoryRepo
|
||||
.createQueryBuilder('wipe')
|
||||
.leftJoinAndSelect('wipe.map', 'map')
|
||||
.select('map.id', 'map_id')
|
||||
.addSelect('map.name', 'map_name')
|
||||
.addSelect('COUNT(wipe.id)', 'usage_count')
|
||||
.where('wipe.license_id = :licenseId', { licenseId })
|
||||
.andWhere('wipe.started_at >= :cutoff', { cutoff })
|
||||
.andWhere('wipe.map_id IS NOT NULL')
|
||||
.groupBy('map.id')
|
||||
.addGroupBy('map.name')
|
||||
.getRawMany();
|
||||
|
||||
return {
|
||||
map_usage: mapUsage.map(m => ({
|
||||
map_id: m.map_id,
|
||||
map_name: m.map_name,
|
||||
usage_count: parseInt(m.usage_count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getPlayerAnalytics(licenseId: string, rangeHours: number, metric: 'sessions' | 'retention') {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
if (metric === 'sessions') {
|
||||
const sessions = await this.playerSessionRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
session_start: MoreThan(cutoff),
|
||||
},
|
||||
order: { session_start: 'DESC' },
|
||||
});
|
||||
|
||||
const totalDuration = sessions
|
||||
.filter(s => s.duration_seconds)
|
||||
.reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||||
|
||||
return {
|
||||
total_sessions: sessions.length,
|
||||
avg_session_duration: sessions.length > 0 ? totalDuration / sessions.length : 0,
|
||||
unique_players: new Set(sessions.map(s => s.steam_id)).size,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: 'Retention metric not implemented' };
|
||||
}
|
||||
|
||||
async getRetention(licenseId: string, wipeCount: number) {
|
||||
const recentWipes = await this.wipeHistoryRepo.find({
|
||||
where: { license_id: licenseId, status: 'success' },
|
||||
order: { started_at: 'DESC' },
|
||||
take: wipeCount,
|
||||
});
|
||||
|
||||
if (recentWipes.length === 0) {
|
||||
return { wipe_count: 0, retention_data: [] };
|
||||
}
|
||||
|
||||
const retentionData = await Promise.all(
|
||||
recentWipes.map(async (wipe) => {
|
||||
const wipeDate = wipe.started_at;
|
||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
||||
const endDate = nextWipe?.started_at || new Date();
|
||||
|
||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||
where: {
|
||||
license_id: licenseId,
|
||||
session_start: MoreThan(wipeDate!),
|
||||
},
|
||||
});
|
||||
|
||||
const uniquePlayers = new Set(sessionsInPeriod.map(s => s.steam_id)).size;
|
||||
|
||||
return {
|
||||
wipe_date: wipeDate,
|
||||
unique_players: uniquePlayers,
|
||||
total_sessions: sessionsInPeriod.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
wipe_count: recentWipes.length,
|
||||
retention_data: retentionData,
|
||||
};
|
||||
}
|
||||
|
||||
async exportData(licenseId: string, rangeHours: number): Promise<string> {
|
||||
const cutoff = new Date(Date.now() - rangeHours * 3600 * 1000);
|
||||
|
||||
const stats = await this.statsRepo.find({
|
||||
where: { license_id: licenseId, recorded_at: MoreThan(cutoff) },
|
||||
order: { recorded_at: 'ASC' },
|
||||
});
|
||||
|
||||
// Generate CSV
|
||||
const headers = 'timestamp,player_count,fps,entity_count,memory_mb\n';
|
||||
const rows = stats.map(s =>
|
||||
`${s.recorded_at.toISOString()},${s.player_count},${s.fps},${s.entity_count},${s.memory_usage_mb}`
|
||||
).join('\n');
|
||||
|
||||
return headers + rows;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user