Files
corrosion-admin-panel/backend-nest/src/modules/analytics/analytics.service.ts
Vantz Stockwell 9240feedaf
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix: Wire real data sources across players, analytics, status, and maps services
- players: Primary data from player_sessions (online status, playtime aggregates);
  ban/unban status overlaid from player_actions latest action per steam_id.
  Register PlayerSession entity in PlayersModule. Extend NATS forwarding to
  include 'unban' alongside kick and ban.
- analytics: Fix retention period boundary bug — sessions were queried with only
  a lower-bound filter (MoreThan), causing all future cycles to bleed into earlier
  wipe windows. Replaced with Between(wipeDate, endDate) for correct isolation.
- status: Replace hardcoded player_count=0/max_players=0 with live data from
  most-recent server_stats row per license. Register ServerStats entity in
  StatusModule. Falls back to 0 gracefully when no stats exist yet.
- maps: File buffer was computed and discarded — never written to disk.
  Now writes to /app/map_data/{licenseId}/{timestamp}_{filename} (tenant-isolated,
  docker volume map_data). Creates directories with mkdirSync(recursive:true).
  Logs success/failure via NestJS Logger. Throws 500 on disk write failure
  instead of silently losing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:01:45 -05:00

221 lines
7.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan, Between } 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;
// Find the next wipe chronologically after this one (wipes are DESC ordered)
const nextWipe = recentWipes
.slice()
.reverse()
.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
const endDate = nextWipe?.started_at || new Date();
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
const sessionsInPeriod = await this.playerSessionRepo.find({
where: {
license_id: licenseId,
session_start: Between(wipeDate!, endDate),
},
});
const uniquePlayers = new Set(sessionsInPeriod.map(s => s.steam_id)).size;
return {
wipe_date: wipeDate,
end_date: endDate,
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;
}
}