feat: ATM10-Statistikportal

Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1):

- mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit,
  FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff
- backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT,
  FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots,
  Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker
- web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server,
  Datenquellen, Verwaltung
- shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client
- deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
// DTOs of the public read API, shared by backend and web.
export type Unit = 'hours' | 'count' | 'km' | 'blocks';
export type Period = 'all' | '30d' | '7d';
export type ItemCategory = 'mined' | 'crafted' | 'used' | 'picked_up';
export const ITEM_CATEGORIES: ItemCategory[] = ['mined', 'crafted', 'used', 'picked_up'];
export interface OverviewDto {
online: number;
totalPlayers: number;
totalPlayHours: number;
totalDeaths: number;
totalMobKills: number;
questsCompleted: number;
advancements: number;
lastUpdate: number | null;
}
export interface OnlinePlayerDto {
uuid: string;
name: string | null;
since: number;
dimension: string | null;
}
export interface OnlineDto {
players: OnlinePlayerDto[];
}
export interface BoardMetaDto {
id: string;
title: string;
description: string;
unit: Unit;
}
export interface LeaderboardEntryDto {
rank: number;
uuid: string;
name: string | null;
value: number;
}
export interface LeaderboardsDto {
boards: (BoardMetaDto & { top: LeaderboardEntryDto[] })[];
}
export interface LeaderboardDto {
board: BoardMetaDto;
period: Period;
entries: LeaderboardEntryDto[];
}
export interface PlayerListItemDto {
uuid: string;
name: string | null;
lastSeen: number | null;
playHours: number;
online: boolean;
}
export interface PlayerListDto {
players: PlayerListItemDto[];
total: number;
}
export interface PlayerQuestsDto {
teamId: string;
teamName: string | null;
completed: number;
total: number | null;
chapters: { chapterId: string; title: string | null; completed: number; total: number }[];
/** last completed quests that have a title in the quest config (newest first, max. 10) */
recent: { questId: string; title: string; chapterTitle: string | null; at: number }[];
}
export interface PlayerProfileDto {
uuid: string;
name: string | null;
firstSeen: number;
lastSeen: number | null;
online: boolean;
playHours: number;
sessions: number;
ranks: Record<string, { rank: number; value: number }>;
/** minecraft:custom counters except distances (raw values; play_time in ticks) */
general: Record<string, number>;
/** distances in cm by key (minecraft:walk_one_cm …) */
movement: Record<string, number>;
deathsByCause: { cause: string; count: number }[];
killedBy: { entity: string; count: number }[];
killsByType: { entity: string; count: number }[];
bossKills: { entity: string; at: number }[];
dimensions: { dimension: string; firstAt: number }[];
advancements: { id: string; title: string | null; at: number }[];
quests: PlayerQuestsDto;
/** minutes online per [weekday 0=Mon..6=Sun][hour 0..23], last 90 days, server time zone */
activityHeatmap: number[][];
}
export interface HistoryDto {
metric: string;
unit: Unit;
points: { t: number; v: number }[];
}
export interface ItemsDto {
category: ItemCategory;
mods: { namespace: string; total: number; items: { key: string; value: number }[] }[];
}
export interface ServerInfoDto {
startedAt: number;
mcVersion: string;
neoforgeVersion: string;
javaVersion: string;
modCount: number;
}
export interface ModsDto {
server: ServerInfoDto | null;
mods: { id: string; name: string; version: string; activity: number; category: string | null }[];
/** activity per category (tech/magic/farming/other) across all visible players */
categories: { category: string; activity: number }[];
}
export interface QuestsDto {
defsKnown: boolean;
chapters: { chapterId: string; title: string | null; total: number; maxCompleted: number; teamsFinished: number }[];
teams: { teamId: string; name: string | null; members: { uuid: string; name: string | null }[]; completed: number }[];
}
export interface HealthPointDto {
t: number;
msptAvg: number;
msptP95: number;
tps: number;
players: number;
heapUsedMb: number;
heapMaxMb: number;
}
export interface ServerDto {
info: ServerInfoDto | null;
health: HealthPointDto[];
}
export interface SourcesDto {
push: { lastEventAt: number | null; lastServerStart: number | null; reachable: boolean };
sftp: { enabled: boolean; lastSyncAt: number | null; lastError: string | null };
limitations: string[];
}
export type LiveKind = 'online' | 'death' | 'advancement' | 'quest' | 'counters';
export interface LiveEvent {
id: number;
kind: LiveKind;
data: Record<string, unknown>;
}
+8
View File
@@ -0,0 +1,8 @@
import { v5 } from 'uuid';
/** Namespace for deterministic event IDs of SFTP-derived events. Never change: IDs must stay stable across versions. */
export const SFTP_NAMESPACE = 'b3a1f0de-7c2e-4f43-9a51-6d2a1c0e9f10';
export function deterministicId(key: string): string {
return v5(key, SFTP_NAMESPACE);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './protocol.ts';
export * from './ids.ts';
+117
View File
@@ -0,0 +1,117 @@
import { z } from 'zod';
export const PROTOCOL_VERSION = 1;
export const MAX_BATCH_EVENTS = 500;
const uuid = z.string().uuid();
const resourceId = z.string().min(1).max(256);
const playerName = z.string().min(1).max(32);
const base = { id: uuid, ts: z.number().int().nonnegative() };
const player = { uuid, name: playerName };
export const serverStartSchema = z.object({
...base,
type: z.literal('server_start'),
mcVersion: z.string().max(64),
neoforgeVersion: z.string().max(64),
javaVersion: z.string().max(64),
mods: z.array(z.object({ id: z.string().max(128), version: z.string().max(128), name: z.string().max(256) })).max(5000),
});
export const serverStopSchema = z.object({ ...base, type: z.literal('server_stop') });
export const playerJoinSchema = z.object({ ...base, ...player, type: z.literal('player_join') });
export const playerLeaveSchema = z.object({ ...base, ...player, type: z.literal('player_leave') });
export const playerDeathSchema = z.object({
...base,
...player,
type: z.literal('player_death'),
dimension: resourceId,
cause: resourceId,
killerType: resourceId.nullable(),
killerName: z.string().max(256).nullable(),
});
export const entityKillSchema = z.object({
...base,
...player,
type: z.literal('entity_kill'),
dimension: resourceId,
victimType: resourceId,
isBoss: z.boolean(),
weapon: resourceId.nullable(),
});
export const advancementSchema = z.object({
...base,
uuid,
/** null when derived from world/advancements files, which carry no player name */
name: playerName.nullable(),
type: z.literal('advancement'),
advancementId: resourceId,
title: z.string().max(256).nullable(),
});
export const dimensionChangeSchema = z.object({
...base,
...player,
type: z.literal('dimension_change'),
from: resourceId,
to: resourceId,
});
export const statsSnapshotSchema = z.object({
...base,
type: z.literal('stats_snapshot'),
uuid,
name: playerName.nullable(),
full: z.boolean(),
stats: z.record(resourceId, z.record(resourceId, z.number().int().nonnegative())),
});
export const fileSyncSchema = z.object({
...base,
type: z.literal('file_sync'),
path: z.string().min(1).max(512).regex(/^[^\\]+$/).refine((p) => !p.split('/').includes('..'), 'path traversal'),
sha256: z.string().regex(/^[0-9a-f]{64}$/),
content: z.string().max(1_600_000),
});
export const healthSchema = z.object({
...base,
type: z.literal('health'),
windowStart: z.number().int(),
windowEnd: z.number().int(),
msptAvg: z.number().nonnegative(),
msptP95: z.number().nonnegative(),
msptMax: z.number().nonnegative(),
tps: z.number().nonnegative(),
players: z.number().int().nonnegative(),
chunks: z.number().int().nonnegative(),
heapUsedMb: z.number().nonnegative(),
heapMaxMb: z.number().nonnegative(),
});
export const atmEventSchema = z.discriminatedUnion('type', [
serverStartSchema,
serverStopSchema,
playerJoinSchema,
playerLeaveSchema,
playerDeathSchema,
entityKillSchema,
advancementSchema,
dimensionChangeSchema,
statsSnapshotSchema,
fileSyncSchema,
healthSchema,
]);
export type AtmEvent = z.infer<typeof atmEventSchema>;
export type EventType = AtmEvent['type'];
export type EventOf<T extends EventType> = Extract<AtmEvent, { type: T }>;
/** Batch envelope; events are validated individually by the server so one bad event does not drop the batch. */
export const ingestBatchSchema = z.object({
protocol: z.literal(PROTOCOL_VERSION),
events: z.array(z.unknown()).min(1).max(MAX_BATCH_EVENTS),
});
export type IngestBatch = { protocol: 1; events: AtmEvent[] };
export interface IngestResponse {
accepted: string[];
duplicate: string[];
rejected: { id: string | null; reason: string }[];
}