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,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],
|
||||
);
|
||||
});
|
||||
@@ -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)],
|
||||
);
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user