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:
nmemmert
2026-05-07 10:06:32 -04:00
parent 583a9b7b66
commit 3cbb7cc409
5 changed files with 599 additions and 132 deletions
+55 -127
View File
@@ -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' && (
<section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head">
<h2>Analytics</h2>
<p>Page hits, visitor geography, and data management.</p>
</div>
{statsStatus === 'loading' && <p className="admin-stats-note">Loading analytics</p>}
{statsStatus === 'error' && <p className="admin-stats-note">Failed to load analytics.</p>}
{statsStatus === 'ready' && stats && (
<>
<div className="admin-stats-grid">
<article><h3>Total Hits</h3><p>{stats.totalHits.toLocaleString()}</p></article>
<article><h3>Last 30 Days</h3><p>{stats.last30DaysTotal.toLocaleString()}</p></article>
<article><h3>First Hit</h3><p>{formatDate(stats.firstHitAt)}</p></article>
<article><h3>Latest Hit</h3><p>{formatDate(stats.lastHitAt)}</p></article>
</div>
<div className="admin-stats-lists">
<div>
<h3>Top Paths</h3>
{stats.topPaths.length === 0 ? <p className="admin-stats-note">No hits tracked yet.</p> : (
<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 &amp; 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>
<AnalyticsPanel
stats={stats}
statsStatus={statsStatus}
opsStatus={opsStatus}
formatDate={formatDate}
maskIp={maskIp}
maintenanceMsg={maintenanceMsg}
selectedBackup={selectedBackup}
backupFiles={backupFiles}
selectedBackupPreview={selectedBackupPreview}
onBackupSelect={setSelectedBackup}
onRestore={handleRestoreBackup}
onExport={handleExport}
onBackupNow={handleBackupNow}
onPrune={handlePrune}
onClear={handleClear}
onPurgeCache={handlePurgeCache}
onDeployHook={handleDeployHook}
onRefreshStatus={() => { void reloadOpsStatus() }}
/>
)}
{/* EMAILS */}