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,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