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:
@@ -0,0 +1,79 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
const TOKEN_KEY = 'bridzik_admin_token';
|
||||
|
||||
const AdminTokenContext = createContext<string>('');
|
||||
|
||||
/** Admin token, read from the input/sessionStorage owned by AdminLayout.
|
||||
* Kept separate from the player login -- /admin is gated by ADMIN_TOKEN only. */
|
||||
export function useAdminToken(): string {
|
||||
return useContext(AdminTokenContext);
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="max-w-sm mx-auto p-4 pt-24 min-h-screen">
|
||||
<h1 className="font-serif text-2xl text-gold mb-4">Admin</h1>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
sessionStorage.setItem(TOKEN_KEY, draft.trim());
|
||||
setToken(draft.trim());
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Admin token"
|
||||
className="bg-header border border-[#142018] rounded-lg px-3 py-2 text-green-score placeholder:text-green-dim outline-none focus:border-gold"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors"
|
||||
>
|
||||
Vstup
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminTokenContext.Provider value={token}>
|
||||
<div className="max-w-4xl mx-auto p-4 pt-8 min-h-screen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="font-serif text-2xl text-gold">Admin</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
}}
|
||||
className="text-sm text-green-dim hover:text-gold"
|
||||
>
|
||||
Odhlásiť
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-4 mb-6 border-b border-gold/[.14] pb-2">
|
||||
<NavLink
|
||||
to="stats"
|
||||
className={({ isActive }) =>
|
||||
`text-sm ${isActive ? 'text-gold' : 'text-green-dim hover:text-gold'}`
|
||||
}
|
||||
>
|
||||
Štatistiky
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
</AdminTokenContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useIsDesktop } from '../../lib/useIsDesktop';
|
||||
import { useAdminToken } from './AdminLayout';
|
||||
import PageviewsChart from './PageviewsChart';
|
||||
|
||||
interface DailyStats {
|
||||
games_per_day: Record<string, number>;
|
||||
players_per_day: Record<string, number>;
|
||||
completion_rate: number | null;
|
||||
avg_game_duration_minutes: number | null;
|
||||
total_players: number;
|
||||
peak_hours: Record<string, number>;
|
||||
rounds_per_day: Record<string, number>;
|
||||
pageviews_per_day: Record<string, number>;
|
||||
pageviews_per_day_by_device: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_os: Record<string, Record<string, number>>;
|
||||
top_referrers: Record<string, number>;
|
||||
top_paths: Record<string, number>;
|
||||
browsers: Record<string, number>;
|
||||
operating_systems: Record<string, number>;
|
||||
device_types: Record<string, number>;
|
||||
countries: Record<string, number>;
|
||||
}
|
||||
|
||||
function toSeries(byDay: Record<string, number>) {
|
||||
return Object.entries(byDay)
|
||||
.map(([day, n]) => ({ day, n }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl px-4 py-3 flex-1 min-w-[140px]">
|
||||
<p className="text-xs text-green-dim uppercase tracking-wide">{label}</p>
|
||||
<p className="font-serif text-xl text-gold mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeSeriesChart({ title, data, kind = 'line' }: { title: string; data: Record<string, number>; kind?: 'line' | 'bar' }) {
|
||||
const series = toSeries(data);
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<p className="text-sm text-gold mb-2">{title}</p>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
{kind === 'line' ? (
|
||||
<LineChart data={series}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
<Line type="monotone" dataKey="n" stroke="#c9a84c" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={series}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
<Bar dataKey="n" fill="#c9a84c" />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownTable({ title, data }: { title: string; data: Record<string, number> }) {
|
||||
const rows = Object.entries(data).sort((a, b) => b[1] - a[1]);
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<p className="text-sm text-gold mb-2">{title}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-green-dim">Žiadne dáta.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(([key, n]) => (
|
||||
<tr key={key} className="border-b border-gold/[.06] last:border-0">
|
||||
<td className="py-1 text-green-score truncate">{key || '—'}</td>
|
||||
<td className="py-1 text-right text-gold-dim">{n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminStats() {
|
||||
const token = useAdminToken();
|
||||
const desktop = useIsDesktop();
|
||||
const [data, setData] = useState<DailyStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/admin/stats', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => {
|
||||
if (res.status === 403) throw new Error('Neplatný token.');
|
||||
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (!cancelled) setData(json);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (error) return <p className="text-red-400 text-sm">{error}</p>;
|
||||
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
||||
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
||||
<SummaryCard
|
||||
label="Dokončené hry"
|
||||
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Priem. dĺžka hry"
|
||||
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<TimeSeriesChart title="Hry za deň" data={data.games_per_day} />
|
||||
<TimeSeriesChart title="Nové registrácie za deň" data={data.players_per_day} />
|
||||
<TimeSeriesChart title="Odohrané kolá za deň" data={data.rounds_per_day} />
|
||||
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
|
||||
</div>
|
||||
|
||||
<PageviewsChart
|
||||
total={data.pageviews_per_day}
|
||||
byDevice={data.pageviews_per_day_by_device}
|
||||
byBrowser={data.pageviews_per_day_by_browser}
|
||||
byOs={data.pageviews_per_day_by_os}
|
||||
/>
|
||||
|
||||
<div className={`grid gap-4 ${desktop ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<BreakdownTable title="Najnavštevovanejšie stránky" data={data.top_paths} />
|
||||
<BreakdownTable title="Zdroje návštevnosti" data={data.top_referrers} />
|
||||
<BreakdownTable title="Prehliadače" data={data.browsers} />
|
||||
<BreakdownTable title="Operačné systémy" data={data.operating_systems} />
|
||||
<BreakdownTable title="Typ zariadenia" data={data.device_types} />
|
||||
<BreakdownTable title="Krajiny" data={data.countries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type Dimension = 'total' | 'device' | 'browser' | 'os';
|
||||
|
||||
const DIMENSION_LABELS: Record<Dimension, string> = {
|
||||
total: 'Spolu',
|
||||
device: 'Zariadenie',
|
||||
browser: 'Prehliadač',
|
||||
os: 'OS',
|
||||
};
|
||||
|
||||
// Cycled per category line -- theme golds/creams, enough spread to stay
|
||||
// distinguishable across the handful of browsers/OSes/device types we expect.
|
||||
const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064', '#7a6e4a'];
|
||||
|
||||
interface Props {
|
||||
total: Record<string, number>;
|
||||
byDevice: Record<string, Record<string, number>>;
|
||||
byBrowser: Record<string, Record<string, number>>;
|
||||
byOs: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) {
|
||||
const [dimension, setDimension] = useState<Dimension>('total');
|
||||
|
||||
const { rows, categories } = useMemo(() => {
|
||||
if (dimension === 'total') {
|
||||
const days = Object.keys(total).sort();
|
||||
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] };
|
||||
}
|
||||
const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
|
||||
const days = Object.keys(byDay).sort();
|
||||
const categories = [...new Set(days.flatMap((d) => Object.keys(byDay[d])))].sort();
|
||||
const rows = days.map((day) => {
|
||||
const row: Record<string, number | string> = { day };
|
||||
for (const c of categories) row[c] = byDay[day][c] ?? 0;
|
||||
return row;
|
||||
});
|
||||
return { rows, categories };
|
||||
}, [dimension, total, byDevice, byBrowser, byOs]);
|
||||
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<p className="text-sm text-gold">Návštevy za deň</p>
|
||||
<div className="flex gap-1">
|
||||
{(Object.keys(DIMENSION_LABELS) as Dimension[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDimension(d)}
|
||||
className={`px-2 py-1 rounded text-xs transition-colors ${
|
||||
dimension === d ? 'bg-gold text-table' : 'text-green-dim hover:text-gold'
|
||||
}`}
|
||||
>
|
||||
{DIMENSION_LABELS[d]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={rows}>
|
||||
<CartesianGrid stroke="#142018" />
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
|
||||
{categories.map((c, i) => (
|
||||
<Line
|
||||
key={c}
|
||||
type="monotone"
|
||||
dataKey={c}
|
||||
name={dimension === 'total' ? 'Návštevy' : c}
|
||||
stroke={PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user