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

+75
View File
@@ -0,0 +1,75 @@
import type { Pool } from 'pg';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import '../src/pipeline/projections/index.ts';
import { processEvents } from '../src/pipeline/process.ts';
import { applyRetention, takeSnapshots } from '../src/stats/snapshots.ts';
import { closeTestPool, getTestPool, resetDb } from './helpers/db.ts';
import { ALICE, ev, T0 } from './helpers/events.ts';
let pool: Pool;
beforeEach(async () => {
pool = await getTestPool();
await resetDb(pool);
await processEvents(
pool,
[ev('stats_snapshot', { ...ALICE, full: false, ts: T0, stats: { 'minecraft:custom': { 'minecraft:play_time': 72000 } } })],
'push',
'atm10',
);
});
afterAll(closeTestPool);
const DAY = 86_400_000;
const count = async (granularity: string) =>
(await pool.query(`SELECT count(*)::int AS n FROM stat_snapshots WHERE granularity = $1`, [granularity])).rows[0].n;
describe('snapshots', () => {
it('takeSnapshots idempotent', async () => {
await takeSnapshots(pool, 'hour', new Date(T0 + 1000));
const first = await count('hour');
await takeSnapshots(pool, 'hour', new Date(T0 + 2000));
expect(await count('hour')).toBe(first);
const { rows } = await pool.query(`SELECT value FROM stat_snapshots WHERE metric = 'playtime'`);
expect(rows[0].value).toBe(72000);
});
it('retention deletes hourly older than 30 days but keeps daily', async () => {
await takeSnapshots(pool, 'hour', new Date(T0));
await takeSnapshots(pool, 'day', new Date(T0));
await applyRetention(pool, new Date(T0 + 31 * DAY));
expect(await count('hour')).toBe(0);
expect(await count('day')).toBeGreaterThan(0);
});
it('retention folds health minutes older than 14 days into hourly aggregates', async () => {
for (const m of [0, 1, 2]) {
await pool.query(
`INSERT INTO server_health VALUES (to_timestamp($1 / 1000.0), $2, 50, 100, 20, 2, 500, 1000, 4000)`,
[T0 + m * 60_000, 10 * (m + 1)],
);
}
await applyRetention(pool, new Date(T0 + 15 * DAY));
expect((await pool.query('SELECT count(*)::int AS n FROM server_health')).rows[0].n).toBe(0);
const { rows } = await pool.query('SELECT mspt_avg, samples FROM server_health_hourly');
expect(rows).toEqual([{ mspt_avg: 20, samples: 3 }]);
});
});
describe('events retention', () => {
it('drops bulky projected events after 30 days but keeps player events', async () => {
const old = T0 - 40 * DAY;
await processEvents(
pool,
[
ev('health', { msptAvg: 1, msptP95: 1, msptMax: 1, tps: 20, players: 0, chunks: 0, heapUsedMb: 1, heapMaxMb: 1, windowStart: old, windowEnd: old, ts: old }),
ev('player_join', { ...ALICE, ts: old }),
],
'push',
'atm10',
);
await pool.query(`UPDATE events SET received_at = to_timestamp($1 / 1000.0)`, [old]);
await applyRetention(pool, new Date(T0));
const { rows } = await pool.query(`SELECT type FROM events ORDER BY type`);
expect(rows.map((r) => r.type)).toEqual(['player_join']);
});
});