1053 lines
40 KiB
TypeScript
1053 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { ClickableUserId } from "@/components/clickable-user-id";
|
||
import { AdminLedger } from "@/components/admin-ledger";
|
||
import { AdminSystemLogs } from "@/components/admin-system-logs";
|
||
import { LocalTimestamp } from "@/components/local-timestamp";
|
||
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||
import type { AuditLogEntry } from "@/lib/auth/audit-log";
|
||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
|
||
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
|
||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||
import {
|
||
buildDashboardHref,
|
||
type AdminDashboardTab,
|
||
} from "@/lib/dashboard-search-url";
|
||
import {
|
||
localLast30DaysDateRange,
|
||
} from "@/lib/local-date-range";
|
||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||
import type {
|
||
AdminMatchRow,
|
||
DbMatch,
|
||
DbTransaction,
|
||
DbUser,
|
||
} from "@/types/database";
|
||
import {
|
||
txAmountToBigInt,
|
||
type SerializedLedgerGlobalSummary,
|
||
} from "@/lib/ledger-integrity";
|
||
import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
|
||
|
||
/** Bigint columns often arrive as strings from PostgREST / JSON. */
|
||
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
|
||
if (winnerId == null) return false;
|
||
if (typeof winnerId === "number") {
|
||
return Number.isInteger(winnerId) && winnerId > 0;
|
||
}
|
||
if (typeof winnerId === "string") {
|
||
const t = winnerId.trim();
|
||
if (t === "" || !/^-?\d+$/.test(t)) return false;
|
||
const n = Number(t);
|
||
return Number.isFinite(n) && n > 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function participantInMatch(m: DbMatch, participantId: number): boolean {
|
||
const red = m.user_red;
|
||
const blue = m.user_blue;
|
||
if (red != null && Number(red) === participantId) return true;
|
||
if (blue != null && Number(blue) === participantId) return true;
|
||
return false;
|
||
}
|
||
|
||
function coalesceUserId(v: number | string | null): number | null {
|
||
if (v == null) return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||
}
|
||
|
||
/** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */
|
||
function formatTs(value: string | null): string {
|
||
if (!value) return "—";
|
||
try {
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return value;
|
||
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
|
||
if (v == null) return "—";
|
||
const b = txAmountToBigInt(v);
|
||
try {
|
||
return b.toLocaleString("en-US");
|
||
} catch {
|
||
return String(v);
|
||
}
|
||
}
|
||
|
||
type LeaderboardSortKey = "rank" | "player" | "rc" | "mmr" | "winRate" | "wins";
|
||
|
||
function playerSortKey(row: LeaderboardRow): string {
|
||
const t = row.username?.trim();
|
||
if (t) return t.toLowerCase();
|
||
return "\uffff";
|
||
}
|
||
|
||
function isEscrowUsername(username: string | null): boolean {
|
||
return (username ?? "").toLowerCase().startsWith("match_escrow_");
|
||
}
|
||
|
||
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
|
||
if (key === "player" || key === "rank") return "asc";
|
||
return "desc";
|
||
}
|
||
|
||
function sortLeaderboardRows(
|
||
rows: LeaderboardRow[],
|
||
key: LeaderboardSortKey,
|
||
dir: "asc" | "desc",
|
||
): LeaderboardRow[] {
|
||
const out = [...rows];
|
||
out.sort((a, b) => {
|
||
let cmp = 0;
|
||
switch (key) {
|
||
case "rank":
|
||
cmp = a.winsLeaderboardRank - b.winsLeaderboardRank;
|
||
break;
|
||
case "wins":
|
||
cmp = a.matchesWon - b.matchesWon;
|
||
break;
|
||
case "player": {
|
||
const ka = playerSortKey(a);
|
||
const kb = playerSortKey(b);
|
||
cmp = ka.localeCompare(kb, "en", { sensitivity: "base" });
|
||
if (cmp === 0) cmp = a.userId - b.userId;
|
||
break;
|
||
}
|
||
case "rc": {
|
||
const aOk = a.rcBalance != null && Number.isFinite(a.rcBalance);
|
||
const bOk = b.rcBalance != null && Number.isFinite(b.rcBalance);
|
||
if (!aOk && !bOk) cmp = 0;
|
||
else if (!aOk) cmp = 1;
|
||
else if (!bOk) cmp = -1;
|
||
else cmp = a.rcBalance! - b.rcBalance!;
|
||
break;
|
||
}
|
||
case "mmr": {
|
||
const aOk = a.mmr != null && Number.isFinite(a.mmr);
|
||
const bOk = b.mmr != null && Number.isFinite(b.mmr);
|
||
if (!aOk && !bOk) cmp = 0;
|
||
else if (!aOk) cmp = 1;
|
||
else if (!bOk) cmp = -1;
|
||
else cmp = a.mmr! - b.mmr!;
|
||
break;
|
||
}
|
||
case "winRate": {
|
||
const aOk =
|
||
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
|
||
const bOk =
|
||
b.winRatePercent != null && Number.isFinite(b.winRatePercent);
|
||
if (!aOk && !bOk) cmp = 0;
|
||
else if (!aOk) cmp = 1;
|
||
else if (!bOk) cmp = -1;
|
||
else cmp = a.winRatePercent! - b.winRatePercent!;
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
if (cmp !== 0) return dir === "asc" ? cmp : -cmp;
|
||
return a.userId - b.userId;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function LeaderboardSortTh({
|
||
label,
|
||
sortKey,
|
||
activeKey,
|
||
activeDir,
|
||
onActivate,
|
||
align = "left",
|
||
}: {
|
||
label: string;
|
||
sortKey: LeaderboardSortKey;
|
||
activeKey: LeaderboardSortKey;
|
||
activeDir: "asc" | "desc";
|
||
onActivate: (key: LeaderboardSortKey) => void;
|
||
align?: "left" | "right";
|
||
}) {
|
||
const active = activeKey === sortKey;
|
||
const rowAlign =
|
||
align === "right" ? "text-right" : "text-left";
|
||
const btnAlign =
|
||
align === "right"
|
||
? "justify-end text-right"
|
||
: "justify-start text-left";
|
||
return (
|
||
<th
|
||
scope="col"
|
||
className={`px-4 py-3 font-medium ${rowAlign}`}
|
||
aria-sort={
|
||
active ? (activeDir === "asc" ? "ascending" : "descending") : "none"
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label={`Sort by ${label}`}
|
||
className={`-mx-1 flex w-full items-center gap-1 rounded px-1 py-0.5 text-inherit hover:bg-zinc-200/80 dark:hover:bg-zinc-700/80 ${btnAlign}`}
|
||
onClick={() => onActivate(sortKey)}
|
||
>
|
||
<span>{label}</span>
|
||
{active ? (
|
||
<span className="shrink-0 tabular-nums text-zinc-400" aria-hidden>
|
||
{activeDir === "asc" ? "↑" : "↓"}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
type Props = {
|
||
users: DbUser[];
|
||
matches: AdminMatchRow[];
|
||
usersError: string | null;
|
||
matchesError: string | null;
|
||
transactionsError: string | null;
|
||
/** Ledger tab: full-database totals + user nets (server scan). */
|
||
ledgerGlobalSummary: SerializedLedgerGlobalSummary | null;
|
||
/** Ledger tab: rows in the selected UTC date range (full set for audits). */
|
||
ledgerTransactionsFiltered: DbTransaction[];
|
||
/** Ledger tab: current page of the ledger table (sorted + paginated). */
|
||
ledgerTableRows: DbTransaction[];
|
||
ledgerTotalRowsInRange: number;
|
||
ledgerPage: number;
|
||
ledgerPageSize: number;
|
||
ledgerTotalPages: number;
|
||
ledgerSort: LedgerSortKey;
|
||
ledgerOrder: LedgerSortOrder;
|
||
ledgerFrom: string;
|
||
ledgerTo: string;
|
||
statsBundle: DashboardStatsBundle | null;
|
||
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
|
||
tab: AdminDashboardTab;
|
||
highlightId: string | null;
|
||
participantRaw: string | null;
|
||
matchmakerSource: MatchmakerLogSource;
|
||
matchmakerContent: string;
|
||
matchmakerError: string | null;
|
||
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
|
||
supplyError: boolean;
|
||
/** Matches tab: local date-only bounds + browser tz offset minutes. */
|
||
matchesFrom: string;
|
||
matchesTo: string;
|
||
matchesTzOffsetMinutes: number | null;
|
||
/** True when `mfrom`/`mto` were present and valid in the URL. */
|
||
matchesRangeExplicit: boolean;
|
||
analysisFrom: string;
|
||
analysisTo: string;
|
||
analysisPlayerIds: number[];
|
||
matchAnalysis: MatchLogAnalysisResult;
|
||
pingAnalytics: PingAnalyticsResult;
|
||
geolocationAnalytics: GeolocationAnalyticsResult;
|
||
auditEntries: AuditLogEntry[];
|
||
auditError: string | null;
|
||
canWritePlayers: boolean;
|
||
canWriteLedger: boolean;
|
||
/** Match IDs that have a replay JSON file on disk. */
|
||
matchIdsWithReplay: number[];
|
||
};
|
||
|
||
function StatCard({
|
||
title,
|
||
value,
|
||
}: {
|
||
title: string;
|
||
value: number | null;
|
||
}) {
|
||
const display =
|
||
value == null ? "—" : value.toLocaleString("en-US");
|
||
return (
|
||
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<div className="flex flex-col items-center text-center">
|
||
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||
{title}
|
||
</p>
|
||
<p className="mt-1.5 text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
|
||
{display}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AdminDashboard({
|
||
users,
|
||
matches,
|
||
usersError,
|
||
matchesError,
|
||
transactionsError,
|
||
ledgerGlobalSummary,
|
||
ledgerTransactionsFiltered,
|
||
ledgerTableRows,
|
||
ledgerTotalRowsInRange,
|
||
ledgerPage,
|
||
ledgerPageSize,
|
||
ledgerTotalPages,
|
||
ledgerSort,
|
||
ledgerOrder,
|
||
ledgerFrom,
|
||
ledgerTo,
|
||
statsBundle,
|
||
tab,
|
||
highlightId,
|
||
participantRaw,
|
||
matchmakerSource,
|
||
matchmakerContent,
|
||
matchmakerError,
|
||
supplyError,
|
||
matchesFrom,
|
||
matchesTo,
|
||
matchesTzOffsetMinutes,
|
||
matchesRangeExplicit,
|
||
analysisFrom,
|
||
analysisTo,
|
||
analysisPlayerIds,
|
||
matchAnalysis,
|
||
pingAnalytics,
|
||
geolocationAnalytics,
|
||
auditEntries,
|
||
auditError,
|
||
canWritePlayers,
|
||
canWriteLedger,
|
||
matchIdsWithReplay,
|
||
}: Props) {
|
||
const replaySet = useMemo(
|
||
() => new Set(matchIdsWithReplay),
|
||
[matchIdsWithReplay],
|
||
);
|
||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||
const [playersSearch, setPlayersSearch] = useState("");
|
||
const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
|
||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("mmr");
|
||
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
||
|
||
/** Once we know the browser offset, reload with mtz so day bounds are local. */
|
||
useEffect(() => {
|
||
if (tab !== "matches") return;
|
||
if (matchesTzOffsetMinutes != null) return;
|
||
const tz = new Date().getTimezoneOffset();
|
||
const range = matchesRangeExplicit
|
||
? { from: matchesFrom, to: matchesTo }
|
||
: localLast30DaysDateRange(tz);
|
||
const href = buildDashboardHref({
|
||
tab: "matches",
|
||
highlightId,
|
||
participantRaw,
|
||
matchesFrom: range.from,
|
||
matchesTo: range.to,
|
||
matchesTzOffsetMinutes: tz,
|
||
});
|
||
window.location.replace(href);
|
||
}, [
|
||
tab,
|
||
matchesTzOffsetMinutes,
|
||
matchesRangeExplicit,
|
||
highlightId,
|
||
participantRaw,
|
||
matchesFrom,
|
||
matchesTo,
|
||
]);
|
||
|
||
const lbRaw = statsBundle?.leaderboard;
|
||
const sortedLeaderboard = useMemo(() => {
|
||
const rows = lbRaw ?? [];
|
||
if (rows.length === 0) return [];
|
||
return sortLeaderboardRows(rows, lbSortKey, lbSortDir);
|
||
}, [lbRaw, lbSortKey, lbSortDir]);
|
||
|
||
const onLbSort = useCallback((key: LeaderboardSortKey) => {
|
||
setLbSortKey((prevKey) => {
|
||
if (key === prevKey) {
|
||
setLbSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||
return prevKey;
|
||
}
|
||
setLbSortDir(leaderboardDefaultSortDir(key));
|
||
return key;
|
||
});
|
||
}, []);
|
||
|
||
const userById = useMemo(() => {
|
||
const m = new Map<number, DbUser>();
|
||
for (const u of users) {
|
||
m.set(u.id, u);
|
||
}
|
||
return m;
|
||
}, [users]);
|
||
|
||
const usernameById = useMemo(() => {
|
||
const m = new Map<number, string | null>();
|
||
for (const u of users) {
|
||
m.set(u.id, u.username);
|
||
}
|
||
return m;
|
||
}, [users]);
|
||
|
||
const participantId = useMemo(() => {
|
||
if (!participantRaw) return null;
|
||
const n = Number(participantRaw);
|
||
return Number.isFinite(n) ? n : null;
|
||
}, [participantRaw]);
|
||
|
||
const filteredMatches = useMemo(() => {
|
||
if (participantId == null) return matches;
|
||
return matches.filter((m) => participantInMatch(m, participantId));
|
||
}, [matches, participantId]);
|
||
const visibleMatches = useMemo(() => {
|
||
if (!hideNoWinner) return filteredMatches;
|
||
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
||
}, [filteredMatches, hideNoWinner]);
|
||
const playersPool = useMemo(() => {
|
||
if (showEscrowAccounts) return users;
|
||
return users.filter((u) => !isEscrowUsername(u.username));
|
||
}, [users, showEscrowAccounts]);
|
||
const filteredUsers = useMemo(() => {
|
||
const q = playersSearch.trim().toLowerCase();
|
||
if (!q) return playersPool;
|
||
return playersPool.filter((u) => {
|
||
const haystack = [
|
||
String(u.id),
|
||
u.username ?? "",
|
||
u.email ?? "",
|
||
u.cc == null ? "" : String(u.cc),
|
||
u.rc == null ? "" : String(u.rc),
|
||
u.mmr == null ? "" : String(u.mmr),
|
||
formatTs(u.created_at),
|
||
formatTs(u.last_logged_at),
|
||
u.ip_address ?? "",
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return haystack.includes(q);
|
||
});
|
||
}, [playersPool, playersSearch]);
|
||
|
||
useEffect(() => {
|
||
if (tab !== "players" || !highlightId) return;
|
||
const el = document.getElementById(`player-row-${highlightId}`);
|
||
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||
}, [tab, highlightId]);
|
||
|
||
function editHref(u: DbUser): string {
|
||
return buildDashboardHref({
|
||
tab,
|
||
highlightId,
|
||
participantRaw,
|
||
editId: u.id,
|
||
});
|
||
}
|
||
|
||
const statusLabel = (status: number | null) => {
|
||
if (status === 2) return "Final";
|
||
if (status === 1) return "Live";
|
||
if (status === 0) return "Waiting";
|
||
return status == null ? "Unknown" : `Status ${status}`;
|
||
};
|
||
|
||
return (
|
||
<main className="flex-1 space-y-6 px-6 py-8">
|
||
<div className="mx-auto w-full max-w-[1400px]">
|
||
{tab === "dashboard" ? (
|
||
<section className="space-y-8">
|
||
{statsBundle?.error ? (
|
||
<div
|
||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||
role="status"
|
||
>
|
||
{statsBundle.error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-8 xl:grid-cols-2">
|
||
<div>
|
||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||
Active players
|
||
</h2>
|
||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||
<StatCard
|
||
title="Active players today"
|
||
value={statsBundle?.stats?.activePlayersToday ?? null}
|
||
/>
|
||
<StatCard
|
||
title="Active players (last 7 days)"
|
||
value={statsBundle?.stats?.activePlayersLastWeek ?? null}
|
||
/>
|
||
<StatCard
|
||
title="Active players (last 30 days)"
|
||
value={statsBundle?.stats?.activePlayersLastMonth ?? null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||
Matches
|
||
</h2>
|
||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||
<StatCard
|
||
title="Matches today"
|
||
value={statsBundle?.stats?.matchesToday ?? null}
|
||
/>
|
||
<StatCard
|
||
title="Matches (last 7 days)"
|
||
value={statsBundle?.stats?.matchesLastWeek ?? null}
|
||
/>
|
||
<StatCard
|
||
title="Matches (last 30 days)"
|
||
value={statsBundle?.stats?.matchesLastMonth ?? null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<StatCard
|
||
title="Total users"
|
||
value={statsBundle?.stats?.totalUsers ?? null}
|
||
/>
|
||
<StatCard
|
||
title="Total matches"
|
||
value={statsBundle?.stats?.totalMatches ?? null}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||
Top players by match wins
|
||
</h2>
|
||
<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>
|
||
<LeaderboardSortTh
|
||
label="#"
|
||
sortKey="rank"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
/>
|
||
<LeaderboardSortTh
|
||
label="Player"
|
||
sortKey="player"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
/>
|
||
<th className="px-4 py-3 font-medium">Email</th>
|
||
<LeaderboardSortTh
|
||
label="RC balance"
|
||
sortKey="rc"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
/>
|
||
<LeaderboardSortTh
|
||
label="MMR"
|
||
sortKey="mmr"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
align="right"
|
||
/>
|
||
<LeaderboardSortTh
|
||
label="Win rate"
|
||
sortKey="winRate"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
align="right"
|
||
/>
|
||
<LeaderboardSortTh
|
||
label="Wins"
|
||
sortKey="wins"
|
||
activeKey={lbSortKey}
|
||
activeDir={lbSortDir}
|
||
onActivate={onLbSort}
|
||
align="right"
|
||
/>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={7}
|
||
className="px-4 py-8 text-center text-zinc-500"
|
||
>
|
||
No wins recorded yet (no rows with a winner).
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
sortedLeaderboard.map((row, i) => (
|
||
<tr
|
||
key={row.userId}
|
||
className="text-zinc-800 dark:text-zinc-200"
|
||
>
|
||
<td className="px-4 py-2 font-mono text-xs text-zinc-500">
|
||
{i + 1}
|
||
</td>
|
||
<td className="px-4 py-2">
|
||
<span className="font-mono text-xs">
|
||
<ClickableUserId id={row.userId} />
|
||
{row.username != null && row.username !== "" ? (
|
||
<span className="text-zinc-700 dark:text-zinc-300">
|
||
{" "}
|
||
({row.username})
|
||
</span>
|
||
) : (
|
||
<span className="text-zinc-500"> (—)</span>
|
||
)}
|
||
</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>
|
||
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||
{row.mmr == null
|
||
? "—"
|
||
: row.mmr.toLocaleString("en-US")}
|
||
</td>
|
||
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||
{row.winRatePercent == null
|
||
? "—"
|
||
: `${row.winRatePercent.toLocaleString("en-US", {
|
||
maximumFractionDigits: 1,
|
||
minimumFractionDigits: 0,
|
||
})}%`}
|
||
</td>
|
||
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||
{row.matchesWon.toLocaleString("en-US")}
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : tab === "players" ? (
|
||
<section>
|
||
{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, MMR, 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>
|
||
<label
|
||
htmlFor="admin-show-escrow-accounts"
|
||
className="inline-flex cursor-pointer items-center gap-2 text-sm whitespace-nowrap text-zinc-700 dark:text-zinc-200"
|
||
>
|
||
<input
|
||
id="admin-show-escrow-accounts"
|
||
type="checkbox"
|
||
checked={showEscrowAccounts}
|
||
onChange={(e) => setShowEscrowAccounts(e.target.checked)}
|
||
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
|
||
/>
|
||
Show escrow accounts
|
||
</label>
|
||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||
Showing {filteredUsers.length} of {playersPool.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">MMR</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">
|
||
{filteredUsers.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={10}
|
||
className="px-4 py-8 text-center text-zinc-500"
|
||
>
|
||
{playersPool.length === 0
|
||
? users.length === 0
|
||
? "No players yet."
|
||
: "No players left after hiding escrow accounts."
|
||
: "No players match this search."}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
filteredUsers.map((u) => {
|
||
const isHi =
|
||
highlightId != null &&
|
||
highlightId === String(u.id);
|
||
return (
|
||
<tr
|
||
key={u.id}
|
||
id={`player-row-${u.id}`}
|
||
className={
|
||
isHi
|
||
? "bg-amber-100/90 ring-2 ring-inset ring-amber-400/80 dark:bg-amber-950/50 dark:ring-amber-500/60"
|
||
: "text-zinc-800 dark:text-zinc-200"
|
||
}
|
||
>
|
||
<td className="px-4 py-2 font-mono text-xs">
|
||
<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">{u.mmr ?? "—"}</td>
|
||
<td className="px-4 py-2 whitespace-nowrap">
|
||
{formatTs(u.created_at)}
|
||
</td>
|
||
<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">
|
||
{canWritePlayers ? (
|
||
<Link
|
||
href={editHref(u)}
|
||
scroll={false}
|
||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||
>
|
||
Edit
|
||
</Link>
|
||
) : null}
|
||
<Link
|
||
href={`/?tab=matches&participant=${u.id}`}
|
||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||
scroll={false}
|
||
>
|
||
Show matches
|
||
</Link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
) : tab === "matchmaker" ? (
|
||
<section className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||
<p>
|
||
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||
history.log
|
||
</span>{" "}
|
||
is the processed summary;{" "}
|
||
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||
matchmaker.log
|
||
</span>{" "}
|
||
is raw output. Toggle below or{" "}
|
||
<Link
|
||
href={`/matchmaker-logs${matchmakerSource === "raw" ? "?mklog=raw" : ""}`}
|
||
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
open fullscreen
|
||
</Link>
|
||
.
|
||
</p>
|
||
</div>
|
||
<MatchmakerLogTerminal
|
||
layout="embedded"
|
||
source={matchmakerSource}
|
||
processedHref={buildDashboardHref({
|
||
tab: "matchmaker",
|
||
highlightId,
|
||
participantRaw,
|
||
})}
|
||
rawHref={buildDashboardHref({
|
||
tab: "matchmaker",
|
||
highlightId,
|
||
participantRaw,
|
||
matchmakerSource: "raw",
|
||
})}
|
||
content={matchmakerContent}
|
||
errorMessage={matchmakerError}
|
||
/>
|
||
</section>
|
||
) : tab === "analysis" ? (
|
||
<AdminMatchAnalysis
|
||
analysis={matchAnalysis}
|
||
pingAnalytics={pingAnalytics}
|
||
geolocationAnalytics={geolocationAnalytics}
|
||
users={users}
|
||
analysisFrom={analysisFrom}
|
||
analysisTo={analysisTo}
|
||
selectedPlayerIds={analysisPlayerIds}
|
||
highlightId={highlightId}
|
||
participantRaw={participantRaw}
|
||
/>
|
||
) : tab === "ledger" ? (
|
||
<AdminLedger
|
||
ledgerGlobalSummary={ledgerGlobalSummary}
|
||
transactionsScoped={ledgerTransactionsFiltered}
|
||
ledgerTableRows={ledgerTableRows}
|
||
ledgerTotalRowsInRange={ledgerTotalRowsInRange}
|
||
ledgerPage={ledgerPage}
|
||
ledgerPageSize={ledgerPageSize}
|
||
ledgerTotalPages={ledgerTotalPages}
|
||
ledgerSort={ledgerSort}
|
||
ledgerOrder={ledgerOrder}
|
||
users={users}
|
||
error={transactionsError}
|
||
highlightId={highlightId}
|
||
participantRaw={participantRaw}
|
||
ledgerFrom={ledgerFrom}
|
||
ledgerTo={ledgerTo}
|
||
supplyError={supplyError}
|
||
readOnly={!canWriteLedger}
|
||
/>
|
||
) : tab === "logs" ? (
|
||
<AdminSystemLogs entries={auditEntries} error={auditError} />
|
||
) : (
|
||
<section className="space-y-3">
|
||
<form
|
||
method="get"
|
||
action="/"
|
||
className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||
onSubmit={(e) => {
|
||
const form = e.currentTarget;
|
||
const mtzInput = form.elements.namedItem(
|
||
"mtz",
|
||
) as HTMLInputElement | null;
|
||
if (mtzInput) {
|
||
mtzInput.value = String(new Date().getTimezoneOffset());
|
||
}
|
||
}}
|
||
>
|
||
<input type="hidden" name="tab" value="matches" />
|
||
{highlightId ? (
|
||
<input type="hidden" name="highlight" value={highlightId} />
|
||
) : null}
|
||
{participantRaw ? (
|
||
<input type="hidden" name="participant" value={participantRaw} />
|
||
) : null}
|
||
<input
|
||
type="hidden"
|
||
name="mtz"
|
||
defaultValue={
|
||
matchesTzOffsetMinutes != null
|
||
? String(matchesTzOffsetMinutes)
|
||
: ""
|
||
}
|
||
/>
|
||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||
<label
|
||
htmlFor="matches-mfrom"
|
||
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
|
||
>
|
||
From (local)
|
||
</label>
|
||
<input
|
||
id="matches-mfrom"
|
||
name="mfrom"
|
||
type="date"
|
||
defaultValue={matchesFrom}
|
||
className="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 className="flex min-w-[10rem] flex-col gap-1">
|
||
<label
|
||
htmlFor="matches-mto"
|
||
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
|
||
>
|
||
To (local)
|
||
</label>
|
||
<input
|
||
id="matches-mto"
|
||
name="mto"
|
||
type="date"
|
||
defaultValue={matchesTo}
|
||
className="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"
|
||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||
>
|
||
Apply
|
||
</button>
|
||
<Link
|
||
href={buildDashboardHref({
|
||
tab: "matches",
|
||
highlightId,
|
||
participantRaw,
|
||
matchesTzOffsetMinutes,
|
||
})}
|
||
scroll={false}
|
||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||
>
|
||
Reset range
|
||
</Link>
|
||
</form>
|
||
|
||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||
Showing {matches.length.toLocaleString("en-US")}{" "}
|
||
{matches.length === 1 ? "match" : "matches"} created{" "}
|
||
<span className="font-mono">
|
||
{matchesFrom}–{matchesTo}
|
||
</span>{" "}
|
||
in your local timezone
|
||
{matches.length >= 10000 ? " · capped at 10,000 rows" : null}
|
||
</p>
|
||
|
||
{participantId != null ? (
|
||
<div
|
||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
|
||
role="status"
|
||
>
|
||
<span>
|
||
Showing matches where{" "}
|
||
<span className="font-mono font-medium">
|
||
{participantId}
|
||
</span>
|
||
{usernameById.get(participantId) != null &&
|
||
usernameById.get(participantId) !== "" ? (
|
||
<span>
|
||
{" "}
|
||
({usernameById.get(participantId)})
|
||
</span>
|
||
) : null}{" "}
|
||
is the Red or Blue user (
|
||
{filteredMatches.length} of {matches.length})
|
||
</span>
|
||
<Link
|
||
href={buildDashboardHref({
|
||
tab: "matches",
|
||
highlightId: null,
|
||
participantRaw: null,
|
||
matchesFrom,
|
||
matchesTo,
|
||
matchesTzOffsetMinutes,
|
||
})}
|
||
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
|
||
scroll={false}
|
||
>
|
||
Clear filter
|
||
</Link>
|
||
</div>
|
||
) : null}
|
||
|
||
{matchesError ? (
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
{matchesError}
|
||
</p>
|
||
) : (
|
||
<div className="grid gap-3">
|
||
<div className="relative z-10 mb-1 flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
<label
|
||
htmlFor="admin-hide-matches-no-winner"
|
||
className="inline-flex cursor-pointer items-center gap-2 text-zinc-700 dark:text-zinc-200"
|
||
>
|
||
<input
|
||
id="admin-hide-matches-no-winner"
|
||
type="checkbox"
|
||
checked={hideNoWinner}
|
||
onChange={(e) => setHideNoWinner(e.target.checked)}
|
||
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
|
||
/>
|
||
Hide matches with no winner
|
||
</label>
|
||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||
Showing {visibleMatches.length} of {filteredMatches.length}
|
||
</span>
|
||
</div>
|
||
{visibleMatches.length === 0 ? (
|
||
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||
{filteredMatches.length === 0
|
||
? "No matches in this date range."
|
||
: hideNoWinner
|
||
? "No matches left after hiding no-winner matches."
|
||
: "No matches for this filter."}
|
||
</div>
|
||
) : (
|
||
visibleMatches.map((m) => {
|
||
const redId = coalesceUserId(m.user_red);
|
||
const blueId = coalesceUserId(m.user_blue);
|
||
const leftUser =
|
||
redId != null ? userById.get(redId) : null;
|
||
const rightUser =
|
||
blueId != null ? userById.get(blueId) : null;
|
||
return (
|
||
<MatchHistoryBattleCard
|
||
key={m.id}
|
||
matchId={m.id}
|
||
statusLabel={statusLabel(m.status)}
|
||
createdAtLabel={<LocalTimestamp value={m.created_at} />}
|
||
entryFeeCoins={m.entryFeePerPlayerCoins}
|
||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||
winnerId={m.winner_id}
|
||
hasReplay={replaySet.has(Number(m.id))}
|
||
left={{
|
||
id: redId,
|
||
username: leftUser?.username ?? null,
|
||
cc: leftUser?.cc ?? null,
|
||
rc: leftUser?.rc ?? null,
|
||
color: "red",
|
||
}}
|
||
right={{
|
||
id: blueId,
|
||
username: rightUser?.username ?? null,
|
||
cc: rightUser?.cc ?? null,
|
||
rc: rightUser?.rc ?? null,
|
||
color: "blue",
|
||
}}
|
||
/>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|