ip geolocation + ping analysis
This commit is contained in:
@@ -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).
|
||||
# Example: https://kickkings.playpoolstudios.com
|
||||
# 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).
|
||||
# Example: /var/www/html/kickkings/logs/matchmaker/logs
|
||||
# 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
|
||||
|
||||
@@ -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
@@ -1,7 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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,7 +2,9 @@ create table public.users (
|
||||
id bigint generated by default as identity not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
username text null,
|
||||
email text null,
|
||||
password text null,
|
||||
ip_address text null,
|
||||
cc real null default '0'::real,
|
||||
rc real null default '0'::real,
|
||||
last_logged_at timestamp with time zone null default now(),
|
||||
|
||||
@@ -6,27 +6,29 @@ import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseStoredCoins } from "@/lib/coins-rc";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
|
||||
function normalizeValue(raw: FormDataEntryValue | null): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
return s === "" ? null : s;
|
||||
const EMPTY = "__empty__";
|
||||
const INVALID = "__invalid__";
|
||||
|
||||
function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
|
||||
const s = raw == null ? "" : String(raw).trim();
|
||||
return s === "" ? EMPTY : s;
|
||||
}
|
||||
|
||||
function normalizeSettingValue(
|
||||
key: string,
|
||||
raw: FormDataEntryValue | null,
|
||||
): string | null {
|
||||
): string | typeof EMPTY | typeof INVALID {
|
||||
const s = raw == null ? "" : String(raw).trim();
|
||||
|
||||
if (key === "entry_fee") {
|
||||
if (s === "") return null;
|
||||
if (s === "") return EMPTY;
|
||||
const coins = parseStoredCoins(s);
|
||||
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
||||
return String(coins);
|
||||
}
|
||||
|
||||
if (key === "bet_fee") {
|
||||
if (s === "") return null;
|
||||
if (s === "") return EMPTY;
|
||||
const n = Math.round(Number(s));
|
||||
if (!Number.isFinite(n)) return "__invalid__";
|
||||
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"));
|
||||
if (value === "__invalid__") {
|
||||
if (value === INVALID || value === EMPTY) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
@@ -80,6 +82,9 @@ export async function insertSetting(formData: FormData) {
|
||||
if (!key) {
|
||||
redirect("/settings?addError=missing");
|
||||
}
|
||||
if (value === EMPTY) {
|
||||
redirect("/settings?addError=missingValue");
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
@@ -95,3 +100,27 @@ export async function insertSetting(formData: FormData) {
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
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 {
|
||||
applySessionCookie,
|
||||
@@ -10,8 +18,23 @@ function invalidRedirect(request: Request) {
|
||||
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
||||
}
|
||||
|
||||
function invalidJson() {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 password: string;
|
||||
@@ -31,13 +54,16 @@ export async function POST(request: Request) {
|
||||
password = String(formData.get("password") ?? "");
|
||||
}
|
||||
|
||||
if (username !== "admin" || password !== "admin") {
|
||||
if (!verifyAdminCredentials(username, password)) {
|
||||
recordFailedLogin(clientIp);
|
||||
if (contentType.includes("application/json")) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
return invalidJson();
|
||||
}
|
||||
return invalidRedirect(request);
|
||||
}
|
||||
|
||||
clearLoginAttempts(clientIp);
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(
|
||||
|
||||
+6
-10
@@ -5,6 +5,7 @@ type Props = {
|
||||
export default async function LoginPage({ searchParams }: Props) {
|
||||
const { error } = await searchParams;
|
||||
const invalid = error === "1";
|
||||
const locked = error === "locked";
|
||||
|
||||
return (
|
||||
<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">
|
||||
Admin sign in
|
||||
</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
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
@@ -58,6 +49,11 @@ export default async function LoginPage({ searchParams }: Props) {
|
||||
required
|
||||
/>
|
||||
</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 ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
Invalid username or password.
|
||||
|
||||
+33
-7
@@ -25,6 +25,10 @@ import {
|
||||
} from "@/lib/match-log-analysis-server";
|
||||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||
import {
|
||||
loadPingAnalytics,
|
||||
type PingAnalyticsResult,
|
||||
} from "@/lib/ping-analytics";
|
||||
import type {
|
||||
AdminMatchRow,
|
||||
DbMatch,
|
||||
@@ -118,6 +122,22 @@ export default async function Home({
|
||||
let analysisTo = utcLast30DaysDateRange().to;
|
||||
let analysisPlayerIds: number[] = [];
|
||||
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 =
|
||||
null;
|
||||
|
||||
@@ -141,7 +161,7 @@ export default async function Home({
|
||||
} else {
|
||||
const usersRes = await supabase
|
||||
.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 })
|
||||
.limit(500);
|
||||
|
||||
@@ -252,12 +272,17 @@ export default async function Home({
|
||||
analysisFrom = range.from;
|
||||
analysisTo = range.to;
|
||||
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
|
||||
matchAnalysis = await analyzeMatchLogsForMatches(
|
||||
matches,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayerIds,
|
||||
);
|
||||
const [matchLogResult, pingResult] = await Promise.all([
|
||||
analyzeMatchLogsForMatches(
|
||||
matches,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayerIds,
|
||||
),
|
||||
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
|
||||
]);
|
||||
matchAnalysis = matchLogResult;
|
||||
pingAnalytics = pingResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +334,7 @@ export default async function Home({
|
||||
analysisTo={analysisTo}
|
||||
analysisPlayerIds={analysisPlayerIds}
|
||||
matchAnalysis={matchAnalysis}
|
||||
pingAnalytics={pingAnalytics}
|
||||
/>
|
||||
{editUser ? (
|
||||
<EditUserCcRcOverlay
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||||
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||||
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
|
||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||
import {
|
||||
@@ -223,6 +224,7 @@ type Props = {
|
||||
analysisTo: string;
|
||||
analysisPlayerIds: number[];
|
||||
matchAnalysis: MatchLogAnalysisResult;
|
||||
pingAnalytics: PingAnalyticsResult;
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
@@ -277,8 +279,10 @@ export function AdminDashboard({
|
||||
analysisTo,
|
||||
analysisPlayerIds,
|
||||
matchAnalysis,
|
||||
pingAnalytics,
|
||||
}: Props) {
|
||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||
const [playersSearch, setPlayersSearch] = useState("");
|
||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
@@ -330,6 +334,25 @@ export function AdminDashboard({
|
||||
if (!hideNoWinner) return filteredMatches;
|
||||
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
||||
}, [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(() => {
|
||||
if (tab !== "players" || !highlightId) return;
|
||||
@@ -546,6 +569,7 @@ export function AdminDashboard({
|
||||
activeDir={lbSortDir}
|
||||
onActivate={onLbSort}
|
||||
/>
|
||||
<th className="px-4 py-3 font-medium">Email</th>
|
||||
<LeaderboardSortTh
|
||||
label="RC balance"
|
||||
sortKey="rc"
|
||||
@@ -575,7 +599,7 @@ export function AdminDashboard({
|
||||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
colSpan={6}
|
||||
className="px-4 py-8 text-center text-zinc-500"
|
||||
>
|
||||
No wins recorded yet (no rows with a winner).
|
||||
@@ -603,6 +627,9 @@ export function AdminDashboard({
|
||||
)}
|
||||
</span>
|
||||
</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">
|
||||
{formatRcBalanceWithCoins(row.rcBalance)}
|
||||
</td>
|
||||
@@ -630,31 +657,51 @@ export function AdminDashboard({
|
||||
{usersError ? (
|
||||
<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">
|
||||
<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">
|
||||
<tr>
|
||||
<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">Email</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">Created</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{users.length === 0 ? (
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
colSpan={9}
|
||||
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>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => {
|
||||
filteredUsers.map((u) => {
|
||||
const isHi =
|
||||
highlightId != null &&
|
||||
highlightId === String(u.id);
|
||||
@@ -672,6 +719,9 @@ export function AdminDashboard({
|
||||
<ClickableUserId id={u.id} />
|
||||
</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.rc ?? "—"}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
@@ -680,6 +730,9 @@ export function AdminDashboard({
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{formatTs(u.last_logged_at)}
|
||||
</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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
@@ -705,6 +758,7 @@ export function AdminDashboard({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : tab === "matchmaker" ? (
|
||||
@@ -751,6 +805,7 @@ export function AdminDashboard({
|
||||
) : tab === "analysis" ? (
|
||||
<AdminMatchAnalysis
|
||||
analysis={matchAnalysis}
|
||||
pingAnalytics={pingAnalytics}
|
||||
users={users}
|
||||
analysisFrom={analysisFrom}
|
||||
analysisTo={analysisTo}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
|
||||
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
|
||||
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
|
||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
||||
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
|
||||
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
|
||||
import type { DbUser } from "@/types/database";
|
||||
|
||||
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 = {
|
||||
analysis: MatchLogAnalysisResult;
|
||||
pingAnalytics: PingAnalyticsResult;
|
||||
users: DbUser[];
|
||||
analysisFrom: string;
|
||||
analysisTo: string;
|
||||
@@ -90,6 +105,7 @@ type Props = {
|
||||
|
||||
export function AdminMatchAnalysis({
|
||||
analysis,
|
||||
pingAnalytics,
|
||||
users,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
@@ -116,6 +132,34 @@ export function AdminMatchAnalysis({
|
||||
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
|
||||
: 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 (
|
||||
<section className="space-y-6">
|
||||
{analysis.configError ? (
|
||||
@@ -394,6 +438,183 @@ export function AdminMatchAnalysis({
|
||||
)}
|
||||
</section>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
insertSetting,
|
||||
deleteSetting,
|
||||
updateSetting,
|
||||
} from "@/app/actions/settings-actions";
|
||||
import {
|
||||
@@ -20,39 +21,58 @@ type Props = {
|
||||
};
|
||||
|
||||
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
const [value, setValue] = useState(row.value ?? "");
|
||||
const saveDisabled = value.trim() === "";
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`setting-value-${index}`}
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<form action={updateSetting}>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`setting-value-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`setting-value-${index}`}
|
||||
name="value"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`setting-value-${index}`}
|
||||
name="value"
|
||||
type="text"
|
||||
defaultValue={row.value ?? ""}
|
||||
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"
|
||||
/>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<button
|
||||
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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,50 +84,64 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
const [v, setV] = useState(initial);
|
||||
|
||||
return (
|
||||
<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="value" value={String(v)} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`bet-fee-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<form action={updateSetting}>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<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="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`bet-fee-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
0–100 (saved as the number shown).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
id={`bet-fee-${index}`}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{v}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
0–100 (saved as the number shown).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
id={`bet-fee-${index}`}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<button
|
||||
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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,79 +157,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
return rcToCoins(Number(t));
|
||||
}, [rcText]);
|
||||
|
||||
const saveDisabled = rcText.trim() === "";
|
||||
|
||||
return (
|
||||
<form
|
||||
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) => {
|
||||
setLocalError(null);
|
||||
const trimmed = rcText.trim();
|
||||
if (trimmed === "") {
|
||||
e.preventDefault();
|
||||
setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
|
||||
return;
|
||||
}
|
||||
const rc = Number(trimmed);
|
||||
const coins = rcToCoins(rc);
|
||||
if (coins === null) {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="value"
|
||||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`entry-fee-rc-${index}`}
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<form
|
||||
action={updateSetting}
|
||||
onSubmit={(e) => {
|
||||
setLocalError(null);
|
||||
const trimmed = rcText.trim();
|
||||
if (trimmed === "") {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter an RC value (one decimal place; tenths digit 0–3).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rc = Number(trimmed);
|
||||
const coins = rcToCoins(rc);
|
||||
if (coins === null) {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="value"
|
||||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`entry-fee-rc-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||
(edit as RC; stored as coins)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`entry-fee-rc-${index}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={rcText}
|
||||
onChange={(e) => setRcText(e.target.value)}
|
||||
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 ? (
|
||||
<p
|
||||
className="mt-2 text-sm text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{localError}
|
||||
</p>
|
||||
) : coinsPreview !== null ? (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
|
||||
the database.
|
||||
</p>
|
||||
) : rcText.trim() !== "" ? (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Not a valid RC encoding yet — fix the value to save.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||
(edit as RC; stored as coins)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`entry-fee-rc-${index}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={rcText}
|
||||
onChange={(e) => setRcText(e.target.value)}
|
||||
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 ? (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{localError}
|
||||
</p>
|
||||
) : coinsPreview !== null ? (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
|
||||
database.
|
||||
</p>
|
||||
) : rcText.trim() !== "" ? (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Not a valid RC encoding yet — fix the value to save.
|
||||
</p>
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<button
|
||||
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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,6 +272,9 @@ export function AdminSettingsEditor({
|
||||
saveError,
|
||||
addError,
|
||||
}: Props) {
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||
{saveError ? (
|
||||
@@ -255,6 +316,10 @@ export function AdminSettingsEditor({
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Enter a non-empty key.
|
||||
</p>
|
||||
) : addError === "missingValue" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Enter a non-empty value.
|
||||
</p>
|
||||
) : addError === "config" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Supabase admin client is not configured.
|
||||
@@ -277,7 +342,8 @@ export function AdminSettingsEditor({
|
||||
id="new-setting-key"
|
||||
name="newKey"
|
||||
type="text"
|
||||
required
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
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"
|
||||
@@ -294,6 +360,8 @@ export function AdminSettingsEditor({
|
||||
id="new-setting-value"
|
||||
name="newValue"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
@@ -301,6 +369,7 @@ export function AdminSettingsEditor({
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Add row
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
formatRcBalanceWithCoins,
|
||||
formatRcLabelFromCoinsBigInt,
|
||||
} from "@/lib/coins-rc";
|
||||
import { countryFlagFromCode } from "@/lib/ip-geolocation";
|
||||
import { usePlayerCard } from "@/components/player-card-context";
|
||||
|
||||
function formatTsUtc(value: string | null): string {
|
||||
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
|
||||
</div>
|
||||
{load.status === "ok" ? (
|
||||
<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">
|
||||
<span>Account created</span>
|
||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
|
||||
{formatTsUtc(load.data.lastSeen)}
|
||||
</span>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = {
|
||||
export type LeaderboardRow = {
|
||||
userId: number;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
/** Matches with `winner_id` equal to this user (same as player card). */
|
||||
matchesWon: number;
|
||||
rcBalance: number | null;
|
||||
@@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins(
|
||||
);
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
@@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins(
|
||||
}
|
||||
|
||||
const nameById = new Map<number, string | null>();
|
||||
const emailById = new Map<number, string | null>();
|
||||
const rcById = new Map<number, number | null>();
|
||||
for (const u of usersRes.data ?? []) {
|
||||
const uid = u.id as number;
|
||||
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;
|
||||
rcById.set(
|
||||
uid,
|
||||
@@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins(
|
||||
return {
|
||||
userId,
|
||||
username: nameById.get(userId) ?? null,
|
||||
email: emailById.get(userId) ?? null,
|
||||
matchesWon,
|
||||
rcBalance: rcById.get(userId) ?? null,
|
||||
winRatePercent,
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DbMatch, DbTransaction } from "@/types/database";
|
||||
import { txAmountToBigInt } from "@/lib/ledger-integrity";
|
||||
import { countryCodeFromIp } from "@/lib/ip-geolocation";
|
||||
|
||||
const TX_PAGE = 5000;
|
||||
/** Recent matches listed on the player card (newest first). */
|
||||
@@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = {
|
||||
export type PlayerCardData = {
|
||||
id: number;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
createdAt: string | null;
|
||||
lastSeen: string | null;
|
||||
lastLoggedInIp: string | null;
|
||||
lastLoggedInCountryCode: string | null;
|
||||
rcBalance: number | null;
|
||||
ccBalance: number | null;
|
||||
matchesPlayed: number;
|
||||
@@ -83,7 +87,7 @@ export async function fetchPlayerCardData(
|
||||
|
||||
const { data: userRow, error: userErr } = await supabase
|
||||
.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)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -270,8 +274,13 @@ export async function fetchPlayerCardData(
|
||||
data: {
|
||||
id: userRow.id as number,
|
||||
username: (userRow.username as string | null) ?? null,
|
||||
email: (userRow.email as string | null) ?? null,
|
||||
createdAt: (userRow.created_at as string) ?? 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,
|
||||
ccBalance: (userRow.cc as number | null) ?? null,
|
||||
matchesPlayed,
|
||||
|
||||
@@ -3,7 +3,9 @@ export type DbUser = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
password: string | null;
|
||||
ip_address: string | null;
|
||||
cc: number | null;
|
||||
rc: number | null;
|
||||
last_logged_at: string | null;
|
||||
@@ -52,3 +54,13 @@ export type DbTransaction = {
|
||||
remarks: string | 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user