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

@@ -0,0 +1,25 @@
import { registerProjection } from '../process.ts';
import { ensurePlayer } from './players.ts';
registerProjection('advancement', async (c, e) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
await c.query(
`INSERT INTO advancements(uuid, advancement_id, achieved_at, title)
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4)
ON CONFLICT (uuid, advancement_id) DO UPDATE SET
achieved_at = LEAST(advancements.achieved_at, EXCLUDED.achieved_at),
title = COALESCE(EXCLUDED.title, advancements.title)`,
[e.uuid, e.advancementId, e.ts, e.title],
);
});
registerProjection('dimension_change', async (c, e) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
for (const dim of [e.from, e.to]) {
await c.query(
`INSERT INTO dimension_visits(uuid, dimension, first_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (uuid, dimension) DO UPDATE SET first_at = LEAST(dimension_visits.first_at, EXCLUDED.first_at)`,
[e.uuid, dim, e.ts],
);
}
});
@@ -0,0 +1,20 @@
import { registerProjection } from '../process.ts';
import { ensurePlayer } from './players.ts';
registerProjection('player_death', async (c, e) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
await c.query(
`INSERT INTO deaths(event_id, uuid, ts, dimension, cause, killer_type, killer_name)
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
[e.id, e.uuid, e.ts, e.dimension, e.cause, e.killerType, e.killerName],
);
});
registerProjection('entity_kill', async (c, e) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
await c.query(
`INSERT INTO kills(event_id, uuid, ts, dimension, victim_type, is_boss, weapon)
VALUES ($1, $2, to_timestamp($3 / 1000.0), $4, $5, $6, $7) ON CONFLICT DO NOTHING`,
[e.id, e.uuid, e.ts, e.dimension, e.victimType, e.isBoss, e.weapon],
);
});
+88
View File
@@ -0,0 +1,88 @@
import type { PoolClient } from 'pg';
import { isQuestConfigFile, parseQuestDefs, parseQuestProgress, parseTeam } from '../../parsers/ftbquests.ts';
import { deferToBatchEnd, registerProjection } from '../process.ts';
const QUEST_PROGRESS = /^world\/ftbquests\/[0-9a-f-]{32,36}\.snbt$/i;
const TEAM_FILE = /^world\/ftbteams\/(party|player)\/[0-9a-f-]{32,36}\.snbt$/i;
export async function recordError(c: PoolClient, source: string, message: string): Promise<void> {
await c.query('INSERT INTO app_errors(source, message) VALUES ($1, $2)', [source, message.slice(0, 2000)]);
}
/** Rebuilds quest_defs from all cached chapter and English lang files (once per batch). */
async function rebuildQuestDefs(c: PoolClient): Promise<void> {
const files = await c.query<{ path: string; content: string }>(
`SELECT path, content FROM file_cache WHERE path LIKE 'config/ftbquests/quests/%'`,
);
let defs;
try {
defs = parseQuestDefs(files.rows.filter((f) => isQuestConfigFile(f.path)));
} catch (err) {
await recordError(c, 'file_sync', `quest definitions: ${(err as Error).message}`);
return;
}
await c.query('DELETE FROM quest_defs');
if (defs.length) {
await c.query(
`INSERT INTO quest_defs(quest_id, chapter_id, chapter_title, title)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[])
ON CONFLICT (quest_id) DO NOTHING`,
[defs.map((d) => d.questId), defs.map((d) => d.chapterId), defs.map((d) => d.chapterTitle), defs.map((d) => d.title)],
);
}
}
registerProjection('file_sync', async (c, e) => {
const { rows } = await c.query<{ sha256: string }>('SELECT sha256 FROM file_cache WHERE path = $1', [e.path]);
if (rows[0]?.sha256 === e.sha256) return; // unchanged content
await c.query(
`INSERT INTO file_cache(path, sha256, content, updated_at) VALUES ($1, $2, $3, to_timestamp($4 / 1000.0))
ON CONFLICT (path) DO UPDATE SET sha256 = EXCLUDED.sha256, content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`,
[e.path, e.sha256, e.content, e.ts],
);
try {
if (QUEST_PROGRESS.test(e.path)) {
const p = parseQuestProgress(e.content);
// The file is the team's complete state: quests no longer listed were reset in FTB Quests.
await c.query(`DELETE FROM quest_progress WHERE team_id = $1 AND NOT (quest_id = ANY($2::text[]))`, [
p.teamId,
p.completed.map((q) => q.questId),
]);
if (p.completed.length) {
await c.query(
`INSERT INTO quest_progress(team_id, quest_id, completed_at)
SELECT $1, t.id, to_timestamp(t.at / 1000.0) FROM unnest($2::text[], $3::bigint[]) AS t(id, at)
ON CONFLICT (team_id, quest_id) DO UPDATE SET completed_at = EXCLUDED.completed_at`,
[p.teamId, p.completed.map((q) => q.questId), p.completed.map((q) => q.at)],
);
}
// Team names from ftbteams win; the progress file only provides a fallback ("Name#uuidprefix").
await c.query(`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, NULL) ON CONFLICT (team_id) DO NOTHING`, [
p.teamId,
p.name,
]);
} else if (isQuestConfigFile(e.path)) {
deferToBatchEnd(c, 'quest_defs', () => rebuildQuestDefs(c));
} else if (TEAM_FILE.test(e.path)) {
const t = parseTeam(e.content);
await c.query(
`INSERT INTO teams(team_id, name, type) VALUES ($1, $2, $3)
ON CONFLICT (team_id) DO UPDATE SET name = COALESCE(EXCLUDED.name, teams.name), type = EXCLUDED.type`,
[t.teamId, t.name, t.type],
);
// Only party membership is stored: a player outside a party plays as their own team (team id = uuid).
if (t.type === 'party') {
await c.query('DELETE FROM team_members WHERE team_id = $1', [t.teamId]);
if (t.members.length) {
// a player belongs to one party at a time
await c.query('DELETE FROM team_members WHERE uuid = ANY($1::uuid[])', [t.members]);
await c.query(`INSERT INTO team_members(team_id, uuid) SELECT $1, unnest($2::uuid[])`, [t.teamId, t.members]);
}
}
}
} catch (err) {
// A malformed file must not reject the batch; it stays cached and is reported to the admin.
await recordError(c, 'file_sync', `${e.path}: ${(err as Error).message}`);
}
});
@@ -0,0 +1,7 @@
// Side-effect imports register all projections with the pipeline.
import './sessions.ts';
import './combat.ts';
import './advancements.ts';
import './stats.ts';
import './server.ts';
import './files.ts';
@@ -0,0 +1,29 @@
import type { PoolClient } from 'pg';
/**
* Upserts a player. A non-null name replaces the stored one only if it was observed at least as late as the
* current name (late replays never revert a rename) and is recorded in name_history;
* `seen` updates last_seen (only for events proving the player was online).
*/
export async function ensurePlayer(c: PoolClient, uuid: string, name: string | null, ts: number, seen: boolean): Promise<void> {
await c.query(
`INSERT INTO players(uuid, name, name_seen_at, first_seen, last_seen)
VALUES ($1, $2, CASE WHEN $2::text IS NOT NULL THEN to_timestamp($3 / 1000.0) END,
to_timestamp($3 / 1000.0), CASE WHEN $4 THEN to_timestamp($3 / 1000.0) END)
ON CONFLICT (uuid) DO UPDATE SET
name = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
THEN EXCLUDED.name ELSE players.name END,
name_seen_at = CASE WHEN EXCLUDED.name IS NOT NULL AND (players.name_seen_at IS NULL OR EXCLUDED.name_seen_at >= players.name_seen_at)
THEN EXCLUDED.name_seen_at ELSE players.name_seen_at END,
first_seen = LEAST(players.first_seen, EXCLUDED.first_seen),
last_seen = CASE WHEN $4 THEN GREATEST(players.last_seen, EXCLUDED.last_seen) ELSE players.last_seen END`,
[uuid, name, ts, seen],
);
if (name) {
await c.query(
`INSERT INTO name_history(uuid, name, seen_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (uuid, name) DO UPDATE SET seen_at = GREATEST(name_history.seen_at, EXCLUDED.seen_at)`,
[uuid, name, ts],
);
}
}
@@ -0,0 +1,13 @@
import { registerProjection } from '../process.ts';
registerProjection('health', async (c, e) => {
await c.query(
`INSERT INTO server_health(bucket, mspt_avg, mspt_p95, mspt_max, tps, players, chunks, heap_used_mb, heap_max_mb)
VALUES (date_trunc('minute', to_timestamp($1 / 1000.0)), $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (bucket) DO UPDATE SET
mspt_avg = EXCLUDED.mspt_avg, mspt_p95 = EXCLUDED.mspt_p95, mspt_max = EXCLUDED.mspt_max, tps = EXCLUDED.tps,
players = EXCLUDED.players, chunks = EXCLUDED.chunks,
heap_used_mb = EXCLUDED.heap_used_mb, heap_max_mb = EXCLUDED.heap_max_mb`,
[e.windowStart, e.msptAvg, e.msptP95, e.msptMax, e.tps, e.players, e.chunks, e.heapUsedMb, e.heapMaxMb],
);
});
@@ -0,0 +1,65 @@
import { registerProjection } from '../process.ts';
import { ensurePlayer } from './players.ts';
/**
* Push and log (sftp) both report joins. A join that falls inside a session already recorded by the other
* source is the same visit seen twice and is skipped; only an open session of the same source is superseded.
*/
registerProjection('player_join', async (c, e, source) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
const { rowCount: covered } = await c.query(
`SELECT 1 FROM sessions
WHERE uuid = $1 AND source <> $3 AND started_at <= to_timestamp($2 / 1000.0)
AND (ended_at IS NULL OR ended_at >= to_timestamp($2 / 1000.0))`,
[e.uuid, e.ts, source],
);
if (covered) return;
await c.query(
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'superseded'
WHERE uuid = $1 AND ended_at IS NULL AND source = $3`,
[e.uuid, e.ts, source],
);
await c.query(`INSERT INTO sessions(uuid, started_at, source) VALUES ($1, to_timestamp($2 / 1000.0), $3)`, [e.uuid, e.ts, source]);
});
registerProjection('player_leave', async (c, e) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, true);
await c.query(
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($2 / 1000.0)), end_reason = 'leave'
WHERE uuid = $1 AND ended_at IS NULL`,
[e.uuid, e.ts],
);
});
registerProjection('server_stop', async (c, e) => {
await c.query(
`UPDATE sessions SET ended_at = GREATEST(started_at, to_timestamp($1 / 1000.0)), end_reason = 'stop'
WHERE ended_at IS NULL`,
[e.ts],
);
});
/**
* A server_start while sessions are still open means the server went down without server_stop (crash, kill).
* Each open session ends at the last sign of life before the start: the player's last event or the last
* health window, whichever is later — never later than the start itself.
*/
registerProjection('server_start', async (c, e) => {
await c.query(
`UPDATE sessions s SET
end_reason = 'crash',
ended_at = GREATEST(s.started_at, LEAST(to_timestamp($1 / 1000.0), COALESCE(GREATEST(
(SELECT max(ev.ts) FROM events ev
WHERE ev.payload->>'uuid' = s.uuid::text AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0)),
(SELECT to_timestamp(max((ev.payload->>'windowEnd')::bigint) / 1000.0) FROM events ev
WHERE ev.type = 'health' AND ev.ts >= s.started_at AND ev.ts < to_timestamp($1 / 1000.0))
), s.started_at)))
WHERE s.ended_at IS NULL`,
[e.ts],
);
await c.query(
`INSERT INTO server_info(started_at, mc_version, neoforge_version, java_version, mods)
VALUES (to_timestamp($1 / 1000.0), $2, $3, $4, $5)`,
[e.ts, e.mcVersion, e.neoforgeVersion, e.javaVersion, JSON.stringify(e.mods)],
);
});
+31
View File
@@ -0,0 +1,31 @@
import { registerProjection } from '../process.ts';
import { ensurePlayer } from './players.ts';
/**
* Counters are absolute. Per (uuid, category, key) the observation with the newest timestamp wins,
* so late or repeated deliveries (push vs. SFTP) never move a value backwards in time.
* Keys missing from a snapshot are left untouched, even for `full` snapshots.
*/
registerProjection('stats_snapshot', async (c, e, source) => {
await ensurePlayer(c, e.uuid, e.name, e.ts, false);
const cats: string[] = [];
const keys: string[] = [];
const vals: number[] = [];
for (const [cat, entries] of Object.entries(e.stats)) {
for (const [key, value] of Object.entries(entries)) {
cats.push(cat);
keys.push(key);
vals.push(value);
}
}
if (cats.length === 0) return;
await c.query(
`INSERT INTO stat_current(uuid, category, key, value, observed_at, source)
SELECT $1, t.cat, t.key, t.val, to_timestamp($5 / 1000.0), $6
FROM unnest($2::text[], $3::text[], $4::bigint[]) AS t(cat, key, val)
ON CONFLICT (uuid, category, key) DO UPDATE SET
value = EXCLUDED.value, observed_at = EXCLUDED.observed_at, source = EXCLUDED.source
WHERE stat_current.observed_at < EXCLUDED.observed_at`,
[e.uuid, cats, keys, vals, e.ts, source],
);
});