From 3cbb7cc409197fb275156550043bf5863352f232 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 7 May 2026 10:06:32 -0400 Subject: [PATCH] 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 --- package-lock.json | 30 +++ package.json | 2 + server.js | 134 ++++++++++- src/AdminPage.tsx | 182 +++++--------- src/components/AnalyticsPanel.tsx | 383 ++++++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 132 deletions(-) create mode 100644 src/components/AnalyticsPanel.tsx diff --git a/package-lock.json b/package-lock.json index 1c89db5..83cadbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "siteforge", "version": "0.0.0", "dependencies": { + "chart.js": "^4.4.0", "express": "^5.2.1", "express-rate-limit": "^8.4.1", "otplib": "^13.4.0", "qrcode": "^1.5.4", "react": "^19.2.4", + "react-chartjs-2": "^5.2.0", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.1", @@ -568,6 +570,12 @@ "@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": { "version": "1.1.1", "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" } }, + "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": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4738,6 +4758,16 @@ "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": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", diff --git a/package.json b/package.json index 54836e3..18c16cf 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.4.0", "express": "^5.2.1", "express-rate-limit": "^8.4.1", "otplib": "^13.4.0", "qrcode": "^1.5.4", "react": "^19.2.4", + "react-chartjs-2": "^5.2.0", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.1", diff --git a/server.js b/server.js index 40d2dcf..e921541 100644 --- a/server.js +++ b/server.js @@ -313,10 +313,17 @@ async function invokeWebhook(url, action) { const EMPTY_HIT_STATS = { totalHits: 0, + realHits: 0, + botHits: 0, firstHitAt: null, lastHitAt: null, byPath: {}, + byPathReal: {}, + byPathBot: {}, byDay: {}, + byDayReal: {}, + byDayBot: {}, + botReasons: {}, } let hitStats = { ...EMPTY_HIT_STATS } @@ -649,6 +656,54 @@ function sanitizeUserAgent(userAgent) { 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) { if (!ip || isPrivateOrLocalIp(ip)) { return { @@ -842,10 +897,14 @@ function pruneStatsByDays(daysRaw) { } const nextByDay = {} + const nextByDayReal = {} + const nextByDayBot = {} for (const [day, count] of Object.entries(hitStats.byDay)) { const ts = new Date(`${day}T00:00:00.000Z`).getTime() if (Number.isFinite(ts) && ts >= cutoff) { 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 hitStats.byDay = nextByDay + hitStats.byDayReal = nextByDayReal + hitStats.byDayBot = nextByDayBot queueHitStatsWrite() queueVisitorStatsWrite() @@ -1094,7 +1155,7 @@ function queueHitStatsWrite() { }) } -function recordHit(pathname) { +function recordHit(pathname, isBot = false, botReason = null) { const nowIso = new Date().toISOString() const dayKey = nowIso.slice(0, 10) const safePath = normalizeHitPath(pathname) @@ -1102,6 +1163,21 @@ function recordHit(pathname) { hitStats.totalHits += 1 hitStats.lastHitAt = 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.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1 @@ -1128,10 +1204,17 @@ function loadHitStatsFromDisk() { const parsed = JSON.parse(raw) hitStats = { totalHits: Number(parsed?.totalHits) || 0, + realHits: Number(parsed?.realHits) || 0, + botHits: Number(parsed?.botHits) || 0, firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null, lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null, 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 : {}, + 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(() => { @@ -1615,15 +1698,54 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { .slice(0, 10) .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) res.json({ totalHits: hitStats.totalHits, + realHits: hitStats.realHits ?? 0, + botHits: hitStats.botHits ?? 0, firstHitAt: hitStats.firstHitAt, lastHitAt: hitStats.lastHitAt, topPaths, - last7Days: buildLastNDaysStats(7), - last30DaysTotal: buildLastNDaysStats(30).reduce((sum, item) => sum + item.hits, 0), + topPathsReal, + topPathsBot, + last7Days, + last7DaysReal, + last7DaysBot, + last30DaysTotal, + last30DaysRealTotal, + last30DaysBotTotal, + botReasons, visitors: { totalVisits: visitorStats.totalVisits, uniqueVisitors: visitorStats.uniqueVisitors, @@ -1878,8 +2000,10 @@ app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => { app.use((req, res, next) => { if (shouldCountHit(req)) { - recordHit(req.path) - if (hasVisitorConsent(req)) { + const ua = sanitizeUserAgent(req.get('user-agent')) + const botDetection = detectBot(ua) + recordHit(req.path, botDetection.isBot, botDetection.reason) + if (hasVisitorConsent(req) && !botDetection.isBot) { recordVisitor(req, res).catch(err => { console.error('[visitor-stats] failed to record visitor:', err) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 7fe3dc7..34513e8 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1,8 +1,33 @@ import { useEffect, useRef, useState } from 'react' import type { ChangeEvent } from 'react' 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 { DEFAULTS } from './content' +import { AnalyticsPanel } from './components/AnalyticsPanel' + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip, + Legend, + ArcElement, +) interface Props { content: SiteContent @@ -20,13 +45,22 @@ interface BackupPreview { totalVisits: number } -interface AdminStats { +export interface AdminStats { totalHits: number + realHits: number + botHits: number firstHitAt: string | null lastHitAt: string | null topPaths: Array<{ path: string; hits: number }> + topPathsReal: Array<{ path: string; hits: number }> + topPathsBot: Array<{ path: string; hits: number }> last7Days: Array<{ day: string; hits: number }> + last7DaysReal: Array<{ day: string; hits: number }> + last7DaysBot: Array<{ day: string; hits: number }> last30DaysTotal: number + last30DaysRealTotal: number + last30DaysBotTotal: number + botReasons: Array<{ reason: string; count: number }> visitors: { totalVisits: number uniqueVisitors: number @@ -2518,132 +2552,26 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { {/* ANALYTICS */} {adminView === 'analytics' && ( -
-
-

Analytics

-

Page hits, visitor geography, and data management.

-
- {statsStatus === 'loading' &&

Loading analytics…

} - {statsStatus === 'error' &&

Failed to load analytics.

} - {statsStatus === 'ready' && stats && ( - <> -
-

Total Hits

{stats.totalHits.toLocaleString()}

-

Last 30 Days

{stats.last30DaysTotal.toLocaleString()}

-

First Hit

{formatDate(stats.firstHitAt)}

-

Latest Hit

{formatDate(stats.lastHitAt)}

-
-
-
-

Top Paths

- {stats.topPaths.length === 0 ?

No hits tracked yet.

: ( -
    {stats.topPaths.map(item =>
  • {item.path}{item.hits.toLocaleString()}
  • )}
- )} -
-
-

Daily Hits (7 Days)

-
    {stats.last7Days.map(item =>
  • {item.day}{item.hits.toLocaleString()}
  • )}
-
-
-
-

Visitor Details

-

IP, geography, and returning visitor behavior.

-
-

Privacy: visitor analytics only run after cookie consent. IPs below are masked.

-
-

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

-

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

-

Returning Visits

{stats.visitors.returningVisits.toLocaleString()}

-

Returning Rate

{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}

-
-
-

Top Countries

    {stats.visitors.topCountries.map(item =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
-

Top States

    {stats.visitors.topStates.map(item =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
-

Top Counties

    {stats.visitors.topCounties.map(item =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
-

Top Cities

    {stats.visitors.topCities.map(item =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
-
-
-

Recent Visitor Log

- {stats.visitors.recentVisits.length === 0 ?

No visitor records yet.

: ( -
- - - - - - {stats.visitors.recentVisits.map(row => ( - - - - ))} - -
TimeIPCountryStateCountyCityPathReturningVisit #
{formatDate(row.at)}{maskIp(row.ip)}{row.country}{row.state}{row.county}{row.city}{row.path}{row.returningVisitor ? 'Yes' : 'No'}{row.visitCount}
-
- )} -
-
-

Contact Summary

-

Submission totals from the contact form.

-
-
-

Total Contact Messages

{stats.contactTotals.totalSubmissions.toLocaleString()}

-

Total Bible Questions

{stats.contactTotals.totalQuestions.toLocaleString()}

-
-
-

Data Management

-

Export, backup, or retain only recent analytics data.

-
-
-

Hit Stats Write

{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.hitStats.at)}

-

Visitor Stats Write

{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.visitorStats.at)}

-

Backup Status

{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.backups.at)}

-

Latest Backup File

{stats.writeStatus.backups.file ?? 'Not available yet'}

-
-
-

Deployment & Cache Status

-

Current deployment metadata and cache purge health for the live app.

-
-
-

Build Commit

{opsStatus?.buildCommit ?? 'Not available'}

-

Build Number

{opsStatus?.buildNumber ?? 'Not available'}

-

Deployed At

{formatDate(opsStatus?.deployedAt ?? null)}

-

Cache Purge

{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}

{formatDate(opsStatus?.cachePurge.at ?? null)}

-
-
- - - - -
-
- - - -
-
- - - -
- {selectedBackupPreview && ( -
-

Restore Preview

-

Backup: {selectedBackupPreview.filename}

-

Created: {formatDate(selectedBackupPreview.createdAt)}

-

Reason: {selectedBackupPreview.reason}

-

Size: {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB

-

Content Updated At: {formatDate(selectedBackupPreview.adminUpdatedAt)}

-

Total Hits: {selectedBackupPreview.totalHits.toLocaleString()}

-

Total Visits: {selectedBackupPreview.totalVisits.toLocaleString()}

-
- )} - {maintenanceMsg &&

{maintenanceMsg}

} - - )} -
+ { void reloadOpsStatus() }} + /> )} {/* EMAILS */} diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx new file mode 100644 index 0000000..8a766b9 --- /dev/null +++ b/src/components/AnalyticsPanel.tsx @@ -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

Loading analytics…

+ if (statsStatus === 'error') return

Failed to load analytics.

+ 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 ( +
+
+

Analytics Dashboard

+

Real-time insights into site traffic, bot detection, visitor behavior, and data management.

+
+ + {/* KPI Grid */} +
+
+

Total Hits

+

{stats.totalHits.toLocaleString()}

+ All page requests +
+
+

Real Traffic

+

{stats.realHits.toLocaleString()}

+ {((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total +
+
+

Bot Traffic

+

{stats.botHits.toLocaleString()}

+ {((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total +
+
+

Last 30 Days

+

{stats.last30DaysRealTotal.toLocaleString()}

+ Real hits • {stats.last30DaysBotTotal.toLocaleString()} bots +
+
+

Unique Visitors

+

{stats.visitors.uniqueVisitors.toLocaleString()}

+ Tracked with consent +
+
+

Returning Rate

+

{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}

+ {stats.visitors.returningVisits.toLocaleString()} return visits +
+
+ + {/* Chart Tabs */} +
+ + + +
+ + {/* Overview Charts */} + {chartTab === 'overview' && ( +
+
+

7-Day Trend (Real vs Bot)

+ +
+
+

Traffic Composition

+ +
+
+ )} + + {/* Bot Breakdown Charts */} + {chartTab === 'breakdown' && ( +
+
+

Bot Sources

+ {stats.botReasons.length === 0 ? ( +

No bot traffic detected.

+ ) : ( + + )} +
+
+

Bot Detection Details

+ {stats.botReasons.length === 0 ? ( +

No bots detected yet.

+ ) : ( +
    + {stats.botReasons.map((reason: { reason: string; count: number }) => ( +
  • + {reason.reason.replace(/-/g, ' ')} + {reason.count.toLocaleString()} +
  • + ))} +
+ )} +
+
+ )} + + {/* Geographic Charts */} + {chartTab === 'geographic' && ( +
+
+

Top Pages

+ +
+
+

Top Countries

+ {stats.visitors.topCountries.length === 0 ? ( +

No visitor data yet.

+ ) : ( +
    + {stats.visitors.topCountries.map((country: { name: string; hits: number }) => ( +
  • + {country.name} + {country.hits.toLocaleString()} +
  • + ))} +
+ )} +
+
+ )} + + {/* Recent Visitors Table */} +
+

Visitor Details

+

Real-time tracking of visitor activity, geography, and returning behavior.

+
+

Privacy: visitor analytics only run after cookie consent. IPs below are masked.

+ +
+

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

+

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

+

Returning Visits

{stats.visitors.returningVisits.toLocaleString()}

+

First Visit

{formatDate(stats.visitors.firstVisitAt)}

+
+ +
+

Top Countries

    {stats.visitors.topCountries.map((item: { name: string; hits: number }) =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
+

Top States

    {stats.visitors.topStates.map((item: { name: string; hits: number }) =>
  • {item.name}{item.hits.toLocaleString()}
  • )}
+
+ +
+

Recent Visitor Log

+ {stats.visitors.recentVisits.length === 0 ? ( +

No visitor records yet.

+ ) : ( +
+ + + + + + {stats.visitors.recentVisits.map(row => ( + + + + ))} + +
TimeIPCountryPathReturningVisit #
{formatDate(row.at)}{maskIp(row.ip)}{row.country}{row.path}{row.returningVisitor ? 'Yes' : 'No'}{row.visitCount}
+
+ )} +
+ + {/* Contact Summary */} +
+

Contact Summary

+

Submission totals from the contact form.

+
+
+

Total Contact Messages

{stats.contactTotals.totalSubmissions.toLocaleString()}

+

Total Bible Questions

{stats.contactTotals.totalQuestions.toLocaleString()}

+
+ + {/* Deployment & Cache Status */} +
+

Deployment & Cache Status

+

Current deployment metadata and cache purge health for the live app.

+
+
+

Build Commit

{opsStatus?.buildCommit ?? 'Not available'}

+

Build Number

{opsStatus?.buildNumber ?? 'Not available'}

+

Deployed At

{formatDate(opsStatus?.deployedAt ?? null)}

+

Cache Purge

{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}

{formatDate(opsStatus?.cachePurge.at ?? null)}

+
+ + {/* Data Management */} +
+

Data Management

+

Export, backup, or retain only recent analytics data.

+
+
+

Hit Stats Write

{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.hitStats.at)}

+

Visitor Stats Write

{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.visitorStats.at)}

+

Backup Status

{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.backups.at)}

+
+
+ + + + +
+
+ + + +
+
+ + + +
+ {selectedBackupPreview && ( +
+

Restore Preview

+

Backup: {selectedBackupPreview.filename}

+

Created: {formatDate(selectedBackupPreview.createdAt)}

+

Size: {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB

+

Total Hits: {selectedBackupPreview.totalHits.toLocaleString()}

+

Total Visits: {selectedBackupPreview.totalVisits.toLocaleString()}

+
+ )} + {maintenanceMsg &&

{maintenanceMsg}

} +
+ ) +}