ip geolocation + ping analysis

This commit is contained in:
NextJS
2026-06-02 10:24:58 +00:00
parent dc8377177a
commit 4ed4a10eae
21 changed files with 1141 additions and 168 deletions
+223
View File
@@ -0,0 +1,223 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
import type { DbPingReport } from "@/types/database";
const BAD_PING_THRESHOLD_MS = 120;
const TOP_ROWS_LIMIT = 20;
type PingReportWithUser = DbPingReport & {
user:
| { id: number; username: string | null; ip_address: string | null }[]
| null;
};
export type PingGroupRow = {
key: string;
label: string;
reports: number;
players: number;
avgPing: number;
minPing: number;
maxPing: number;
badReports: number;
badRatePercent: number;
};
export type PingAnalyticsSummary = {
totalReports: number;
totalMatches: number;
totalPlayers: number;
avgPing: number | null;
p95Ping: number | null;
badThresholdMs: number;
badReports: number;
badRatePercent: number | null;
};
export type PingAnalyticsResult = {
summary: PingAnalyticsSummary;
byMatches: PingGroupRow[];
byPlayers: PingGroupRow[];
byCountries: PingGroupRow[];
error: string | null;
};
function percentile(sorted: number[], ratio: number): number | null {
if (sorted.length === 0) return null;
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.ceil(sorted.length * ratio) - 1),
);
return sorted[idx] ?? null;
}
function toGroupRow(
key: string,
label: string,
pings: number[],
players: Set<number>,
): PingGroupRow {
const reports = pings.length;
const sum = pings.reduce((acc, value) => acc + value, 0);
const minPing = reports > 0 ? Math.min(...pings) : 0;
const maxPing = reports > 0 ? Math.max(...pings) : 0;
const badReports = pings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
const badRatePercent = reports > 0 ? (badReports / reports) * 100 : 0;
return {
key,
label,
reports,
players: players.size,
avgPing: reports > 0 ? sum / reports : 0,
minPing,
maxPing,
badReports,
badRatePercent,
};
}
export async function loadPingAnalytics(
supabase: SupabaseClient,
from: string,
to: string,
playerIds: number[],
): Promise<PingAnalyticsResult> {
const rangeStartIso = `${from}T00:00:00.000Z`;
const rangeEndIso = `${to}T23:59:59.999Z`;
let query = supabase
.from("ping_reports")
.select("id, created_at, user_id, match_id, ip_address, ping, user:users(id, username, ip_address)")
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.order("created_at", { ascending: false })
.limit(10000);
if (playerIds.length > 0) {
query = query.in("user_id", playerIds);
}
const { data, error } = await query;
if (error) {
return {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: BAD_PING_THRESHOLD_MS,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: error.message,
};
}
const rows = (data ?? []) as PingReportWithUser[];
const allPings: number[] = [];
const uniquePlayers = new Set<number>();
const uniqueMatches = new Set<number>();
const byMatch = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const byPlayer = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const byCountry = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const ipByRowIndex = new Map<number, string | null>();
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const user = row.user?.[0] ?? null;
ipByRowIndex.set(i, row.ip_address ?? user?.ip_address ?? null);
}
const countryByRowIndex = new Map<number, string | null>();
await Promise.all(
[...ipByRowIndex.entries()].map(async ([index, ip]) => {
countryByRowIndex.set(index, await countryCodeFromIp(ip));
}),
);
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const ping = Number(row.ping);
const userId = Number(row.user_id);
const matchId = Number(row.match_id);
if (!Number.isFinite(ping) || !Number.isFinite(userId) || !Number.isFinite(matchId)) {
continue;
}
allPings.push(ping);
uniquePlayers.add(userId);
uniqueMatches.add(matchId);
const matchKey = String(matchId);
const matchState = byMatch.get(matchKey) ?? {
label: `M${matchId}`,
pings: [],
players: new Set<number>(),
};
matchState.pings.push(ping);
matchState.players.add(userId);
byMatch.set(matchKey, matchState);
const user = row.user?.[0] ?? null;
const username = user?.username?.trim();
const playerLabel = username ? `${username} (${userId})` : `User ${userId}`;
const playerKey = String(userId);
const playerState = byPlayer.get(playerKey) ?? {
label: playerLabel,
pings: [],
players: new Set<number>(),
};
playerState.pings.push(ping);
playerState.players.add(userId);
byPlayer.set(playerKey, playerState);
const country = countryByRowIndex.get(i) ?? "UNK";
const countryFlag = countryFlagFromCode(country);
const countryWithFlag = countryFlag ? `${countryFlag} ${country}` : country;
const countryState = byCountry.get(country) ?? {
label: countryWithFlag,
pings: [],
players: new Set<number>(),
};
countryState.pings.push(ping);
countryState.players.add(userId);
byCountry.set(country, countryState);
}
const sortedPings = [...allPings].sort((a, b) => a - b);
const badReports = allPings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
const toRankedRows = (
src: Map<string, { label: string; pings: number[]; players: Set<number> }>,
): PingGroupRow[] =>
[...src.entries()]
.map(([key, state]) => toGroupRow(key, state.label, state.pings, state.players))
.sort((a, b) => b.avgPing - a.avgPing || b.reports - a.reports)
.slice(0, TOP_ROWS_LIMIT);
return {
summary: {
totalReports: allPings.length,
totalMatches: uniqueMatches.size,
totalPlayers: uniquePlayers.size,
avgPing:
allPings.length > 0
? allPings.reduce((acc, value) => acc + value, 0) / allPings.length
: null,
p95Ping: percentile(sortedPings, 0.95),
badThresholdMs: BAD_PING_THRESHOLD_MS,
badReports,
badRatePercent:
allPings.length > 0 ? (badReports / allPings.length) * 100 : null,
},
byMatches: toRankedRows(byMatch),
byPlayers: toRankedRows(byPlayer),
byCountries: toRankedRows(byCountry),
error: null,
};
}