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
+8
View File
@@ -1,3 +1,7 @@
# Admin panel login (server only; required in production)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects). # Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
# Example: https://kickkings.playpoolstudios.com # Example: https://kickkings.playpoolstudios.com
# APP_ORIGIN= # APP_ORIGIN=
@@ -16,3 +20,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw). # Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw).
# Example: /var/www/html/kickkings/logs/matchmaker/logs # Example: /var/www/html/kickkings/logs/matchmaker/logs
# MATCHMAKER_LOGS_DIR= # MATCHMAKER_LOGS_DIR=
# Third-party IP geolocation provider (ipgeolocation.io)
# Used for country code lookup from user IP and ping report IP.
IPGEOLOCATION_API_KEY=your-ipgeolocation-api-key
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=KickKings Admin (Next.js)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=next
Group=next
WorkingDirectory=/home/next/kickkings_admin
Environment=NODE_ENV=production
Environment=PATH=/home/next/.nvm/versions/node/v24.13.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/home/next/.nvm/versions/node/v24.13.0/bin/npm run start
Restart=on-failure
RestartSec=5
# Hardening (optional; remove if the service fails to start)
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+1 -3
View File
@@ -1,7 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {};
/* config options here */
};
export default nextConfig; export default nextConfig;
+11
View File
@@ -0,0 +1,11 @@
create table public.ping_reports (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
user_id bigint not null,
match_id bigint not null,
ip_address text null,
ping integer not null,
constraint ping_reports_pkey primary key (id),
constraint ping_reports_user_id_fkey foreign key (user_id) references users (id),
constraint ping_reports_match_id_fkey foreign key (match_id) references matches (id)
) TABLESPACE pg_default;
+2
View File
@@ -2,7 +2,9 @@ create table public.users (
id bigint generated by default as identity not null, id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(), created_at timestamp with time zone not null default now(),
username text null, username text null,
email text null,
password text null, password text null,
ip_address text null,
cc real null default '0'::real, cc real null default '0'::real,
rc real null default '0'::real, rc real null default '0'::real,
last_logged_at timestamp with time zone null default now(), last_logged_at timestamp with time zone null default now(),
+37 -8
View File
@@ -6,27 +6,29 @@ import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { parseStoredCoins } from "@/lib/coins-rc"; import { parseStoredCoins } from "@/lib/coins-rc";
import { createAdminSupabase } from "@/lib/supabase/admin"; import { createAdminSupabase } from "@/lib/supabase/admin";
function normalizeValue(raw: FormDataEntryValue | null): string | null { const EMPTY = "__empty__";
if (raw == null) return null; const INVALID = "__invalid__";
const s = String(raw).trim();
return s === "" ? null : s; function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
const s = raw == null ? "" : String(raw).trim();
return s === "" ? EMPTY : s;
} }
function normalizeSettingValue( function normalizeSettingValue(
key: string, key: string,
raw: FormDataEntryValue | null, raw: FormDataEntryValue | null,
): string | null { ): string | typeof EMPTY | typeof INVALID {
const s = raw == null ? "" : String(raw).trim(); const s = raw == null ? "" : String(raw).trim();
if (key === "entry_fee") { if (key === "entry_fee") {
if (s === "") return null; if (s === "") return EMPTY;
const coins = parseStoredCoins(s); const coins = parseStoredCoins(s);
if (!Number.isFinite(coins) || coins < 0) return "__invalid__"; if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
return String(coins); return String(coins);
} }
if (key === "bet_fee") { if (key === "bet_fee") {
if (s === "") return null; if (s === "") return EMPTY;
const n = Math.round(Number(s)); const n = Math.round(Number(s));
if (!Number.isFinite(n)) return "__invalid__"; if (!Number.isFinite(n)) return "__invalid__";
return String(Math.min(100, Math.max(0, n))); return String(Math.min(100, Math.max(0, n)));
@@ -47,7 +49,7 @@ export async function updateSetting(formData: FormData) {
} }
const value = normalizeSettingValue(key, formData.get("value")); const value = normalizeSettingValue(key, formData.get("value"));
if (value === "__invalid__") { if (value === INVALID || value === EMPTY) {
redirect("/settings?saveError=1"); redirect("/settings?saveError=1");
} }
@@ -80,6 +82,9 @@ export async function insertSetting(formData: FormData) {
if (!key) { if (!key) {
redirect("/settings?addError=missing"); redirect("/settings?addError=missing");
} }
if (value === EMPTY) {
redirect("/settings?addError=missingValue");
}
const supabase = createAdminSupabase(); const supabase = createAdminSupabase();
if (!supabase) { if (!supabase) {
@@ -95,3 +100,27 @@ export async function insertSetting(formData: FormData) {
redirect("/settings"); redirect("/settings");
} }
export async function deleteSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const key = String(formData.get("key") ?? "").trim();
if (!key) {
redirect("/settings?saveError=1");
}
const supabase = createAdminSupabase();
if (!supabase) {
redirect("/settings?saveError=1");
}
const { error } = await supabase.from("settings").delete().eq("key", key);
if (error) {
redirect("/settings?saveError=1");
}
redirect("/settings");
}
+28 -2
View File
@@ -1,4 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getClientIp } from "@/lib/auth/client-ip";
import { verifyAdminCredentials } from "@/lib/auth/credentials";
import {
checkLoginRateLimit,
clearLoginAttempts,
rateLimitedLoginResponse,
recordFailedLogin,
} from "@/lib/auth/login-rate-limit";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { import {
applySessionCookie, applySessionCookie,
@@ -10,8 +18,23 @@ function invalidRedirect(request: Request) {
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1")); return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
} }
function invalidJson() {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
export async function POST(request: Request) { export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? ""; const contentType = request.headers.get("content-type") ?? "";
const clientIp = getClientIp(request);
const rateLimit = checkLoginRateLimit(clientIp);
if (!rateLimit.allowed) {
return rateLimitedLoginResponse(
request,
contentType,
rateLimit.retryAfterSeconds,
publicRequestUrl,
);
}
let username: string; let username: string;
let password: string; let password: string;
@@ -31,13 +54,16 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? ""); password = String(formData.get("password") ?? "");
} }
if (username !== "admin" || password !== "admin") { if (!verifyAdminCredentials(username, password)) {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); return invalidJson();
} }
return invalidRedirect(request); return invalidRedirect(request);
} }
clearLoginAttempts(clientIp);
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true }); const res = NextResponse.json({ ok: true });
res.cookies.set( res.cookies.set(
+6 -10
View File
@@ -5,6 +5,7 @@ type Props = {
export default async function LoginPage({ searchParams }: Props) { export default async function LoginPage({ searchParams }: Props) {
const { error } = await searchParams; const { error } = await searchParams;
const invalid = error === "1"; const invalid = error === "1";
const locked = error === "locked";
return ( return (
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950"> <div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50"> <h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Admin sign in Admin sign in
</h1> </h1>
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
Default:{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>{" "}
/{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>
</p>
<form <form
action="/api/auth/login" action="/api/auth/login"
method="post" method="post"
@@ -58,6 +49,11 @@ export default async function LoginPage({ searchParams }: Props) {
required required
/> />
</div> </div>
{locked ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Too many failed attempts. Wait about 15 minutes, then try again.
</p>
) : null}
{invalid ? ( {invalid ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert"> <p className="text-sm text-red-600 dark:text-red-400" role="alert">
Invalid username or password. Invalid username or password.
+29 -3
View File
@@ -25,6 +25,10 @@ import {
} from "@/lib/match-log-analysis-server"; } from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser"; import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser"; import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
loadPingAnalytics,
type PingAnalyticsResult,
} from "@/lib/ping-analytics";
import type { import type {
AdminMatchRow, AdminMatchRow,
DbMatch, DbMatch,
@@ -118,6 +122,22 @@ export default async function Home({
let analysisTo = utcLast30DaysDateRange().to; let analysisTo = utcLast30DaysDateRange().to;
let analysisPlayerIds: number[] = []; let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult(); let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let pingAnalytics: PingAnalyticsResult = {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: 120,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: null,
};
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null = let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null; null;
@@ -141,7 +161,7 @@ export default async function Home({
} else { } else {
const usersRes = await supabase const usersRes = await supabase
.from("users") .from("users")
.select("id, created_at, username, cc, rc, last_logged_at") .select("id, created_at, username, email, ip_address, cc, rc, last_logged_at")
.order("id", { ascending: false }) .order("id", { ascending: false })
.limit(500); .limit(500);
@@ -252,12 +272,17 @@ export default async function Home({
analysisFrom = range.from; analysisFrom = range.from;
analysisTo = range.to; analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers); analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
matchAnalysis = await analyzeMatchLogsForMatches( const [matchLogResult, pingResult] = await Promise.all([
analyzeMatchLogsForMatches(
matches, matches,
analysisFrom, analysisFrom,
analysisTo, analysisTo,
analysisPlayerIds, analysisPlayerIds,
); ),
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
]);
matchAnalysis = matchLogResult;
pingAnalytics = pingResult;
} }
} }
@@ -309,6 +334,7 @@ export default async function Home({
analysisTo={analysisTo} analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds} analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis} matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics}
/> />
{editUser ? ( {editUser ? (
<EditUserCcRcOverlay <EditUserCcRcOverlay
+60 -5
View File
@@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc"; import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis"; import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser"; import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { import {
@@ -223,6 +224,7 @@ type Props = {
analysisTo: string; analysisTo: string;
analysisPlayerIds: number[]; analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult; matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
}; };
function StatCard({ function StatCard({
@@ -277,8 +279,10 @@ export function AdminDashboard({
analysisTo, analysisTo,
analysisPlayerIds, analysisPlayerIds,
matchAnalysis, matchAnalysis,
pingAnalytics,
}: Props) { }: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true); const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins"); const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc"); const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
@@ -330,6 +334,25 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches; if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id)); return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]); }, [filteredMatches, hideNoWinner]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return users;
return users.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [users, playersSearch]);
useEffect(() => { useEffect(() => {
if (tab !== "players" || !highlightId) return; if (tab !== "players" || !highlightId) return;
@@ -546,6 +569,7 @@ export function AdminDashboard({
activeDir={lbSortDir} activeDir={lbSortDir}
onActivate={onLbSort} onActivate={onLbSort}
/> />
<th className="px-4 py-3 font-medium">Email</th>
<LeaderboardSortTh <LeaderboardSortTh
label="RC balance" label="RC balance"
sortKey="rc" sortKey="rc"
@@ -575,7 +599,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? ( {(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr> <tr>
<td <td
colSpan={5} colSpan={6}
className="px-4 py-8 text-center text-zinc-500" className="px-4 py-8 text-center text-zinc-500"
> >
No wins recorded yet (no rows with a winner). No wins recorded yet (no rows with a winner).
@@ -603,6 +627,9 @@ export function AdminDashboard({
)} )}
</span> </span>
</td> </td>
<td className="max-w-48 truncate px-4 py-2 text-zinc-700 dark:text-zinc-300">
{row.email?.trim() ? row.email.trim() : "—"}
</td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300"> <td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)} {formatRcBalanceWithCoins(row.rcBalance)}
</td> </td>
@@ -630,31 +657,51 @@ export function AdminDashboard({
{usersError ? ( {usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p> <p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : ( ) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label className="min-w-[16rem] flex-1">
<span className="sr-only">Search players</span>
<input
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length}
</span>
</div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400"> <thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr> <tr>
<th className="px-4 py-3 font-medium">ID</th> <th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Username</th> <th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">CC</th> <th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th> <th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">Created</th> <th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th> <th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Actions</th> <th className="px-4 py-3 font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800"> <tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? ( {filteredUsers.length === 0 ? (
<tr> <tr>
<td <td
colSpan={7} colSpan={9}
className="px-4 py-8 text-center text-zinc-500" className="px-4 py-8 text-center text-zinc-500"
> >
No players yet. {users.length === 0
? "No players yet."
: "No players match this search."}
</td> </td>
</tr> </tr>
) : ( ) : (
users.map((u) => { filteredUsers.map((u) => {
const isHi = const isHi =
highlightId != null && highlightId != null &&
highlightId === String(u.id); highlightId === String(u.id);
@@ -672,6 +719,9 @@ export function AdminDashboard({
<ClickableUserId id={u.id} /> <ClickableUserId id={u.id} />
</td> </td>
<td className="px-4 py-2">{u.username ?? "—"}</td> <td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="max-w-48 truncate px-4 py-2">
{u.email?.trim() ? u.email.trim() : "—"}
</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td> <td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td> <td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
@@ -680,6 +730,9 @@ export function AdminDashboard({
<td className="px-4 py-2 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)} {formatTs(u.last_logged_at)}
</td> </td>
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap">
{u.ip_address?.trim() ? u.ip_address.trim() : "—"}
</td>
<td className="px-4 py-2 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link <Link
@@ -705,6 +758,7 @@ export function AdminDashboard({
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
</section> </section>
) : tab === "matchmaker" ? ( ) : tab === "matchmaker" ? (
@@ -751,6 +805,7 @@ export function AdminDashboard({
) : tab === "analysis" ? ( ) : tab === "analysis" ? (
<AdminMatchAnalysis <AdminMatchAnalysis
analysis={matchAnalysis} analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
users={users} users={users}
analysisFrom={analysisFrom} analysisFrom={analysisFrom}
analysisTo={analysisTo} analysisTo={analysisTo}
+222 -1
View File
@@ -1,13 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id"; import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box"; import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart"; import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart"; import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url"; import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser"; import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database"; import type { DbUser } from "@/types/database";
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -78,8 +79,22 @@ function StatBlock({
); );
} }
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
type Props = { type Props = {
analysis: MatchLogAnalysisResult; analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
users: DbUser[]; users: DbUser[];
analysisFrom: string; analysisFrom: string;
analysisTo: string; analysisTo: string;
@@ -90,6 +105,7 @@ type Props = {
export function AdminMatchAnalysis({ export function AdminMatchAnalysis({
analysis, analysis,
pingAnalytics,
users, users,
analysisFrom, analysisFrom,
analysisTo, analysisTo,
@@ -116,6 +132,34 @@ export function AdminMatchAnalysis({
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100 ? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null; : null;
const fmtMs = (value: number | null): string =>
value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`;
const fmtPct = (value: number | null): string =>
value == null
? "—"
: `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
const reports = pingAnalytics.summary.totalReports;
const avgPing = pingAnalytics.summary.avgPing;
const p95Ping = pingAnalytics.summary.p95Ping;
const badThreshold = pingAnalytics.summary.badThresholdMs;
const badReports = pingAnalytics.summary.badReports;
const badRate = pingAnalytics.summary.badRatePercent;
const goodCount =
reports > 0 && avgPing != null
? Math.max(0, reports - badReports)
: Math.max(0, reports - badReports);
const topCountryRows = pingAnalytics.byCountries.slice(0, 8);
const [pingView, setPingView] = useState<"matches" | "players" | "countries">(
"matches",
);
const pingRows =
pingView === "matches"
? pingAnalytics.byMatches
: pingView === "players"
? pingAnalytics.byPlayers
: pingAnalytics.byCountries;
return ( return (
<section className="space-y-6"> <section className="space-y-6">
{analysis.configError ? ( {analysis.configError ? (
@@ -394,6 +438,183 @@ export function AdminMatchAnalysis({
)} )}
</section> </section>
) : null} ) : null}
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping latency analytics
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Visual summary of current ping health and worst latency countries in this filter.
</p>
</div>
{pingAnalytics.error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load ping reports: {pingAnalytics.error}
</div>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat
label="Ping reports"
value={reports.toLocaleString("en-US")}
/>
<Stat label="Average ping" value={fmtMs(avgPing)} />
<Stat label="P95 ping" value={fmtMs(p95Ping)} />
<Stat
label={`Bad ping >= ${badThreshold}ms`}
value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`}
/>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping quality split
</h4>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<SplitBar
label={`Below ${badThreshold}ms`}
value={goodCount}
total={reports}
colorClassName="bg-emerald-500"
/>
<SplitBar
label={`${badThreshold}ms or above`}
value={badReports}
total={reports}
colorClassName="bg-rose-500"
/>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping breakdown
</h4>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setPingView("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By matches
</button>
<button
type="button"
onClick={() => setPingView("players")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "players"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By players
</button>
<button
type="button"
onClick={() => setPingView("countries")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "countries"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By country
</button>
</div>
</div>
{pingRows.length === 0 ? (
<p className="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
No ping data for this filter.
</p>
) : (
<div className="mt-3 space-y-2">
{pingRows.map((row) => (
<PingEntryBar
key={row.key}
label={row.label}
minPing={row.minPing}
avgPing={row.avgPing}
maxPing={row.maxPing}
/>
))}
</div>
)}
</div>
</>
)}
</section>
</section> </section>
); );
} }
function SplitBar({
label,
value,
total,
colorClassName,
}: {
label: string;
value: number;
total: number;
colorClassName: string;
}) {
const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-zinc-600 dark:text-zinc-300">
<span>{label}</span>
<span className="font-mono tabular-nums">
{value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%)
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className={`h-2.5 rounded-full ${colorClassName}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function PingEntryBar({
label,
minPing,
avgPing,
maxPing,
}: {
label: string;
minPing: number;
avgPing: number;
maxPing: number;
}) {
const clamped = Math.max(0, Math.min(400, avgPing));
const widthPct = (clamped / 400) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-mono text-zinc-700 dark:text-zinc-300">{label}</span>
<span className="font-mono tabular-nums text-zinc-600 dark:text-zinc-400">
min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className="h-2.5 rounded-full bg-amber-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
+87 -18
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
insertSetting, insertSetting,
deleteSetting,
updateSetting, updateSetting,
} from "@/app/actions/settings-actions"; } from "@/app/actions/settings-actions";
import { import {
@@ -20,13 +21,14 @@ type Props = {
}; };
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) { function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
const [value, setValue] = useState(row.value ?? "");
const saveDisabled = value.trim() === "";
return ( return (
<form <div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
action={updateSetting} <form action={updateSetting}>
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} /> <input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end"> <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<label <label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400" className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
@@ -40,19 +42,37 @@ function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
id={`setting-value-${index}`} id={`setting-value-${index}`}
name="value" name="value"
type="text" type="text"
defaultValue={row.value ?? ""} value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete="off" autoComplete="off"
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200" disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
> >
Save Save
</button> </button>
</div> </div>
</form> </form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
); );
} }
@@ -64,10 +84,8 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
const [v, setV] = useState(initial); const [v, setV] = useState(initial);
return ( return (
<form <div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
action={updateSetting} <form action={updateSetting}>
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} /> <input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} /> <input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
@@ -108,6 +126,22 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
</button> </button>
</div> </div>
</form> </form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
); );
} }
@@ -123,16 +157,20 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
return rcToCoins(Number(t)); return rcToCoins(Number(t));
}, [rcText]); }, [rcText]);
const saveDisabled = rcText.trim() === "";
return ( return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form <form
action={updateSetting} action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => { onSubmit={(e) => {
setLocalError(null); setLocalError(null);
const trimmed = rcText.trim(); const trimmed = rcText.trim();
if (trimmed === "") { if (trimmed === "") {
e.preventDefault(); e.preventDefault();
setLocalError("Enter an RC value (one decimal place; tenths digit 03)."); setLocalError(
"Enter an RC value (one decimal place; tenths digit 03).",
);
return; return;
} }
const rc = Number(trimmed); const rc = Number(trimmed);
@@ -174,13 +212,16 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/> />
{localError ? ( {localError ? (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert"> <p
className="mt-2 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{localError} {localError}
</p> </p>
) : coinsPreview !== null ? ( ) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400"> <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
database. the database.
</p> </p>
) : rcText.trim() !== "" ? ( ) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300"> <p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
@@ -190,12 +231,29 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
</div> </div>
<button <button
type="submit" type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200" disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
> >
Save Save
</button> </button>
</div> </div>
</form> </form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
); );
} }
@@ -214,6 +272,9 @@ export function AdminSettingsEditor({
saveError, saveError,
addError, addError,
}: Props) { }: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
return ( return (
<div className="mx-auto w-full max-w-[900px] space-y-8"> <div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? ( {saveError ? (
@@ -255,6 +316,10 @@ export function AdminSettingsEditor({
<p className="mt-3 text-sm text-red-600 dark:text-red-400"> <p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty key. Enter a non-empty key.
</p> </p>
) : addError === "missingValue" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty value.
</p>
) : addError === "config" ? ( ) : addError === "config" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400"> <p className="mt-3 text-sm text-red-600 dark:text-red-400">
Supabase admin client is not configured. Supabase admin client is not configured.
@@ -277,7 +342,8 @@ export function AdminSettingsEditor({
id="new-setting-key" id="new-setting-key"
name="newKey" name="newKey"
type="text" type="text"
required value={newKey}
onChange={(e) => setNewKey(e.target.value)}
autoComplete="off" autoComplete="off"
placeholder="e.g. maintenance_message" placeholder="e.g. maintenance_message"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
@@ -294,6 +360,8 @@ export function AdminSettingsEditor({
id="new-setting-value" id="new-setting-value"
name="newValue" name="newValue"
type="text" type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
autoComplete="off" autoComplete="off"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/> />
@@ -301,6 +369,7 @@ export function AdminSettingsEditor({
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"
disabled={newKey.trim() === "" || newValue.trim() === ""}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200" className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
> >
Add row Add row
+26
View File
@@ -16,6 +16,7 @@ import {
formatRcBalanceWithCoins, formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt, formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc"; } from "@/lib/coins-rc";
import { countryFlagFromCode } from "@/lib/ip-geolocation";
import { usePlayerCard } from "@/components/player-card-context"; import { usePlayerCard } from "@/components/player-card-context";
function formatTsUtc(value: string | null): string { function formatTsUtc(value: string | null): string {
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
</div> </div>
{load.status === "ok" ? ( {load.status === "ok" ? (
<div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400"> <div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Email</span>
<span className="truncate font-mono text-zinc-800 dark:text-zinc-200">
{load.data.email?.trim() ? load.data.email.trim() : "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5"> <div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Account created</span> <span>Account created</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200"> <span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
{formatTsUtc(load.data.lastSeen)} {formatTsUtc(load.data.lastSeen)}
</span> </span>
</div> </div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Last logged in IP</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{load.data.lastLoggedInIp?.trim()
? load.data.lastLoggedInIp.trim()
: "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Country code</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{(() => {
const code = load.data.lastLoggedInCountryCode?.trim();
if (!code) return "—";
const flag = countryFlagFromCode(code);
return flag ? `${flag} ${code}` : code;
})()}
</span>
</div>
</div> </div>
) : null} ) : null}
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
/**
* Best-effort client IP for rate limiting behind a reverse proxy.
* Falls back to a sentinel when unknown so attempts are still counted.
*/
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
+30
View File
@@ -0,0 +1,30 @@
import { timingSafeEqual } from "node:crypto";
function safeEqual(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return timingSafeEqual(aBuf, bBuf);
}
export function getAdminUsername(): string {
return process.env.ADMIN_USERNAME?.trim() || "admin";
}
export function getAdminPassword(): string | null {
const password = process.env.ADMIN_PASSWORD?.trim();
return password || null;
}
export function verifyAdminCredentials(
username: string,
password: string,
): boolean {
const expectedUsername = getAdminUsername();
const expectedPassword = getAdminPassword();
if (!expectedPassword) return false;
return (
safeEqual(username, expectedUsername) &&
safeEqual(password, expectedPassword)
);
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
const MAX_FAILURES = 5;
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 15 * 60 * 1000;
type Entry = {
failures: number;
windowStartedAt: number;
lockedUntil: number | null;
};
const attemptsByIp = new Map<string, Entry>();
function getEntry(ip: string): Entry {
const existing = attemptsByIp.get(ip);
if (existing) return existing;
const entry: Entry = {
failures: 0,
windowStartedAt: Date.now(),
lockedUntil: null,
};
attemptsByIp.set(ip, entry);
return entry;
}
function resetWindowIfExpired(entry: Entry, now: number): void {
if (now - entry.windowStartedAt >= WINDOW_MS) {
entry.failures = 0;
entry.windowStartedAt = now;
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
}
}
}
export type LoginRateLimitResult =
| { allowed: true }
| { allowed: false; retryAfterSeconds: number };
export function checkLoginRateLimit(ip: string): LoginRateLimitResult {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
if (entry.lockedUntil !== null && entry.lockedUntil > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((entry.lockedUntil - now) / 1000),
};
}
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
entry.failures = 0;
entry.windowStartedAt = now;
}
return { allowed: true };
}
export function recordFailedLogin(ip: string): void {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
entry.failures += 1;
if (entry.failures >= MAX_FAILURES) {
entry.lockedUntil = now + LOCKOUT_MS;
}
}
export function clearLoginAttempts(ip: string): void {
attemptsByIp.delete(ip);
}
export function rateLimitedLoginResponse(
request: Request,
contentType: string,
retryAfterSeconds: number,
publicRequestUrl: (request: Request, path: string) => URL,
): NextResponse {
const headers = { "Retry-After": String(retryAfterSeconds) };
if (contentType.includes("application/json")) {
return NextResponse.json(
{
error: "Too many failed login attempts. Try again later.",
retryAfterSeconds,
},
{ status: 429, headers },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=locked"),
{ headers },
);
}
+5 -1
View File
@@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = {
export type LeaderboardRow = { export type LeaderboardRow = {
userId: number; userId: number;
username: string | null; username: string | null;
email: string | null;
/** Matches with `winner_id` equal to this user (same as player card). */ /** Matches with `winner_id` equal to this user (same as player card). */
matchesWon: number; matchesWon: number;
rcBalance: number | null; rcBalance: number | null;
@@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins(
); );
const [usersRes, ...playedRes] = await Promise.all([ const [usersRes, ...playedRes] = await Promise.all([
supabase.from("users").select("id, username, rc").in("id", ids), supabase.from("users").select("id, username, email, rc").in("id", ids),
...playedPromises, ...playedPromises,
]); ]);
@@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins(
} }
const nameById = new Map<number, string | null>(); const nameById = new Map<number, string | null>();
const emailById = new Map<number, string | null>();
const rcById = new Map<number, number | null>(); const rcById = new Map<number, number | null>();
for (const u of usersRes.data ?? []) { for (const u of usersRes.data ?? []) {
const uid = u.id as number; const uid = u.id as number;
nameById.set(uid, (u.username as string | null) ?? null); nameById.set(uid, (u.username as string | null) ?? null);
emailById.set(uid, (u.email as string | null) ?? null);
const rawRc = u.rc as number | null; const rawRc = u.rc as number | null;
rcById.set( rcById.set(
uid, uid,
@@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins(
return { return {
userId, userId,
username: nameById.get(userId) ?? null, username: nameById.get(userId) ?? null,
email: emailById.get(userId) ?? null,
matchesWon, matchesWon,
rcBalance: rcById.get(userId) ?? null, rcBalance: rcById.get(userId) ?? null,
winRatePercent, winRatePercent,
+87
View File
@@ -0,0 +1,87 @@
const IPV4_WITH_OPTIONAL_PORT = /^(\d{1,3}(?:\.\d{1,3}){3})(?::\d{1,5})?$/;
const COUNTRY_LOOKUP_ENDPOINT = "https://api.ipgeolocation.io/v3/ipgeo";
const geoLookupCache = new Map<string, Promise<string | null>>();
function normalizeIp(raw: string | null | undefined): string | null {
const value = (raw ?? "").trim();
if (!value) return null;
if (value.toLowerCase() === "unknown") return null;
const first = value.split(",")[0]?.trim() ?? value;
if (!first) return null;
if (first.startsWith("::ffff:")) {
return first.slice("::ffff:".length);
}
const v4WithPort = IPV4_WITH_OPTIONAL_PORT.exec(first);
if (v4WithPort) {
return v4WithPort[1] ?? null;
}
// Handle [ipv6]:port style values.
if (first.startsWith("[") && first.includes("]")) {
const end = first.indexOf("]");
if (end > 1) return first.slice(1, end);
}
return first;
}
async function fetchCountryCodeFromApi(ip: string): Promise<string | null> {
const apiKey = process.env.IPGEOLOCATION_API_KEY?.trim();
if (!apiKey) return null;
const url = new URL(COUNTRY_LOOKUP_ENDPOINT);
url.searchParams.set("apiKey", apiKey);
url.searchParams.set("ip", ip);
try {
const res = await fetch(url.toString(), {
method: "GET",
headers: { Accept: "application/json" },
// 12h cache for same IP in Next server runtime.
next: { revalidate: 60 * 60 * 12 },
});
if (!res.ok) return null;
const json = (await res.json()) as {
location?: { country_code2?: string | null };
};
const cc = json.location?.country_code2?.trim().toUpperCase();
return cc ? cc : null;
} catch {
return null;
}
}
/** Best-effort ISO country code from an IP address (e.g. "IN", "US"). */
export async function countryCodeFromIp(
ip: string | null | undefined,
): Promise<string | null> {
const normalized = normalizeIp(ip);
if (!normalized) return null;
const cached = geoLookupCache.get(normalized);
if (cached) return cached;
const promise = fetchCountryCodeFromApi(normalized);
geoLookupCache.set(normalized, promise);
const result = await promise;
if (result == null) {
// allow retries for transient API/network failures
geoLookupCache.delete(normalized);
}
return result;
}
/** Convert ISO country code (e.g. "IN") to emoji flag (e.g. 🇮🇳). */
export function countryFlagFromCode(
countryCode: string | null | undefined,
): string | null {
const cc = (countryCode ?? "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(cc)) return null;
const base = 127397; // Regional indicator symbol letter A offset.
const chars = [...cc].map((ch) =>
String.fromCodePoint(base + ch.charCodeAt(0)),
);
return chars.join("");
}
+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,
};
}
+10 -1
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { DbMatch, DbTransaction } from "@/types/database"; import type { DbMatch, DbTransaction } from "@/types/database";
import { txAmountToBigInt } from "@/lib/ledger-integrity"; import { txAmountToBigInt } from "@/lib/ledger-integrity";
import { countryCodeFromIp } from "@/lib/ip-geolocation";
const TX_PAGE = 5000; const TX_PAGE = 5000;
/** Recent matches listed on the player card (newest first). */ /** Recent matches listed on the player card (newest first). */
@@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = {
export type PlayerCardData = { export type PlayerCardData = {
id: number; id: number;
username: string | null; username: string | null;
email: string | null;
createdAt: string | null; createdAt: string | null;
lastSeen: string | null; lastSeen: string | null;
lastLoggedInIp: string | null;
lastLoggedInCountryCode: string | null;
rcBalance: number | null; rcBalance: number | null;
ccBalance: number | null; ccBalance: number | null;
matchesPlayed: number; matchesPlayed: number;
@@ -83,7 +87,7 @@ export async function fetchPlayerCardData(
const { data: userRow, error: userErr } = await supabase const { data: userRow, error: userErr } = await supabase
.from("users") .from("users")
.select("id, username, created_at, last_logged_at, rc, cc") .select("id, username, email, created_at, last_logged_at, ip_address, rc, cc")
.eq("id", userId) .eq("id", userId)
.maybeSingle(); .maybeSingle();
@@ -270,8 +274,13 @@ export async function fetchPlayerCardData(
data: { data: {
id: userRow.id as number, id: userRow.id as number,
username: (userRow.username as string | null) ?? null, username: (userRow.username as string | null) ?? null,
email: (userRow.email as string | null) ?? null,
createdAt: (userRow.created_at as string) ?? null, createdAt: (userRow.created_at as string) ?? null,
lastSeen: (userRow.last_logged_at as string | null) ?? null, lastSeen: (userRow.last_logged_at as string | null) ?? null,
lastLoggedInIp: (userRow.ip_address as string | null) ?? null,
lastLoggedInCountryCode: await countryCodeFromIp(
(userRow.ip_address as string | null) ?? null,
),
rcBalance: (userRow.rc as number | null) ?? null, rcBalance: (userRow.rc as number | null) ?? null,
ccBalance: (userRow.cc as number | null) ?? null, ccBalance: (userRow.cc as number | null) ?? null,
matchesPlayed, matchesPlayed,
+12
View File
@@ -3,7 +3,9 @@ export type DbUser = {
id: number; id: number;
created_at: string; created_at: string;
username: string | null; username: string | null;
email: string | null;
password: string | null; password: string | null;
ip_address: string | null;
cc: number | null; cc: number | null;
rc: number | null; rc: number | null;
last_logged_at: string | null; last_logged_at: string | null;
@@ -52,3 +54,13 @@ export type DbTransaction = {
remarks: string | null; remarks: string | null;
match_id: number | null; match_id: number | null;
}; };
/** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */
export type DbPingReport = {
id: number;
created_at: string;
user_id: number;
match_id: number;
ip_address: string | null;
ping: number;
};