Implement comprehensive analytics redesign with bot detection and charts
- Add bot detection logic to differentiate between real traffic and bot traffic - Detect common bots: search crawlers, social media crawlers, headless browsers, monitoring tools, security scanners - Expand hit-stats.json to track realHits, botHits, byPathReal, byPathBot, byDayReal, byDayBot, botReasons - Update analytics API endpoint to return bot/visitor breakdown with 7-day and 30-day comparisons - Add Chart.js integration with react-chartjs-2 for interactive visualizations - Create new AnalyticsPanel component with tabbed interface: Overview, Bot Breakdown, Geographic - Implement graphs showing: 7-day trend (real vs bot), traffic composition (doughnut), bot sources (pie), top pages (bar) - Display clear KPI cards showing real traffic %, bot traffic %, return visitor rate - Add deployment & cache status section to analytics dashboard - Improve data presentation with responsive grid layouts and color-coded metrics
This commit is contained in:
Generated
+30
@@ -8,11 +8,13 @@
|
|||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
@@ -568,6 +570,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||||
@@ -1706,6 +1714,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@@ -4738,6 +4758,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
|||||||
@@ -13,11 +13,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
|||||||
@@ -313,10 +313,17 @@ async function invokeWebhook(url, action) {
|
|||||||
|
|
||||||
const EMPTY_HIT_STATS = {
|
const EMPTY_HIT_STATS = {
|
||||||
totalHits: 0,
|
totalHits: 0,
|
||||||
|
realHits: 0,
|
||||||
|
botHits: 0,
|
||||||
firstHitAt: null,
|
firstHitAt: null,
|
||||||
lastHitAt: null,
|
lastHitAt: null,
|
||||||
byPath: {},
|
byPath: {},
|
||||||
|
byPathReal: {},
|
||||||
|
byPathBot: {},
|
||||||
byDay: {},
|
byDay: {},
|
||||||
|
byDayReal: {},
|
||||||
|
byDayBot: {},
|
||||||
|
botReasons: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
let hitStats = { ...EMPTY_HIT_STATS }
|
let hitStats = { ...EMPTY_HIT_STATS }
|
||||||
@@ -649,6 +656,54 @@ function sanitizeUserAgent(userAgent) {
|
|||||||
return userAgent.trim().slice(0, 300) || 'unknown'
|
return userAgent.trim().slice(0, 300) || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectBot(userAgent, pathInfo = {}) {
|
||||||
|
if (!userAgent || typeof userAgent !== 'string') {
|
||||||
|
return { isBot: true, reason: 'missing-user-agent' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = userAgent.toLowerCase()
|
||||||
|
|
||||||
|
// Search engine crawlers
|
||||||
|
if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'search-crawler' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social media crawlers
|
||||||
|
if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'social-crawler' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headless browsers and automation
|
||||||
|
if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'headless-browser' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitoring and uptime checkers
|
||||||
|
if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'monitoring-tool' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security scanners and tools
|
||||||
|
if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'security-scanner' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP clients and frameworks
|
||||||
|
if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'http-client' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common crawler keywords
|
||||||
|
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
|
||||||
|
// But allow some common real user agents that might contain these words
|
||||||
|
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
|
||||||
|
return { isBot: true, reason: 'bot-keyword' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isBot: false, reason: null }
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveGeo(ip) {
|
async function resolveGeo(ip) {
|
||||||
if (!ip || isPrivateOrLocalIp(ip)) {
|
if (!ip || isPrivateOrLocalIp(ip)) {
|
||||||
return {
|
return {
|
||||||
@@ -842,10 +897,14 @@ function pruneStatsByDays(daysRaw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextByDay = {}
|
const nextByDay = {}
|
||||||
|
const nextByDayReal = {}
|
||||||
|
const nextByDayBot = {}
|
||||||
for (const [day, count] of Object.entries(hitStats.byDay)) {
|
for (const [day, count] of Object.entries(hitStats.byDay)) {
|
||||||
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
|
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
|
||||||
if (Number.isFinite(ts) && ts >= cutoff) {
|
if (Number.isFinite(ts) && ts >= cutoff) {
|
||||||
nextByDay[day] = count
|
nextByDay[day] = count
|
||||||
|
nextByDayReal[day] = hitStats.byDayReal?.[day] ?? 0
|
||||||
|
nextByDayBot[day] = hitStats.byDayBot?.[day] ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,6 +917,8 @@ function pruneStatsByDays(daysRaw) {
|
|||||||
visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
|
visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
|
||||||
|
|
||||||
hitStats.byDay = nextByDay
|
hitStats.byDay = nextByDay
|
||||||
|
hitStats.byDayReal = nextByDayReal
|
||||||
|
hitStats.byDayBot = nextByDayBot
|
||||||
|
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
queueVisitorStatsWrite()
|
queueVisitorStatsWrite()
|
||||||
@@ -1094,7 +1155,7 @@ function queueHitStatsWrite() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordHit(pathname) {
|
function recordHit(pathname, isBot = false, botReason = null) {
|
||||||
const nowIso = new Date().toISOString()
|
const nowIso = new Date().toISOString()
|
||||||
const dayKey = nowIso.slice(0, 10)
|
const dayKey = nowIso.slice(0, 10)
|
||||||
const safePath = normalizeHitPath(pathname)
|
const safePath = normalizeHitPath(pathname)
|
||||||
@@ -1102,6 +1163,21 @@ function recordHit(pathname) {
|
|||||||
hitStats.totalHits += 1
|
hitStats.totalHits += 1
|
||||||
hitStats.lastHitAt = nowIso
|
hitStats.lastHitAt = nowIso
|
||||||
hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso
|
hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso
|
||||||
|
|
||||||
|
if (isBot) {
|
||||||
|
hitStats.botHits += 1
|
||||||
|
hitStats.byPathBot[safePath] = (hitStats.byPathBot[safePath] ?? 0) + 1
|
||||||
|
hitStats.byDayBot[dayKey] = (hitStats.byDayBot[dayKey] ?? 0) + 1
|
||||||
|
if (botReason) {
|
||||||
|
hitStats.botReasons[botReason] = (hitStats.botReasons[botReason] ?? 0) + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hitStats.realHits += 1
|
||||||
|
hitStats.byPathReal[safePath] = (hitStats.byPathReal[safePath] ?? 0) + 1
|
||||||
|
hitStats.byDayReal[dayKey] = (hitStats.byDayReal[dayKey] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep legacy byPath and byDay for backward compatibility
|
||||||
hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1
|
hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1
|
||||||
hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1
|
hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1
|
||||||
|
|
||||||
@@ -1128,10 +1204,17 @@ function loadHitStatsFromDisk() {
|
|||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
hitStats = {
|
hitStats = {
|
||||||
totalHits: Number(parsed?.totalHits) || 0,
|
totalHits: Number(parsed?.totalHits) || 0,
|
||||||
|
realHits: Number(parsed?.realHits) || 0,
|
||||||
|
botHits: Number(parsed?.botHits) || 0,
|
||||||
firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
|
firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
|
||||||
lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
|
lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
|
||||||
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
||||||
|
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
|
||||||
|
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
|
||||||
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
||||||
|
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
|
||||||
|
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
|
||||||
|
botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -1615,15 +1698,54 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||||
|
|
||||||
|
const topPathsReal = Object.entries(hitStats.byPathReal)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||||
|
|
||||||
|
const topPathsBot = Object.entries(hitStats.byPathBot)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||||
|
|
||||||
|
const last7Days = buildLastNDaysStats(7)
|
||||||
|
const last7DaysReal = last7Days.map(item => ({
|
||||||
|
day: item.day,
|
||||||
|
hits: hitStats.byDayReal?.[item.day] ?? 0
|
||||||
|
}))
|
||||||
|
const last7DaysBot = last7Days.map(item => ({
|
||||||
|
day: item.day,
|
||||||
|
hits: hitStats.byDayBot?.[item.day] ?? 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
const last30Days = buildLastNDaysStats(30)
|
||||||
|
const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
|
||||||
|
const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayReal?.[item.day] ?? 0), 0)
|
||||||
|
const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (hitStats.byDayBot?.[item.day] ?? 0), 0)
|
||||||
|
|
||||||
|
const botReasons = Object.entries(hitStats.botReasons ?? {})
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([reason, count]) => ({ reason, count }))
|
||||||
|
|
||||||
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalHits: hitStats.totalHits,
|
totalHits: hitStats.totalHits,
|
||||||
|
realHits: hitStats.realHits ?? 0,
|
||||||
|
botHits: hitStats.botHits ?? 0,
|
||||||
firstHitAt: hitStats.firstHitAt,
|
firstHitAt: hitStats.firstHitAt,
|
||||||
lastHitAt: hitStats.lastHitAt,
|
lastHitAt: hitStats.lastHitAt,
|
||||||
topPaths,
|
topPaths,
|
||||||
last7Days: buildLastNDaysStats(7),
|
topPathsReal,
|
||||||
last30DaysTotal: buildLastNDaysStats(30).reduce((sum, item) => sum + item.hits, 0),
|
topPathsBot,
|
||||||
|
last7Days,
|
||||||
|
last7DaysReal,
|
||||||
|
last7DaysBot,
|
||||||
|
last30DaysTotal,
|
||||||
|
last30DaysRealTotal,
|
||||||
|
last30DaysBotTotal,
|
||||||
|
botReasons,
|
||||||
visitors: {
|
visitors: {
|
||||||
totalVisits: visitorStats.totalVisits,
|
totalVisits: visitorStats.totalVisits,
|
||||||
uniqueVisitors: visitorStats.uniqueVisitors,
|
uniqueVisitors: visitorStats.uniqueVisitors,
|
||||||
@@ -1878,8 +2000,10 @@ app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
|
|||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (shouldCountHit(req)) {
|
if (shouldCountHit(req)) {
|
||||||
recordHit(req.path)
|
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||||
if (hasVisitorConsent(req)) {
|
const botDetection = detectBot(ua)
|
||||||
|
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||||||
|
if (hasVisitorConsent(req) && !botDetection.isBot) {
|
||||||
recordVisitor(req, res).catch(err => {
|
recordVisitor(req, res).catch(err => {
|
||||||
console.error('[visitor-stats] failed to record visitor:', err)
|
console.error('[visitor-stats] failed to record visitor:', err)
|
||||||
})
|
})
|
||||||
|
|||||||
+55
-127
@@ -1,8 +1,33 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ArcElement,
|
||||||
|
} from 'chart.js'
|
||||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||||
import { DEFAULTS } from './content'
|
import { DEFAULTS } from './content'
|
||||||
|
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ArcElement,
|
||||||
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: SiteContent
|
content: SiteContent
|
||||||
@@ -20,13 +45,22 @@ interface BackupPreview {
|
|||||||
totalVisits: number
|
totalVisits: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminStats {
|
export interface AdminStats {
|
||||||
totalHits: number
|
totalHits: number
|
||||||
|
realHits: number
|
||||||
|
botHits: number
|
||||||
firstHitAt: string | null
|
firstHitAt: string | null
|
||||||
lastHitAt: string | null
|
lastHitAt: string | null
|
||||||
topPaths: Array<{ path: string; hits: number }>
|
topPaths: Array<{ path: string; hits: number }>
|
||||||
|
topPathsReal: Array<{ path: string; hits: number }>
|
||||||
|
topPathsBot: Array<{ path: string; hits: number }>
|
||||||
last7Days: Array<{ day: string; hits: number }>
|
last7Days: Array<{ day: string; hits: number }>
|
||||||
|
last7DaysReal: Array<{ day: string; hits: number }>
|
||||||
|
last7DaysBot: Array<{ day: string; hits: number }>
|
||||||
last30DaysTotal: number
|
last30DaysTotal: number
|
||||||
|
last30DaysRealTotal: number
|
||||||
|
last30DaysBotTotal: number
|
||||||
|
botReasons: Array<{ reason: string; count: number }>
|
||||||
visitors: {
|
visitors: {
|
||||||
totalVisits: number
|
totalVisits: number
|
||||||
uniqueVisitors: number
|
uniqueVisitors: number
|
||||||
@@ -2518,132 +2552,26 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
|
|
||||||
{/* ANALYTICS */}
|
{/* ANALYTICS */}
|
||||||
{adminView === 'analytics' && (
|
{adminView === 'analytics' && (
|
||||||
<section className="admin-panel-section" aria-label="Analytics">
|
<AnalyticsPanel
|
||||||
<div className="admin-panel-head">
|
stats={stats}
|
||||||
<h2>Analytics</h2>
|
statsStatus={statsStatus}
|
||||||
<p>Page hits, visitor geography, and data management.</p>
|
opsStatus={opsStatus}
|
||||||
</div>
|
formatDate={formatDate}
|
||||||
{statsStatus === 'loading' && <p className="admin-stats-note">Loading analytics…</p>}
|
maskIp={maskIp}
|
||||||
{statsStatus === 'error' && <p className="admin-stats-note">Failed to load analytics.</p>}
|
maintenanceMsg={maintenanceMsg}
|
||||||
{statsStatus === 'ready' && stats && (
|
selectedBackup={selectedBackup}
|
||||||
<>
|
backupFiles={backupFiles}
|
||||||
<div className="admin-stats-grid">
|
selectedBackupPreview={selectedBackupPreview}
|
||||||
<article><h3>Total Hits</h3><p>{stats.totalHits.toLocaleString()}</p></article>
|
onBackupSelect={setSelectedBackup}
|
||||||
<article><h3>Last 30 Days</h3><p>{stats.last30DaysTotal.toLocaleString()}</p></article>
|
onRestore={handleRestoreBackup}
|
||||||
<article><h3>First Hit</h3><p>{formatDate(stats.firstHitAt)}</p></article>
|
onExport={handleExport}
|
||||||
<article><h3>Latest Hit</h3><p>{formatDate(stats.lastHitAt)}</p></article>
|
onBackupNow={handleBackupNow}
|
||||||
</div>
|
onPrune={handlePrune}
|
||||||
<div className="admin-stats-lists">
|
onClear={handleClear}
|
||||||
<div>
|
onPurgeCache={handlePurgeCache}
|
||||||
<h3>Top Paths</h3>
|
onDeployHook={handleDeployHook}
|
||||||
{stats.topPaths.length === 0 ? <p className="admin-stats-note">No hits tracked yet.</p> : (
|
onRefreshStatus={() => { void reloadOpsStatus() }}
|
||||||
<ul>{stats.topPaths.map(item => <li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Daily Hits (7 Days)</h3>
|
|
||||||
<ul>{stats.last7Days.map(item => <li key={item.day}><span>{item.day}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
|
||||||
<h2>Visitor Details</h2>
|
|
||||||
<p>IP, geography, and returning visitor behavior.</p>
|
|
||||||
</div>
|
|
||||||
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
|
|
||||||
<div className="admin-stats-grid">
|
|
||||||
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
|
|
||||||
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
|
|
||||||
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
|
||||||
<article><h3>Returning Rate</h3><p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p></article>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-lists">
|
|
||||||
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
||||||
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
||||||
<div><h3>Top Counties</h3><ul>{stats.visitors.topCounties.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
||||||
<div><h3>Top Cities</h3><ul>{stats.visitors.topCities.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-visits-table-wrap">
|
|
||||||
<h3>Recent Visitor Log</h3>
|
|
||||||
{stats.visitors.recentVisits.length === 0 ? <p className="admin-stats-note">No visitor records yet.</p> : (
|
|
||||||
<div className="admin-visits-table-scroll">
|
|
||||||
<table className="admin-visits-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Time</th><th>IP</th><th>Country</th><th>State</th><th>County</th><th>City</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{stats.visitors.recentVisits.map(row => (
|
|
||||||
<tr key={`${row.visitorId}-${row.at}`}>
|
|
||||||
<td>{formatDate(row.at)}</td><td>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.state}</td><td>{row.county}</td><td>{row.city}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
|
||||||
<h2>Contact Summary</h2>
|
|
||||||
<p>Submission totals from the contact form.</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-grid">
|
|
||||||
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
|
||||||
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
|
||||||
<h2>Data Management</h2>
|
|
||||||
<p>Export, backup, or retain only recent analytics data.</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-grid">
|
|
||||||
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
|
|
||||||
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
|
|
||||||
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
|
|
||||||
<article><h3>Latest Backup File</h3><p>{stats.writeStatus.backups.file ?? 'Not available yet'}</p></article>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
|
||||||
<h2>Deployment & Cache Status</h2>
|
|
||||||
<p>Current deployment metadata and cache purge health for the live app.</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stats-grid">
|
|
||||||
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
|
||||||
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
|
|
||||||
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? null)}</p></article>
|
|
||||||
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
|
|
||||||
</div>
|
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handlePrune}>Prune Old Data</button>
|
|
||||||
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
|
|
||||||
</div>
|
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
|
|
||||||
</div>
|
|
||||||
<div className="admin-restore-row">
|
|
||||||
<label htmlFor="restore-backup">Restore Backup</label>
|
|
||||||
<select id="restore-backup" value={selectedBackup} onChange={e => setSelectedBackup(e.target.value)} disabled={backupFiles.length === 0}>
|
|
||||||
{backupFiles.length === 0 && <option value="">No backups found</option>}
|
|
||||||
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
|
|
||||||
</select>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleRestoreBackup} disabled={!selectedBackup}>Restore Selected Backup</button>
|
|
||||||
</div>
|
|
||||||
{selectedBackupPreview && (
|
|
||||||
<div className="admin-restore-preview">
|
|
||||||
<h3>Restore Preview</h3>
|
|
||||||
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
|
|
||||||
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
|
|
||||||
<p><strong>Reason:</strong> {selectedBackupPreview.reason}</p>
|
|
||||||
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
|
|
||||||
<p><strong>Content Updated At:</strong> {formatDate(selectedBackupPreview.adminUpdatedAt)}</p>
|
|
||||||
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
|
|
||||||
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EMAILS */}
|
{/* EMAILS */}
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2'
|
||||||
|
import type { AdminStats } from '../AdminPage'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: AdminStats | null
|
||||||
|
statsStatus: 'loading' | 'error' | 'ready'
|
||||||
|
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
|
||||||
|
formatDate: (date: string | null) => string
|
||||||
|
maskIp: (ip: string) => string
|
||||||
|
maintenanceMsg: string | null
|
||||||
|
selectedBackup: string
|
||||||
|
backupFiles: Array<{ filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number }>
|
||||||
|
selectedBackupPreview: { filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number } | null
|
||||||
|
onBackupSelect: (filename: string) => void
|
||||||
|
onRestore: () => void
|
||||||
|
onExport: () => void
|
||||||
|
onBackupNow: () => void
|
||||||
|
onPrune: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onPurgeCache: () => void
|
||||||
|
onDeployHook: () => void
|
||||||
|
onRefreshStatus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsPanel({
|
||||||
|
stats,
|
||||||
|
statsStatus,
|
||||||
|
opsStatus,
|
||||||
|
formatDate,
|
||||||
|
maskIp,
|
||||||
|
maintenanceMsg,
|
||||||
|
selectedBackup,
|
||||||
|
backupFiles,
|
||||||
|
selectedBackupPreview,
|
||||||
|
onBackupSelect,
|
||||||
|
onRestore,
|
||||||
|
onExport,
|
||||||
|
onBackupNow,
|
||||||
|
onPrune,
|
||||||
|
onClear,
|
||||||
|
onPurgeCache,
|
||||||
|
onDeployHook,
|
||||||
|
onRefreshStatus,
|
||||||
|
}: Props) {
|
||||||
|
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic'>('overview')
|
||||||
|
|
||||||
|
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics…</p>
|
||||||
|
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
|
||||||
|
if (!stats) return null
|
||||||
|
|
||||||
|
// Chart data - Daily trend
|
||||||
|
const dailyChartData = {
|
||||||
|
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Real Visitors',
|
||||||
|
data: stats.last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||||
|
borderColor: '#c9a84c',
|
||||||
|
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bot Traffic',
|
||||||
|
data: stats.last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||||
|
borderColor: '#666',
|
||||||
|
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart data - Bot breakdown (pie)
|
||||||
|
const botPieData = {
|
||||||
|
labels: stats.botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||||
|
datasets: [{
|
||||||
|
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||||
|
backgroundColor: [
|
||||||
|
'#c9a84c',
|
||||||
|
'#a0853d',
|
||||||
|
'#6d5a2e',
|
||||||
|
'#8b7635',
|
||||||
|
'#9e8843',
|
||||||
|
'#bbb477',
|
||||||
|
'#7a6930',
|
||||||
|
'#5a4820',
|
||||||
|
'#c0a060',
|
||||||
|
'#746c36',
|
||||||
|
],
|
||||||
|
borderColor: '#1a1a15',
|
||||||
|
borderWidth: 2,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic ratio (doughnut)
|
||||||
|
const trafficRatioData = {
|
||||||
|
labels: ['Real Visitors', 'Bot Traffic'],
|
||||||
|
datasets: [{
|
||||||
|
data: [stats.realHits, stats.botHits],
|
||||||
|
backgroundColor: ['#c9a84c', '#999'],
|
||||||
|
borderColor: '#1a1a15',
|
||||||
|
borderWidth: 2,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top paths comparison
|
||||||
|
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => {
|
||||||
|
const realPath = stats.topPathsReal[i]?.path || ''
|
||||||
|
const botPath = stats.topPathsBot[i]?.path || ''
|
||||||
|
return realPath || botPath || `Path ${i + 1}`
|
||||||
|
}).slice(0, 8)
|
||||||
|
|
||||||
|
const topPathsChartData = {
|
||||||
|
labels: topPathsLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Real Hits',
|
||||||
|
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0),
|
||||||
|
backgroundColor: '#c9a84c',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bot Hits',
|
||||||
|
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0),
|
||||||
|
backgroundColor: '#999',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: '#f0ead8',
|
||||||
|
font: { size: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: '#2a2518',
|
||||||
|
titleColor: '#f0ead8',
|
||||||
|
bodyColor: '#f0ead8',
|
||||||
|
borderColor: '#c9a84c',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { color: '#7a7060' },
|
||||||
|
grid: { color: '#2a2518' },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#7a7060' },
|
||||||
|
grid: { color: '#2a2518' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Analytics">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Analytics Dashboard</h2>
|
||||||
|
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Grid */}
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Total Hits</h3>
|
||||||
|
<p>{stats.totalHits.toLocaleString()}</p>
|
||||||
|
<small>All page requests</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Real Traffic</h3>
|
||||||
|
<p>{stats.realHits.toLocaleString()}</p>
|
||||||
|
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Bot Traffic</h3>
|
||||||
|
<p>{stats.botHits.toLocaleString()}</p>
|
||||||
|
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Last 30 Days</h3>
|
||||||
|
<p>{stats.last30DaysRealTotal.toLocaleString()}</p>
|
||||||
|
<small>Real hits • {stats.last30DaysBotTotal.toLocaleString()} bots</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Unique Visitors</h3>
|
||||||
|
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
|
||||||
|
<small>Tracked with consent</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Returning Rate</h3>
|
||||||
|
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
|
||||||
|
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Tabs */}
|
||||||
|
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<button type="button" className={`btn-admin-reset${chartTab === 'overview' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
|
||||||
|
<button type="button" className={`btn-admin-reset${chartTab === 'breakdown' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</button>
|
||||||
|
<button type="button" className={`btn-admin-reset${chartTab === 'geographic' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Charts */}
|
||||||
|
{chartTab === 'overview' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>7-Day Trend (Real vs Bot)</h3>
|
||||||
|
<Line data={dailyChartData} options={chartOptions} />
|
||||||
|
</div>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Traffic Composition</h3>
|
||||||
|
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bot Breakdown Charts */}
|
||||||
|
{chartTab === 'breakdown' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
|
||||||
|
{stats.botReasons.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||||
|
) : (
|
||||||
|
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
|
||||||
|
{stats.botReasons.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No bots detected yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{stats.botReasons.map((reason: { reason: string; count: number }) => (
|
||||||
|
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||||
|
<span>{reason.reason.replace(/-/g, ' ')}</span>
|
||||||
|
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Geographic Charts */}
|
||||||
|
{chartTab === 'geographic' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
|
||||||
|
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3>
|
||||||
|
{stats.visitors.topCountries.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No visitor data yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{stats.visitors.topCountries.map((country: { name: string; hits: number }) => (
|
||||||
|
<li key={country.name} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
|
||||||
|
<span>{country.name}</span>
|
||||||
|
<strong style={{ color: '#c9a84c' }}>{country.hits.toLocaleString()}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Visitors Table */}
|
||||||
|
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
|
||||||
|
<h2>Visitor Details</h2>
|
||||||
|
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
|
||||||
|
</div>
|
||||||
|
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
|
||||||
|
|
||||||
|
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
|
||||||
|
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
|
||||||
|
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
||||||
|
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-stats-lists">
|
||||||
|
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||||
|
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map((item: { name: string; hits: number }) => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-visits-table-wrap">
|
||||||
|
<h3>Recent Visitor Log</h3>
|
||||||
|
{stats.visitors.recentVisits.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No visitor records yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-visits-table-scroll">
|
||||||
|
<table className="admin-visits-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>IP</th><th>Country</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.visitors.recentVisits.map(row => (
|
||||||
|
<tr key={`${row.visitorId}-${row.at}`}>
|
||||||
|
<td>{formatDate(row.at)}</td><td>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Summary */}
|
||||||
|
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
|
||||||
|
<h2>Contact Summary</h2>
|
||||||
|
<p>Submission totals from the contact form.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
||||||
|
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployment & Cache Status */}
|
||||||
|
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
|
||||||
|
<h2>Deployment & Cache Status</h2>
|
||||||
|
<p>Current deployment metadata and cache purge health for the live app.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
||||||
|
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
|
||||||
|
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? null)}</p></article>
|
||||||
|
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Management */}
|
||||||
|
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
|
||||||
|
<h2>Data Management</h2>
|
||||||
|
<p>Export, backup, or retain only recent analytics data.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
|
||||||
|
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
|
||||||
|
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onExport}>Export JSON</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onBackupNow}>Backup Now</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onPrune}>Prune Old Data</button>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={onClear}>Clear Analytics</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onPurgeCache}>Purge Cache</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onDeployHook}>Trigger Deploy</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onRefreshStatus}>Refresh Status</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-restore-row">
|
||||||
|
<label htmlFor="restore-backup">Restore Backup</label>
|
||||||
|
<select id="restore-backup" value={selectedBackup} onChange={e => onBackupSelect(e.target.value)} disabled={backupFiles.length === 0}>
|
||||||
|
{backupFiles.length === 0 && <option value="">No backups found</option>}
|
||||||
|
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={onRestore} disabled={!selectedBackup}>Restore Selected Backup</button>
|
||||||
|
</div>
|
||||||
|
{selectedBackupPreview && (
|
||||||
|
<div className="admin-restore-preview">
|
||||||
|
<h3>Restore Preview</h3>
|
||||||
|
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
|
||||||
|
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
|
||||||
|
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
|
||||||
|
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
|
||||||
|
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user