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:
+34
-6
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user