Files
kickkingsadmin/src/components/admin-dashboard.tsx
T
2026-09-14 13:23:14 +00:00

1053 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}