ip geolocation + ping analysis
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user