ip geolocation + ping analysis

This commit is contained in:
NextJS
2026-06-02 10:24:58 +00:00
parent dc8377177a
commit 4ed4a10eae
21 changed files with 1141 additions and 168 deletions
+60 -5
View File
@@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
@@ -223,6 +224,7 @@ type Props = {
analysisTo: string;
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
};
function StatCard({
@@ -277,8 +279,10 @@ export function AdminDashboard({
analysisTo,
analysisPlayerIds,
matchAnalysis,
pingAnalytics,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
@@ -330,6 +334,25 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return users;
return users.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [users, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -546,6 +569,7 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<th className="px-4 py-3 font-medium">Email</th>
<LeaderboardSortTh
label="RC balance"
sortKey="rc"
@@ -575,7 +599,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={5}
colSpan={6}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
@@ -603,6 +627,9 @@ export function AdminDashboard({
)}
</span>
</td>
<td className="max-w-48 truncate px-4 py-2 text-zinc-700 dark:text-zinc-300">
{row.email?.trim() ? row.email.trim() : "—"}
</td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)}
</td>
@@ -630,31 +657,51 @@ export function AdminDashboard({
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label className="min-w-[16rem] flex-1">
<span className="sr-only">Search players</span>
<input
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length}
</span>
</div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? (
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={7}
colSpan={9}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
{users.length === 0
? "No players yet."
: "No players match this search."}
</td>
</tr>
) : (
users.map((u) => {
filteredUsers.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
@@ -672,6 +719,9 @@ export function AdminDashboard({
<ClickableUserId id={u.id} />
</td>
<td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="max-w-48 truncate px-4 py-2">
{u.email?.trim() ? u.email.trim() : "—"}
</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap">
@@ -680,6 +730,9 @@ export function AdminDashboard({
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)}
</td>
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap">
{u.ip_address?.trim() ? u.ip_address.trim() : "—"}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
@@ -705,6 +758,7 @@ export function AdminDashboard({
</tbody>
</table>
</div>
</div>
)}
</section>
) : tab === "matchmaker" ? (
@@ -751,6 +805,7 @@ export function AdminDashboard({
) : tab === "analysis" ? (
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}