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).
|
# 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
|
||||||
|
|||||||
@@ -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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {};
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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,
|
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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 0–3).");
|
setLocalError(
|
||||||
|
"Enter an RC value (one decimal place; tenths digit 0–3).",
|
||||||
|
);
|
||||||
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
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,
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user