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,93 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { deterministicId, type AtmEvent } from '@atm/shared';
|
||||
|
||||
const MONTHS: Record<string, number> = {
|
||||
Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11,
|
||||
};
|
||||
|
||||
function tzOffsetMs(utcMs: number, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).formatToParts(new Date(utcMs));
|
||||
const get = (t: string) => Number(parts.find((p) => p.type === t)!.value);
|
||||
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
return asUtc - Math.floor(utcMs / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** "12Oct2026 09:03:03.006" in the server's local time zone → epoch ms. */
|
||||
export function parseLogTimestamp(s: string, timeZone: string): number {
|
||||
const m = /^(\d{2})([A-Z][a-z]{2})(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(s);
|
||||
if (!m || MONTHS[m[2]!] === undefined) return Number.NaN;
|
||||
const local = Date.UTC(+m[3]!, MONTHS[m[2]!]!, +m[1]!, +m[4]!, +m[5]!, +m[6]!, +m[7]!);
|
||||
let utc = local - tzOffsetMs(local, timeZone);
|
||||
utc = local - tzOffsetMs(utc, timeZone); // second pass settles DST boundaries
|
||||
return utc;
|
||||
}
|
||||
|
||||
const LINE = /^\[([^\]]+)\] \[[^\]]+\] \[([^\]]+)\]: (.*)$/;
|
||||
const NAME = '([A-Za-z0-9_]{1,16})';
|
||||
const UUID_OF = new RegExp(`^UUID of player ${NAME} is ([0-9a-f-]{36})$`);
|
||||
const JOINED = new RegExp(`^${NAME} joined the game$`);
|
||||
const LEFT = new RegExp(`^${NAME} left the game$`);
|
||||
const ADVANCEMENT = new RegExp(`^${NAME} has (?:made the advancement|reached the goal|completed the challenge) \\[(.+)\\]$`);
|
||||
const SYSTEM_LOGGER = 'net.minecraft.server.MinecraftServer/';
|
||||
|
||||
/**
|
||||
* Parses complete lines of `logs/latest.log`. Only bytes up to the last newline are consumed, so a line that is
|
||||
* still being written is read again next time. `names` maps player name → uuid and is updated from
|
||||
* "UUID of player" lines; lines for players without known uuid are skipped.
|
||||
* Event IDs derive from the absolute byte offset and line content, so re-reading yields the same IDs.
|
||||
*/
|
||||
export function parseLogChunk(
|
||||
chunk: Buffer,
|
||||
baseOffset: number,
|
||||
names: Map<string, string>,
|
||||
timeZone: string,
|
||||
): { events: AtmEvent[]; consumed: number } {
|
||||
const end = chunk.lastIndexOf(0x0a);
|
||||
if (end < 0) return { events: [], consumed: 0 };
|
||||
const events: AtmEvent[] = [];
|
||||
let offset = 0;
|
||||
while (offset <= end) {
|
||||
const nl = chunk.indexOf(0x0a, offset);
|
||||
const line = chunk.toString('utf8', offset, nl).replace(/\r$/, '');
|
||||
const lineOffset = baseOffset + offset;
|
||||
offset = nl + 1;
|
||||
|
||||
const m = LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, time, logger, msg] = m as unknown as [string, string, string, string];
|
||||
|
||||
const uuidOf = UUID_OF.exec(msg);
|
||||
if (uuidOf) {
|
||||
names.set(uuidOf[1]!, uuidOf[2]!);
|
||||
continue;
|
||||
}
|
||||
if (logger !== SYSTEM_LOGGER) continue;
|
||||
|
||||
const id = () => deterministicId(`log:${lineOffset}:${createHash('sha256').update(line).digest('hex')}`);
|
||||
const ts = parseLogTimestamp(time, timeZone);
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
|
||||
let hit: RegExpExecArray | null;
|
||||
if ((hit = JOINED.exec(msg)) || (hit = LEFT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: JOINED.test(msg) ? 'player_join' : 'player_leave', ts, uuid, name });
|
||||
} else if ((hit = ADVANCEMENT.exec(msg))) {
|
||||
const name = hit[1]!;
|
||||
const uuid = names.get(name);
|
||||
if (!uuid) continue;
|
||||
events.push({ id: id(), type: 'advancement', ts, uuid, name, advancementId: `log:${hit[2]}`, title: hit[2]! });
|
||||
}
|
||||
}
|
||||
return { events, consumed: end + 1 };
|
||||
}
|
||||
Reference in new issue
Block a user