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
+38
View File
@@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: let the router handle unknown paths.
location / {
try_files $uri $uri/ /index.html;
}
# Socket.IO (websocket + polling) -> backend.
location /socket.io/ {
proxy_pass http://${BACKEND_UPSTREAM}/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://${BACKEND_UPSTREAM}/health;
}
# Pageview beacon + admin stats -> backend.
location /api/ {
proxy_pass http://${BACKEND_UPSTREAM}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+355 -3
View File
@@ -12,6 +12,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"recharts": "^2.12.7",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
@@ -1577,7 +1578,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2697,6 +2697,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3208,6 +3271,15 @@
"node": ">= 6"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -3291,9 +3363,129 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3365,6 +3557,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3425,6 +3623,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3700,6 +3908,12 @@
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3707,6 +3921,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4211,6 +4434,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4822,6 +5054,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -4989,7 +5227,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5344,6 +5581,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5409,6 +5663,12 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5451,6 +5711,37 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5474,6 +5765,39 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6295,6 +6619,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6622,6 +6952,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2
View File
@@ -2,6 +2,7 @@
"name": "bridzik-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -12,6 +13,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"recharts": "^2.12.7",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
+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>
</>
+79
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+6
View File
@@ -40,6 +40,12 @@ export default defineConfig({
ws: true,
changeOrigin: true,
},
'/api': {
// Backend HTTP endpoints (/api/track, /api/admin/stats) -- kept under
// /api so they never collide with client-side routes like /admin/stats.
target: process.env.VITE_BACKEND_URL ?? 'http://localhost:5000',
changeOrigin: true,
},
},
},
});