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>
76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
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']);
|
|
});
|
|
});
|