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
@@ -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>
);
}