account system and syslogs
This commit is contained in:
@@ -4,7 +4,10 @@ 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";
|
||||
@@ -16,6 +19,9 @@ 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,
|
||||
@@ -221,13 +227,31 @@ type Props = {
|
||||
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;
|
||||
readOnly: boolean;
|
||||
auditEntries: AuditLogEntry[];
|
||||
auditError: string | null;
|
||||
pageAccess: {
|
||||
players: boolean;
|
||||
matches: boolean;
|
||||
analysis: boolean;
|
||||
matchmaker: boolean;
|
||||
ledger: boolean;
|
||||
logs: boolean;
|
||||
};
|
||||
canWritePlayers: boolean;
|
||||
canWriteLedger: boolean;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
@@ -278,19 +302,55 @@ export function AdminDashboard({
|
||||
matchmakerContent,
|
||||
matchmakerError,
|
||||
supplyError,
|
||||
matchesFrom,
|
||||
matchesTo,
|
||||
matchesTzOffsetMinutes,
|
||||
matchesRangeExplicit,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayerIds,
|
||||
matchAnalysis,
|
||||
pingAnalytics,
|
||||
geolocationAnalytics,
|
||||
readOnly,
|
||||
auditEntries,
|
||||
auditError,
|
||||
pageAccess,
|
||||
canWritePlayers,
|
||||
canWriteLedger,
|
||||
isAdmin,
|
||||
}: Props) {
|
||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||
const [playersSearch, setPlayersSearch] = useState("");
|
||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||
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 ?? [];
|
||||
@@ -407,84 +467,112 @@ export function AdminDashboard({
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "players",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "players")}
|
||||
scroll={false}
|
||||
>
|
||||
Players (
|
||||
{totalUsersLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${users.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matches",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matches")}
|
||||
scroll={false}
|
||||
>
|
||||
Matches (
|
||||
{totalMatchesLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${matches.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "analysis",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayers:
|
||||
analysisPlayerIds.length > 0
|
||||
? analysisPlayerIds.join(",")
|
||||
: null,
|
||||
})}
|
||||
className={tabClass(tab === "analysis")}
|
||||
scroll={false}
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matchmaker",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matchmaker")}
|
||||
scroll={false}
|
||||
>
|
||||
Matchmaker
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "ledger",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "ledger")}
|
||||
scroll={false}
|
||||
>
|
||||
Ledger
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
{pageAccess.players ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "players",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "players")}
|
||||
scroll={false}
|
||||
>
|
||||
Players (
|
||||
{totalUsersLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${users.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matches",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
matchesFrom,
|
||||
matchesTo,
|
||||
matchesTzOffsetMinutes,
|
||||
})}
|
||||
className={tabClass(tab === "matches")}
|
||||
scroll={false}
|
||||
>
|
||||
Matches (
|
||||
{totalMatchesLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${matches.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.analysis ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "analysis",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
analysisFrom,
|
||||
analysisTo,
|
||||
analysisPlayers:
|
||||
analysisPlayerIds.length > 0
|
||||
? analysisPlayerIds.join(",")
|
||||
: null,
|
||||
})}
|
||||
className={tabClass(tab === "analysis")}
|
||||
scroll={false}
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matchmaker ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matchmaker",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matchmaker")}
|
||||
scroll={false}
|
||||
>
|
||||
Matchmaker
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.ledger ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "ledger",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "ledger")}
|
||||
scroll={false}
|
||||
>
|
||||
Ledger
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.logs ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "logs",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "logs")}
|
||||
scroll={false}
|
||||
>
|
||||
System logs
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/settings"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1400px]">
|
||||
@@ -740,7 +828,7 @@ export function AdminDashboard({
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!readOnly ? (
|
||||
{canWritePlayers ? (
|
||||
<Link
|
||||
href={editHref(u)}
|
||||
scroll={false}
|
||||
@@ -839,10 +927,102 @@ export function AdminDashboard({
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
supplyError={supplyError}
|
||||
readOnly={readOnly}
|
||||
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"
|
||||
@@ -864,7 +1044,14 @@ export function AdminDashboard({
|
||||
{filteredMatches.length} of {matches.length})
|
||||
</span>
|
||||
<Link
|
||||
href="/?tab=matches"
|
||||
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}
|
||||
>
|
||||
@@ -900,7 +1087,7 @@ export function AdminDashboard({
|
||||
{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 yet."
|
||||
? "No matches in this date range."
|
||||
: hideNoWinner
|
||||
? "No matches left after hiding no-winner matches."
|
||||
: "No matches for this filter."}
|
||||
@@ -918,7 +1105,7 @@ export function AdminDashboard({
|
||||
key={m.id}
|
||||
matchId={m.id}
|
||||
statusLabel={statusLabel(m.status)}
|
||||
createdAtLabel={formatTs(m.created_at)}
|
||||
createdAtLabel={<LocalTimestamp value={m.created_at} />}
|
||||
entryFeeCoins={m.entryFeePerPlayerCoins}
|
||||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||||
|
||||
Reference in New Issue
Block a user