fix: Wire real data sources across players, analytics, status, and maps services
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, MoreThan } from 'typeorm';
|
import { Repository, MoreThan, Between } from 'typeorm';
|
||||||
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
import { ServerStatsHourly } from '../../entities/server-stats-hourly.entity';
|
||||||
import { ServerStats } from '../../entities/server-stats.entity';
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { WipeHistory } from '../../entities/wipe-history.entity';
|
import { WipeHistory } from '../../entities/wipe-history.entity';
|
||||||
@@ -169,13 +169,18 @@ export class AnalyticsService {
|
|||||||
const retentionData = await Promise.all(
|
const retentionData = await Promise.all(
|
||||||
recentWipes.map(async (wipe) => {
|
recentWipes.map(async (wipe) => {
|
||||||
const wipeDate = wipe.started_at;
|
const wipeDate = wipe.started_at;
|
||||||
const nextWipe = recentWipes.find(w => w.started_at && wipeDate && w.started_at > wipeDate);
|
// 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();
|
const endDate = nextWipe?.started_at || new Date();
|
||||||
|
|
||||||
|
// Query sessions strictly within this wipe cycle: [wipeDate, endDate)
|
||||||
const sessionsInPeriod = await this.playerSessionRepo.find({
|
const sessionsInPeriod = await this.playerSessionRepo.find({
|
||||||
where: {
|
where: {
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
session_start: MoreThan(wipeDate!),
|
session_start: Between(wipeDate!, endDate),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +188,7 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
wipe_date: wipeDate,
|
wipe_date: wipeDate,
|
||||||
|
end_date: endDate,
|
||||||
unique_players: uniquePlayers,
|
unique_players: uniquePlayers,
|
||||||
total_sessions: sessionsInPeriod.length,
|
total_sessions: sessionsInPeriod.length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { MapLibrary } from '../../entities/map-library.entity';
|
import { MapLibrary } from '../../entities/map-library.entity';
|
||||||
import { MapRotation } from '../../entities/map-rotation.entity';
|
import { MapRotation } from '../../entities/map-rotation.entity';
|
||||||
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
import { UpdateRotationDto } from './dto/update-rotation.dto';
|
||||||
import { UploadMapDto } from './dto/upload-map.dto';
|
import { UploadMapDto } from './dto/upload-map.dto';
|
||||||
|
|
||||||
|
// Docker volume mount point for map storage. Tenant-scoped subdirectory enforces isolation.
|
||||||
|
const MAP_DATA_ROOT = process.env.MAP_DATA_PATH || '/app/map_data';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MapsService {
|
export class MapsService {
|
||||||
|
private readonly logger = new Logger(MapsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(MapLibrary)
|
@InjectRepository(MapLibrary)
|
||||||
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
private readonly mapLibraryRepo: Repository<MapLibrary>,
|
||||||
@@ -22,7 +29,23 @@ export class MapsService {
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
): Promise<MapLibrary> {
|
): Promise<MapLibrary> {
|
||||||
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
const checksum = createHash('sha256').update(file.buffer).digest('hex');
|
||||||
const storagePath = `/maps/${licenseId}/${Date.now()}_${file.originalname}`;
|
|
||||||
|
// Build tenant-scoped storage path: /app/map_data/{licenseId}/{timestamp}_{filename}
|
||||||
|
const filename = `${Date.now()}_${file.originalname}`;
|
||||||
|
const tenantDir = join(MAP_DATA_ROOT, licenseId);
|
||||||
|
const absolutePath = join(tenantDir, filename);
|
||||||
|
|
||||||
|
// Relative storage path stored in DB — avoids coupling to the absolute mount point
|
||||||
|
const storagePath = `/map_data/${licenseId}/${filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(tenantDir, { recursive: true });
|
||||||
|
writeFileSync(absolutePath, file.buffer);
|
||||||
|
this.logger.log(`Map uploaded: ${absolutePath} (${file.size} bytes)`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to write map file to disk: ${absolutePath}`, err);
|
||||||
|
throw new InternalServerErrorException('Failed to save map file to storage');
|
||||||
|
}
|
||||||
|
|
||||||
const map = this.mapLibraryRepo.create({
|
const map = this.mapLibraryRepo.create({
|
||||||
license_id: licenseId,
|
license_id: licenseId,
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { PlayersController } from './players.controller';
|
import { PlayersController } from './players.controller';
|
||||||
import { PlayersService } from './players.service';
|
import { PlayersService } from './players.service';
|
||||||
import { PlayerAction } from '../../entities/player-action.entity';
|
import { PlayerAction } from '../../entities/player-action.entity';
|
||||||
|
import { PlayerSession } from '../../entities/player-session.entity';
|
||||||
import { NatsService } from '../../services/nats.service';
|
import { NatsService } from '../../services/nats.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([PlayerAction])],
|
imports: [TypeOrmModule.forFeature([PlayerAction, PlayerSession])],
|
||||||
controllers: [PlayersController],
|
controllers: [PlayersController],
|
||||||
providers: [PlayersService, NatsService],
|
providers: [PlayersService, NatsService],
|
||||||
exports: [PlayersService],
|
exports: [PlayersService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PlayerAction } from '../../entities/player-action.entity';
|
import { PlayerAction } from '../../entities/player-action.entity';
|
||||||
|
import { PlayerSession } from '../../entities/player-session.entity';
|
||||||
import { NatsService } from '../../services/nats.service';
|
import { NatsService } from '../../services/nats.service';
|
||||||
import { PlayerActionDto } from './dto/player-action.dto';
|
import { PlayerActionDto } from './dto/player-action.dto';
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ export interface Player {
|
|||||||
status: 'online' | 'offline' | 'banned';
|
status: 'online' | 'offline' | 'banned';
|
||||||
last_seen?: Date;
|
last_seen?: Date;
|
||||||
ban_expires?: Date | null;
|
ban_expires?: Date | null;
|
||||||
|
total_sessions?: number;
|
||||||
|
total_playtime_seconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -18,43 +21,86 @@ export class PlayersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(PlayerAction)
|
@InjectRepository(PlayerAction)
|
||||||
private readonly actionRepo: Repository<PlayerAction>,
|
private readonly actionRepo: Repository<PlayerAction>,
|
||||||
|
@InjectRepository(PlayerSession)
|
||||||
|
private readonly sessionRepo: Repository<PlayerSession>,
|
||||||
private readonly natsService: NatsService,
|
private readonly natsService: NatsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent players for a license
|
* Get players for a license.
|
||||||
*
|
*
|
||||||
* TODO: This needs a player_sessions table to track online/offline status.
|
* Primary source: player_sessions table (tracks session lifecycles).
|
||||||
* For now, we query player_actions to get a list of players who have had actions.
|
* Secondary source: player_actions table (determines ban status).
|
||||||
|
* A player whose most recent action is a 'ban' (not subsequently 'unban') is shown as banned.
|
||||||
|
* A player with an open session (session_end IS NULL) is shown as online.
|
||||||
*/
|
*/
|
||||||
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
async getPlayers(licenseId: string): Promise<{ players: Player[] }> {
|
||||||
const actions = await this.actionRepo
|
// Get distinct players from session history
|
||||||
|
const sessions = await this.sessionRepo
|
||||||
|
.createQueryBuilder('session')
|
||||||
|
.where('session.license_id = :licenseId', { licenseId })
|
||||||
|
.orderBy('session.session_start', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Build per-player session aggregates
|
||||||
|
const playerMap = new Map<string, Player>();
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!playerMap.has(session.steam_id)) {
|
||||||
|
const isOnline = session.session_end === null;
|
||||||
|
playerMap.set(session.steam_id, {
|
||||||
|
steam_id: session.steam_id,
|
||||||
|
player_name: session.player_name,
|
||||||
|
status: isOnline ? 'online' : 'offline',
|
||||||
|
last_seen: session.session_start,
|
||||||
|
ban_expires: null,
|
||||||
|
total_sessions: 0,
|
||||||
|
total_playtime_seconds: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = playerMap.get(session.steam_id)!;
|
||||||
|
entry.total_sessions = (entry.total_sessions || 0) + 1;
|
||||||
|
entry.total_playtime_seconds = (entry.total_playtime_seconds || 0) + (session.duration_seconds || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay ban status from most recent action per player
|
||||||
|
const recentActions = await this.actionRepo
|
||||||
.createQueryBuilder('action')
|
.createQueryBuilder('action')
|
||||||
.where('action.license_id = :licenseId', { licenseId })
|
.where('action.license_id = :licenseId', { licenseId })
|
||||||
.orderBy('action.created_at', 'DESC')
|
.orderBy('action.created_at', 'DESC')
|
||||||
.take(100)
|
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
// Group by steam_id to get unique players
|
// Track the most recent action per steam_id to determine ban state
|
||||||
const playerMap = new Map<string, Player>();
|
const latestActionBySteamId = new Map<string, PlayerAction>();
|
||||||
|
for (const action of recentActions) {
|
||||||
for (const action of actions) {
|
if (!latestActionBySteamId.has(action.steam_id)) {
|
||||||
if (!playerMap.has(action.steam_id)) {
|
latestActionBySteamId.set(action.steam_id, action);
|
||||||
// Determine status based on latest action
|
}
|
||||||
let status: 'online' | 'offline' | 'banned' = 'offline';
|
|
||||||
if (action.action_type === 'ban') {
|
|
||||||
status = 'banned';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playerMap.set(action.steam_id, {
|
for (const [steamId, action] of latestActionBySteamId) {
|
||||||
|
if (action.action_type === 'ban') {
|
||||||
|
// Add player from actions even if they have no sessions
|
||||||
|
if (!playerMap.has(steamId)) {
|
||||||
|
playerMap.set(steamId, {
|
||||||
steam_id: action.steam_id,
|
steam_id: action.steam_id,
|
||||||
player_name: action.player_name,
|
player_name: action.player_name,
|
||||||
status,
|
status: 'banned',
|
||||||
last_seen: action.created_at,
|
last_seen: action.created_at,
|
||||||
ban_expires: action.duration_minutes
|
ban_expires: action.duration_minutes
|
||||||
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||||
: null,
|
: null,
|
||||||
|
total_sessions: 0,
|
||||||
|
total_playtime_seconds: 0,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const entry = playerMap.get(steamId)!;
|
||||||
|
entry.status = 'banned';
|
||||||
|
entry.ban_expires = action.duration_minutes
|
||||||
|
? new Date(action.created_at.getTime() + action.duration_minutes * 60000)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +110,9 @@ export class PlayersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a moderation action on a player
|
* Perform a moderation action on a player.
|
||||||
|
* Supported actions: kick, ban, unban, warn, note.
|
||||||
|
* kick/ban/unban are forwarded to the game server via NATS.
|
||||||
*/
|
*/
|
||||||
async performAction(
|
async performAction(
|
||||||
licenseId: string,
|
licenseId: string,
|
||||||
@@ -84,8 +132,8 @@ export class PlayersService {
|
|||||||
|
|
||||||
await this.actionRepo.save(action);
|
await this.actionRepo.save(action);
|
||||||
|
|
||||||
// For kick/ban, send NATS command to the server
|
// Forward kick, ban, and unban to the game server via NATS
|
||||||
if (dto.action_type === 'kick' || dto.action_type === 'ban') {
|
if (dto.action_type === 'kick' || dto.action_type === 'ban' || dto.action_type === 'unban') {
|
||||||
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
await this.natsService.sendServerCommand(licenseId, dto.action_type, {
|
||||||
steam_id: dto.steam_id,
|
steam_id: dto.steam_id,
|
||||||
reason: dto.reason,
|
reason: dto.reason,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { StatusController } from './status.controller';
|
|||||||
import { StatusService } from './status.service';
|
import { StatusService } from './status.service';
|
||||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, License]),
|
TypeOrmModule.forFeature([PublicSiteConfig, ServerConnection, ServerStats, License]),
|
||||||
],
|
],
|
||||||
controllers: [StatusController],
|
controllers: [StatusController],
|
||||||
providers: [StatusService],
|
providers: [StatusService],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
import { PublicSiteConfig } from '../../entities/public-site-config.entity';
|
||||||
import { ServerConnection } from '../../entities/server-connection.entity';
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
||||||
|
import { ServerStats } from '../../entities/server-stats.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,6 +13,8 @@ export class StatusService {
|
|||||||
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
|
private readonly publicSiteRepo: Repository<PublicSiteConfig>,
|
||||||
@InjectRepository(ServerConnection)
|
@InjectRepository(ServerConnection)
|
||||||
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
||||||
|
@InjectRepository(ServerStats)
|
||||||
|
private readonly serverStatsRepo: Repository<ServerStats>,
|
||||||
@InjectRepository(License)
|
@InjectRepository(License)
|
||||||
private readonly licenseRepo: Repository<License>,
|
private readonly licenseRepo: Repository<License>,
|
||||||
) {}
|
) {}
|
||||||
@@ -32,12 +35,18 @@ export class StatusService {
|
|||||||
where: { license_id: config.license_id },
|
where: { license_id: config.license_id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch the most recent stats row for this server to get live player counts
|
||||||
|
const latestStats = await this.serverStatsRepo.findOne({
|
||||||
|
where: { license_id: config.license_id },
|
||||||
|
order: { recorded_at: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server_name: license?.subdomain || 'Unknown Server',
|
server_name: license?.server_name || license?.subdomain || 'Unknown Server',
|
||||||
subdomain: license?.subdomain || null,
|
subdomain: license?.subdomain || null,
|
||||||
status: connection?.connection_status || 'offline',
|
status: connection?.connection_status || 'offline',
|
||||||
player_count: 0, // Would need real-time data
|
player_count: latestStats?.player_count ?? 0,
|
||||||
max_players: 0,
|
max_players: latestStats?.max_players ?? 0,
|
||||||
steam_connect_url: config.steam_connect_url,
|
steam_connect_url: config.steam_connect_url,
|
||||||
motd: config.motd,
|
motd: config.motd,
|
||||||
discord_invite_url: config.discord_invite_url,
|
discord_invite_url: config.discord_invite_url,
|
||||||
|
|||||||
Reference in New Issue
Block a user