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:
+55
-127
@@ -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 & 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 */}
|
||||
|
||||
Reference in New Issue
Block a user