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:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000007", "type": "advancement", "ts": 1760260131149, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "advancementId": "mysticalagriculture:infusion", "title": "Infusing Efficiency" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000008", "type": "dimension_change", "ts": 1760271736798, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "from": "minecraft:overworld", "to": "minecraft:the_nether" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000006", "type": "entity_kill", "ts": 1760262000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "dimension": "minecraft:the_end", "victimType": "minecraft:ender_dragon", "isBoss": true, "weapon": "mekanismtools:refined_obsidian_sword" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-00000000000a", "type": "file_sync", "ts": 1760264000000, "path": "world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt", "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "content": "{\n\tversion: 1\n}\n" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-00000000000b", "type": "health", "ts": 1760265060000, "windowStart": 1760265000000, "windowEnd": 1760265060000, "msptAvg": 38.5, "msptP95": 61.2, "msptMax": 210.0, "tps": 20.0, "players": 3, "chunks": 1840, "heapUsedMb": 2200, "heapMaxMb": 8192 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000005", "type": "player_death", "ts": 1760261000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "dimension": "minecraft:the_nether", "cause": "minecraft:mob_attack", "killerType": "minecraft:wither_skeleton", "killerName": null }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000003", "type": "player_join", "ts": 1760259783006, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000004", "type": "player_leave", "ts": 1760270000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000001", "type": "server_start", "ts": 1760259000000, "mcVersion": "1.21.1", "neoforgeVersion": "21.1.219", "javaVersion": "21.0.5", "mods": [ { "id": "mekanism", "version": "10.7.19", "name": "Mekanism" }, { "id": "ftbquests", "version": "2101.1.36", "name": "FTB Quests" } ] }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000002", "type": "server_stop", "ts": 1760300000000 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "0199d6a0-0000-7000-8000-000000000009", "type": "stats_snapshot", "ts": 1760263000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "full": false, "stats": { "minecraft:custom": { "minecraft:play_time": 1175736, "minecraft:deaths": 8 }, "minecraft:mined": { "mekanism:osmium_ore": 12 } } }
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@atm/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./api": "./src/api.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './protocol.ts';
|
||||
export * from './ids.ts';
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { atmEventSchema, deterministicId, ingestBatchSchema } from '../src/index.ts';
|
||||
|
||||
const dir = join(import.meta.dirname, '..', 'fixtures', 'protocol');
|
||||
|
||||
describe('protocol v1', () => {
|
||||
it('every fixture validates', () => {
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
expect(files.length).toBe(11);
|
||||
for (const f of files) {
|
||||
const parsed = atmEventSchema.parse(JSON.parse(readFileSync(join(dir, f), 'utf8')));
|
||||
expect(parsed.type).toBe(f.replace('.json', ''));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown type', () => {
|
||||
const r = atmEventSchema.safeParse({ id: '0199d6a0-0000-7000-8000-000000000001', type: 'nope', ts: 1 });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects batch with 501 events', () => {
|
||||
const ev = JSON.parse(readFileSync(join(dir, 'server_stop.json'), 'utf8'));
|
||||
const r = ingestBatchSchema.safeParse({ protocol: 1, events: Array.from({ length: 501 }, () => ev) });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('deterministicId is stable', () => {
|
||||
expect(deterministicId('a')).toBe(deterministicId('a'));
|
||||
expect(deterministicId('a')).not.toBe(deterministicId('b'));
|
||||
expect(deterministicId('a')).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../tsconfig.base.json", "include": ["src", "test"] }
|
||||
Reference in new issue
Block a user