All checks were successful
Test Asgard Runner / test (push) Successful in 3s
- 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>
221 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|