Add self-hosted usage analytics: pageview tracking + admin stats dashboard

Records pageviews (path, referrer, browser/OS/device, IP, GeoIP country) via
a POST /api/track beacon into a new PageView table, and exposes aggregated
daily/breakdown stats behind a token-gated GET /api/admin/stats endpoint with
brute-force lockout. Frontend gets a /admin dashboard (charts + breakdown
tables) built on recharts, with a switchable per-day pageviews chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-01 19:43:10 +02:00
co-authored by Claude Sonnet 5
parent c59dca754f
commit 0845562a21
17 changed files with 1386 additions and 18 deletions
+34 -6
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
import { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket';
import type { MyPlayer } from './types';
@@ -8,10 +8,13 @@ import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable';
import Auth from './pages/Auth';
import History from './pages/History';
import AdminLayout from './pages/admin/AdminLayout';
import AdminStats from './pages/admin/AdminStats';
function AppInner() {
const navigate = useNavigate();
const location = useLocation();
const navigationType = useNavigationType();
const account = useGameStore((s) => s.account);
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
@@ -40,11 +43,15 @@ function AppInner() {
location.pathname === '/auth' ||
location.pathname.startsWith('/game') ||
location.pathname.startsWith('/lobby');
const targetRoute = !account
? '/auth'
: myPlayer
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
: onGameRoute ? '/' : null;
// /admin is a separate concern gated by its own token, not the player login.
const onAdminRoute = location.pathname.startsWith('/admin');
const targetRoute = onAdminRoute
? null
: !account
? '/auth'
: myPlayer
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
: onGameRoute ? '/' : null;
useEffect(() => {
if (!targetRoute) return;
@@ -58,6 +65,23 @@ function AppInner() {
return () => clearTimeout(t);
}, [error, clearError]);
// Fire-and-forget pageview beacon for self-hosted analytics; must never
// affect the app (network errors are swallowed). Skip REPLACE navigations --
// those are app-internal gate/index redirects (login gate, /admin index ->
// stats), not a page the user actually navigated to, so they'd otherwise
// inflate the count with one extra row per redirect hop. Skip /admin itself
// too -- that's the dashboard viewing its own traffic, not player usage.
useEffect(() => {
if (navigationType === 'REPLACE') return;
if (location.pathname.startsWith('/admin')) return;
fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer: document.referrer }),
keepalive: true,
}).catch(() => {});
}, [location.pathname, navigationType]);
return (
<>
{error && (
@@ -71,6 +95,10 @@ function AppInner() {
<Route path="/history" element={<History />} />
<Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="stats" replace />} />
<Route path="stats" element={<AdminStats />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>