995 lines
33 KiB
HTML
995 lines
33 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Admin - Visitor Tracking</title>
|
|
<link rel="stylesheet" href="/styles.css" />
|
|
<style>
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.stat-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: var(--text);
|
|
margin: 8px 0;
|
|
}
|
|
.stat-label {
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}
|
|
.chart-container {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.chart-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
}
|
|
.bar-chart {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.bar-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.bar-label {
|
|
min-width: 200px;
|
|
font-size: 14px;
|
|
color: var(--text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.bar-wrapper {
|
|
flex: 1;
|
|
height: 24px;
|
|
background: var(--background);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
transition: width 0.3s ease;
|
|
}
|
|
.bar-count {
|
|
min-width: 60px;
|
|
text-align: right;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.table-container {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
margin-bottom: 24px;
|
|
}
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.data-table th {
|
|
background: var(--background);
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: var(--muted);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.data-table td {
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text);
|
|
}
|
|
.data-table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.data-table tr:hover {
|
|
background: var(--background);
|
|
}
|
|
.time-cell {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
.path-cell {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
}
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--muted);
|
|
}
|
|
.line-chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
height: 200px;
|
|
gap: 4px;
|
|
padding: 20px 0;
|
|
}
|
|
.line-bar {
|
|
flex: 1;
|
|
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 4px 4px 0 0;
|
|
min-width: 8px;
|
|
position: relative;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.line-bar:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.line-bar-label {
|
|
position: absolute;
|
|
bottom: -25px;
|
|
left: 50%;
|
|
transform: translateX(-50%) rotate(-45deg);
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
white-space: nowrap;
|
|
transform-origin: center;
|
|
}
|
|
.line-bar-value {
|
|
position: absolute;
|
|
top: -25px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
</style>
|
|
|
|
<!-- PostHog Analytics -->
|
|
<script src="/posthog.js"></script>
|
|
<script src="/admin.js"></script>
|
|
</head>
|
|
<body>
|
|
<div class="sidebar-overlay"></div>
|
|
<div class="app-shell">
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="brand-mark">A</div>
|
|
<div>
|
|
<div class="brand-title">Admin</div>
|
|
<div class="brand-sub">Site management</div>
|
|
</div>
|
|
<button id="close-sidebar" class="ghost" style="margin-left: auto; display: none;">×</button>
|
|
</div>
|
|
<div class="sidebar-section">
|
|
<div class="section-heading">Navigation</div>
|
|
<a class="ghost" href="/admin/build">Build models</a>
|
|
<a class="ghost" href="/admin/plan">Plan models</a>
|
|
<a class="ghost" href="/admin/plans">Plans</a>
|
|
<a class="ghost" href="/admin/accounts">Accounts</a>
|
|
<a class="ghost" href="/admin/affiliates">Affiliates</a>
|
|
<a class="ghost" href="/admin/withdrawals">Withdrawals</a>
|
|
<a class="ghost" href="/admin/tracking">Tracking</a>
|
|
<a class="ghost" href="/admin/resources">Resources</a>
|
|
<a class="ghost" href="/admin/system-tests">System Tests</a>
|
|
<a class="ghost" href="/admin/external-testing">External Testing</a>
|
|
<a class="ghost" href="/admin/contact-messages">Contact Messages</a>
|
|
<a class="ghost" href="/admin/feature-requests">Feature Requests</a>
|
|
<a class="ghost" href="/admin/login">Login</a>
|
|
</div>
|
|
</aside>
|
|
<main class="main">
|
|
<div class="admin-shell">
|
|
<div class="topbar" style="margin-bottom: 24px;">
|
|
<button id="menu-toggle">
|
|
<span></span><span></span><span></span>
|
|
</button>
|
|
<div>
|
|
<div class="pill">Admin</div>
|
|
<div class="title" style="margin-top: 6px;">Visitor Tracking</div>
|
|
<div class="crumb">Analytics and visitor statistics for your application.</div>
|
|
</div>
|
|
<div class="admin-actions">
|
|
<button id="admin-refresh" class="ghost">Refresh</button>
|
|
<button id="admin-logout" class="primary">Logout</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Enhanced Analytics Overview -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">DAU (Daily Active)</div>
|
|
<div class="stat-value" id="dau">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">WAU (Weekly Active)</div>
|
|
<div class="stat-value" id="wau">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">MAU (Monthly Active)</div>
|
|
<div class="stat-value" id="mau">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg Session Duration</div>
|
|
<div class="stat-value" id="avg-session-duration">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Business Metrics Overview -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">MRR (Monthly Recurring)</div>
|
|
<div class="stat-value" id="mrr">$0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">LTV (Lifetime Value)</div>
|
|
<div class="stat-value" id="ltv">$0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Churn Rate</div>
|
|
<div class="stat-value" id="churn-rate">0%</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">ARPU</div>
|
|
<div class="stat-value" id="arpu">$0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Product Usage Metrics -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Project Completion Rate</div>
|
|
<div class="stat-value" id="project-completion-rate">0%</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Return User Rate</div>
|
|
<div class="stat-value" id="return-user-rate">0%</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Sessions</div>
|
|
<div class="stat-value" id="total-sessions">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Projects</div>
|
|
<div class="stat-value" id="total-projects">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Technical Metrics Overview -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg Queue Time</div>
|
|
<div class="stat-value" id="avg-queue-time">0ms</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Exports</div>
|
|
<div class="stat-value" id="total-exports">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Errors</div>
|
|
<div class="stat-value" id="total-errors">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">System Uptime</div>
|
|
<div class="stat-value" id="system-uptime">0h</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Feature Usage Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Feature Usage (Most Popular)</div>
|
|
<div class="bar-chart" id="feature-usage-chart"></div>
|
|
</div>
|
|
|
|
<!-- Model Usage Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">AI Model Usage</div>
|
|
<div class="bar-chart" id="model-usage-chart"></div>
|
|
</div>
|
|
|
|
<!-- Error Rates Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Error Rates by Type</div>
|
|
<div class="bar-chart" id="error-rates-chart"></div>
|
|
</div>
|
|
|
|
<!-- Plan Upgrade Patterns Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Plan Upgrade Patterns</div>
|
|
<div class="bar-chart" id="plan-upgrades-chart"></div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 24px;">
|
|
<!-- Retention Cohorts Table -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Retention Cohorts</div>
|
|
<div class="table-container">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Cohort Month</th>
|
|
<th>Size</th>
|
|
<th>1 Week</th>
|
|
<th>1 Month</th>
|
|
<th>3 Month</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="retention-cohorts-table">
|
|
<tr>
|
|
<td colspan="5" class="empty-state">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Conversion Funnels -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Conversion Funnels</div>
|
|
<div id="conversion-funnels-chart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resource Utilization Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Resource Utilization (Last 24 Hours)</div>
|
|
<div class="line-chart" id="resource-utilization-chart"></div>
|
|
</div>
|
|
|
|
<!-- AI Response Times Chart -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">AI Response Times (Last 100 Requests)</div>
|
|
<div class="line-chart" id="ai-response-times-chart"></div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 24px;">
|
|
<!-- Top Referrers -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Top Referrers</div>
|
|
<div class="bar-chart" id="referrers-chart"></div>
|
|
</div>
|
|
|
|
<!-- Top Referrers to Upgrade -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Top Referrers to Upgrade Page</div>
|
|
<div class="bar-chart" id="upgrade-referrers-chart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 24px;">
|
|
<!-- Top Pages -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Most Visited Pages</div>
|
|
<div class="bar-chart" id="pages-chart"></div>
|
|
</div>
|
|
|
|
<!-- Conversion Sources -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Signup Conversion Sources</div>
|
|
<div class="bar-chart" id="conversion-sources-chart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upgrade Popup Sources -->
|
|
<div class="chart-container">
|
|
<div class="chart-title">Upgrade Popup Sources (Where users clicked upgrade)</div>
|
|
<div class="bar-chart" id="upgrade-sources-chart"></div>
|
|
</div>
|
|
|
|
<!-- Recent Visits Table -->
|
|
<div class="table-container">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Path</th>
|
|
<th>Referrer</th>
|
|
<th>IP Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="recent-visits-table">
|
|
<tr>
|
|
<td colspan="4" class="empty-state">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
let trackingData = null;
|
|
|
|
const byId = (id) => document.getElementById(id);
|
|
|
|
function setText(id, value) {
|
|
const el = byId(id);
|
|
if (!el) return;
|
|
el.textContent = value;
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
return (Number(value) || 0).toLocaleString();
|
|
}
|
|
|
|
function formatMoney(value) {
|
|
const num = Number(value) || 0;
|
|
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function formatPercent(value, digits = 0) {
|
|
const num = Number(value) || 0;
|
|
return `${num.toFixed(digits)}%`;
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const num = Number(seconds) || 0;
|
|
if (num < 60) return `${Math.round(num)}s`;
|
|
const minutes = Math.floor(num / 60);
|
|
const remainingSeconds = Math.round(num % 60);
|
|
return `${minutes}m ${remainingSeconds}s`;
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
const num = Number(seconds) || 0;
|
|
const hours = Math.floor(num / 3600);
|
|
const days = Math.floor(hours / 24);
|
|
if (days > 0) return `${days}d ${hours % 24}h`;
|
|
if (hours > 0) return `${hours}h ${Math.floor((num % 3600) / 60)}m`;
|
|
return `${Math.floor(num / 60)}m`;
|
|
}
|
|
|
|
async function loadTrackingData() {
|
|
try {
|
|
// Ensure credentials are included so admin session cookie is sent
|
|
const response = await fetch('/api/admin/tracking', { credentials: 'same-origin' });
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
window.location.href = '/admin/login';
|
|
return;
|
|
}
|
|
throw new Error('Failed to load tracking data');
|
|
}
|
|
|
|
const data = await response.json();
|
|
trackingData = (data && data.stats) ? data.stats : null;
|
|
|
|
renderStats();
|
|
renderFeatureUsageChart();
|
|
renderModelUsageChart();
|
|
renderErrorRatesChart();
|
|
renderPlanUpgradesChart();
|
|
renderRetentionCohorts();
|
|
renderConversionFunnels();
|
|
renderResourceUtilization();
|
|
renderAIResponseTimes();
|
|
renderReferrersChart();
|
|
renderUpgradeReferrersChart();
|
|
renderPagesChart();
|
|
renderConversionSourcesChart();
|
|
renderUpgradeSourcesChart();
|
|
renderRecentVisits();
|
|
} catch (error) {
|
|
console.error('Error loading tracking data:', error);
|
|
}
|
|
}
|
|
|
|
function renderStats() {
|
|
if (!trackingData) return;
|
|
|
|
const engagement = trackingData.userEngagement || {};
|
|
setText('dau', formatNumber(engagement.dau));
|
|
setText('wau', formatNumber(engagement.wau));
|
|
setText('mau', formatNumber(engagement.mau));
|
|
setText('avg-session-duration', formatDuration(engagement.averageSessionDuration));
|
|
|
|
const business = trackingData.businessMetrics || {};
|
|
setText('mrr', formatMoney(business.mrr));
|
|
setText('ltv', formatMoney(business.ltv));
|
|
setText('churn-rate', formatPercent(business.churnRate, 1));
|
|
setText('arpu', formatMoney(business.averageRevenuePerUser));
|
|
|
|
setText('project-completion-rate', formatPercent(engagement.projectCompletionRate));
|
|
setText('return-user-rate', formatPercent(engagement.returnUserRate));
|
|
|
|
const sessions = trackingData.sessionInsights || {};
|
|
setText('total-sessions', formatNumber(sessions.totalSessions));
|
|
setText('total-projects', formatNumber(sessions.totalProjectsCreated));
|
|
setText('total-exports', formatNumber(sessions.totalExports));
|
|
setText('total-errors', formatNumber(sessions.totalErrors));
|
|
|
|
const tech = trackingData.technicalMetrics || {};
|
|
setText('avg-queue-time', `${formatNumber(tech.averageQueueTime)}ms`);
|
|
setText('system-uptime', formatUptime(tech.systemHealth && tech.systemHealth.uptime));
|
|
}
|
|
|
|
function renderBarChart({
|
|
containerId,
|
|
entries,
|
|
maxItems = 10,
|
|
emptyText = 'No data available',
|
|
barGradient = 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
|
labelFormatter = (label) => label,
|
|
countFormatter = (count) => String(count),
|
|
}) {
|
|
const chartEl = byId(containerId);
|
|
if (!chartEl) return;
|
|
|
|
chartEl.innerHTML = '';
|
|
|
|
const sliced = entries.slice(0, maxItems);
|
|
if (sliced.length === 0) {
|
|
chartEl.innerHTML = `<div class="empty-state">${emptyText}</div>`;
|
|
return;
|
|
}
|
|
|
|
const maxCount = Math.max(...sliced.map(([, count]) => Number(count) || 0), 1);
|
|
|
|
sliced.forEach(([labelRaw, countRaw]) => {
|
|
const count = Number(countRaw) || 0;
|
|
const item = document.createElement('div');
|
|
item.className = 'bar-item';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'bar-label';
|
|
label.textContent = labelFormatter(labelRaw);
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'bar-wrapper';
|
|
|
|
const fill = document.createElement('div');
|
|
fill.className = 'bar-fill';
|
|
fill.style.background = barGradient;
|
|
fill.style.width = `${(count / maxCount) * 100}%`;
|
|
|
|
const countEl = document.createElement('div');
|
|
countEl.className = 'bar-count';
|
|
countEl.textContent = countFormatter(count);
|
|
|
|
wrapper.appendChild(fill);
|
|
item.appendChild(label);
|
|
item.appendChild(wrapper);
|
|
item.appendChild(countEl);
|
|
|
|
chartEl.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function renderFeatureUsageChart() {
|
|
if (!trackingData || !trackingData.featureUsage) return;
|
|
|
|
renderBarChart({
|
|
containerId: 'feature-usage-chart',
|
|
entries: Object.entries(trackingData.featureUsage).sort((a, b) => (b[1] || 0) - (a[1] || 0)),
|
|
emptyText: 'No feature usage data available',
|
|
barGradient: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
|
labelFormatter: (feature) => String(feature).replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
|
});
|
|
}
|
|
|
|
function renderModelUsageChart() {
|
|
if (!trackingData || !trackingData.modelUsage) return;
|
|
|
|
renderBarChart({
|
|
containerId: 'model-usage-chart',
|
|
entries: Object.entries(trackingData.modelUsage).sort((a, b) => (b[1] || 0) - (a[1] || 0)),
|
|
emptyText: 'No model usage data available',
|
|
barGradient: 'linear-gradient(90deg, #008060 0%, #004c3f 100%)',
|
|
labelFormatter: (model) => String(model).split('/').pop(),
|
|
});
|
|
}
|
|
|
|
function renderErrorRatesChart() {
|
|
if (!trackingData || !trackingData.errorRates) return;
|
|
|
|
renderBarChart({
|
|
containerId: 'error-rates-chart',
|
|
entries: Object.entries(trackingData.errorRates).sort((a, b) => (b[1] || 0) - (a[1] || 0)),
|
|
emptyText: 'No error data available',
|
|
barGradient: 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)',
|
|
labelFormatter: (error) => String(error).replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
|
});
|
|
}
|
|
|
|
function renderPlanUpgradesChart() {
|
|
if (!trackingData || !trackingData.planUpgradePatterns) return;
|
|
|
|
const upgrades = trackingData.planUpgradePatterns;
|
|
const upgradeEntries = [];
|
|
|
|
Object.entries(upgrades).forEach(([fromPlan, toPlans]) => {
|
|
Object.entries(toPlans || {}).forEach(([toPlan, count]) => {
|
|
upgradeEntries.push([`${fromPlan} → ${toPlan}`, Number(count) || 0]);
|
|
});
|
|
});
|
|
|
|
upgradeEntries.sort((a, b) => (b[1] || 0) - (a[1] || 0));
|
|
|
|
renderBarChart({
|
|
containerId: 'plan-upgrades-chart',
|
|
entries: upgradeEntries,
|
|
emptyText: 'No upgrade data available',
|
|
barGradient: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)',
|
|
});
|
|
}
|
|
|
|
function renderRetentionCohorts() {
|
|
if (!trackingData || !trackingData.retentionCohorts) return;
|
|
|
|
const tableEl = byId('retention-cohorts-table');
|
|
if (!tableEl) return;
|
|
|
|
tableEl.innerHTML = '';
|
|
|
|
const cohorts = Object.entries(trackingData.retentionCohorts)
|
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
.slice(0, 12);
|
|
|
|
if (cohorts.length === 0) {
|
|
tableEl.innerHTML = '<tr><td colspan="5" class="empty-state">No cohort data available</td></tr>';
|
|
return;
|
|
}
|
|
|
|
cohorts.forEach(([month, cohort]) => {
|
|
const row = document.createElement('tr');
|
|
const tdMonth = document.createElement('td');
|
|
tdMonth.textContent = month;
|
|
|
|
const tdSize = document.createElement('td');
|
|
tdSize.textContent = formatNumber(cohort && cohort.cohortSize);
|
|
|
|
const retention = (cohort && cohort.retention) || {};
|
|
const td1w = document.createElement('td');
|
|
td1w.textContent = formatPercent(retention['1week'] || 0, 1);
|
|
|
|
const td1m = document.createElement('td');
|
|
td1m.textContent = formatPercent(retention['1month'] || 0, 1);
|
|
|
|
const td3m = document.createElement('td');
|
|
td3m.textContent = formatPercent(retention['3month'] || 0, 1);
|
|
|
|
row.appendChild(tdMonth);
|
|
row.appendChild(tdSize);
|
|
row.appendChild(td1w);
|
|
row.appendChild(td1m);
|
|
row.appendChild(td3m);
|
|
tableEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderConversionFunnels() {
|
|
if (!trackingData || !trackingData.conversionFunnels) return;
|
|
|
|
const chartEl = byId('conversion-funnels-chart');
|
|
if (!chartEl) return;
|
|
|
|
chartEl.innerHTML = '';
|
|
|
|
const funnels = trackingData.conversionFunnels;
|
|
const funnelNames = Object.keys(funnels || {});
|
|
|
|
if (funnelNames.length === 0) {
|
|
chartEl.innerHTML = '<div class="empty-state">No funnel data available</div>';
|
|
return;
|
|
}
|
|
|
|
funnelNames.forEach((funnelName) => {
|
|
const funnel = funnels[funnelName] || {};
|
|
const steps = Object.keys(funnel).sort();
|
|
|
|
if (steps.length === 0) return;
|
|
|
|
const funnelDiv = document.createElement('div');
|
|
funnelDiv.className = 'chart-container';
|
|
funnelDiv.style.marginBottom = '20px';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-title';
|
|
title.textContent = String(funnelName).replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
|
|
|
const stepsList = document.createElement('div');
|
|
stepsList.className = 'bar-chart';
|
|
|
|
const maxCount = Math.max(
|
|
...steps.map((step) => Number((funnel[step] && funnel[step].count) || 0)),
|
|
1
|
|
);
|
|
|
|
steps.forEach((step) => {
|
|
const stepData = funnel[step] || {};
|
|
const count = Number(stepData.count) || 0;
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'bar-item';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'bar-label';
|
|
label.textContent = String(step).replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'bar-wrapper';
|
|
|
|
const fill = document.createElement('div');
|
|
fill.className = 'bar-fill';
|
|
fill.style.background = 'linear-gradient(90deg, #10b981 0%, #059669 100%)';
|
|
fill.style.width = `${(count / maxCount) * 100}%`;
|
|
|
|
const countEl = document.createElement('div');
|
|
countEl.className = 'bar-count';
|
|
countEl.textContent = formatNumber(count);
|
|
|
|
wrapper.appendChild(fill);
|
|
item.appendChild(label);
|
|
item.appendChild(wrapper);
|
|
item.appendChild(countEl);
|
|
|
|
stepsList.appendChild(item);
|
|
});
|
|
|
|
funnelDiv.appendChild(title);
|
|
funnelDiv.appendChild(stepsList);
|
|
chartEl.appendChild(funnelDiv);
|
|
});
|
|
}
|
|
|
|
function getResourceUsageSeries() {
|
|
if (!trackingData || !trackingData.technicalMetrics) return [];
|
|
|
|
const tech = trackingData.technicalMetrics;
|
|
|
|
if (Array.isArray(tech.resourceUsage)) {
|
|
return tech.resourceUsage;
|
|
}
|
|
|
|
const raw = tech.resourceUtilization;
|
|
if (!raw) return [];
|
|
|
|
if (Array.isArray(raw)) return raw;
|
|
|
|
if (typeof raw === 'object') {
|
|
return Object.entries(raw)
|
|
.map(([timestamp, data]) => ({ timestamp: Number(timestamp), ...(data || {}) }))
|
|
.filter((d) => Number.isFinite(d.timestamp))
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function renderResourceUtilization() {
|
|
const chartEl = byId('resource-utilization-chart');
|
|
if (!chartEl) return;
|
|
|
|
chartEl.innerHTML = '';
|
|
|
|
const all = getResourceUsageSeries();
|
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
const data = all.filter((d) => (Number(d.timestamp) || 0) >= cutoff).slice(-288); // ~24h @ 5m intervals
|
|
|
|
if (data.length === 0) {
|
|
chartEl.innerHTML = '<div class="empty-state">No resource data available</div>';
|
|
return;
|
|
}
|
|
|
|
const maxMemory = Math.max(...data.map((d) => Number(d.memory) || 0), 1);
|
|
const labelEvery = Math.max(1, Math.floor(data.length / 8));
|
|
|
|
data.forEach((d, idx) => {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'line-bar';
|
|
|
|
const height = ((Number(d.memory) || 0) / maxMemory) * 100;
|
|
bar.style.height = `${height}%`;
|
|
|
|
const ts = Number(d.timestamp) || 0;
|
|
const memMb = ((Number(d.memory) || 0) / (1024 * 1024)).toFixed(1);
|
|
const cpu = Number(d.cpu) || 0;
|
|
bar.title = `${new Date(ts).toLocaleString()}: ${memMb}MB, load: ${cpu.toFixed(2)}`;
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'line-bar-label';
|
|
label.textContent = idx % labelEvery === 0 ? new Date(ts).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : '';
|
|
bar.appendChild(label);
|
|
|
|
chartEl.appendChild(bar);
|
|
});
|
|
}
|
|
|
|
function renderAIResponseTimes() {
|
|
const chartEl = byId('ai-response-times-chart');
|
|
if (!chartEl) return;
|
|
if (!trackingData || !trackingData.technicalMetrics || !Array.isArray(trackingData.technicalMetrics.aiResponseTimes)) return;
|
|
|
|
chartEl.innerHTML = '';
|
|
|
|
const responseTimes = trackingData.technicalMetrics.aiResponseTimes.slice(-100);
|
|
if (responseTimes.length === 0) {
|
|
chartEl.innerHTML = '<div class="empty-state">No response time data available</div>';
|
|
return;
|
|
}
|
|
|
|
const maxTime = Math.max(...responseTimes.map((r) => Number(r.responseTime) || 0), 1);
|
|
|
|
responseTimes.forEach((data, index) => {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'line-bar';
|
|
|
|
const height = ((Number(data.responseTime) || 0) / maxTime) * 100;
|
|
bar.style.height = `${height}%`;
|
|
bar.style.background = data.success
|
|
? 'linear-gradient(180deg, #10b981 0%, #059669 100%)'
|
|
: 'linear-gradient(180deg, #ef4444 0%, #dc2626 100%)';
|
|
|
|
bar.title = `${data.provider}: ${data.responseTime}ms ${data.success ? '(success)' : '(error)'}`;
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'line-bar-label';
|
|
label.textContent = index % 10 === 0 ? `${Math.round(Number(data.responseTime) || 0)}ms` : '';
|
|
bar.appendChild(label);
|
|
|
|
chartEl.appendChild(bar);
|
|
});
|
|
}
|
|
|
|
function renderReferrersChart() {
|
|
if (!trackingData) return;
|
|
|
|
const referrers = Array.isArray(trackingData.topReferrers) ? trackingData.topReferrers.slice(0, 10) : [];
|
|
renderBarChart({
|
|
containerId: 'referrers-chart',
|
|
entries: referrers.map((r) => [r.domain, r.count]),
|
|
emptyText: 'No referrer data available',
|
|
barGradient: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
|
countFormatter: (count) => formatNumber(count),
|
|
});
|
|
}
|
|
|
|
function renderUpgradeReferrersChart() {
|
|
if (!trackingData) return;
|
|
|
|
const referrers = Array.isArray(trackingData.referrersToUpgrade) ? trackingData.referrersToUpgrade : [];
|
|
renderBarChart({
|
|
containerId: 'upgrade-referrers-chart',
|
|
entries: referrers.map((r) => [r.domain, r.count]),
|
|
maxItems: 10,
|
|
emptyText: 'No data available',
|
|
barGradient: 'linear-gradient(90deg, #008060 0%, #004c3f 100%)',
|
|
countFormatter: (count) => formatNumber(count),
|
|
});
|
|
}
|
|
|
|
function renderPagesChart() {
|
|
if (!trackingData) return;
|
|
|
|
const pages = Array.isArray(trackingData.topPages) ? trackingData.topPages.slice(0, 10) : [];
|
|
renderBarChart({
|
|
containerId: 'pages-chart',
|
|
entries: pages.map((p) => [p.path, p.count]),
|
|
emptyText: 'No page data available',
|
|
barGradient: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
|
countFormatter: (count) => formatNumber(count),
|
|
});
|
|
}
|
|
|
|
function renderConversionSourcesChart() {
|
|
if (!trackingData || !trackingData.conversionSources) return;
|
|
|
|
const sources = trackingData.conversionSources.signup || {};
|
|
const entries = Object.entries(sources).sort((a, b) => (b[1] || 0) - (a[1] || 0));
|
|
|
|
renderBarChart({
|
|
containerId: 'conversion-sources-chart',
|
|
entries,
|
|
emptyText: 'No data available',
|
|
barGradient: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)',
|
|
labelFormatter: (source) => {
|
|
const s = String(source);
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderUpgradeSourcesChart() {
|
|
if (!trackingData || !trackingData.upgradeSources) return;
|
|
|
|
const sources = trackingData.upgradeSources || {};
|
|
const entries = Object.entries(sources).sort((a, b) => (b[1] || 0) - (a[1] || 0));
|
|
const sourceLabels = {
|
|
apps_page: 'Apps Page',
|
|
builder_model: 'Builder Model Selection',
|
|
usage_limit: 'Usage Limit Reached',
|
|
other: 'Other',
|
|
};
|
|
|
|
renderBarChart({
|
|
containerId: 'upgrade-sources-chart',
|
|
entries,
|
|
emptyText: 'No upgrade popup data available',
|
|
barGradient: 'linear-gradient(90deg, #f59e0b 0%, #d97706 100%)',
|
|
labelFormatter: (source) => sourceLabels[source] || String(source).replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
|
});
|
|
}
|
|
|
|
function renderRecentVisits() {
|
|
if (!trackingData) return;
|
|
|
|
const tableEl = byId('recent-visits-table');
|
|
if (!tableEl) return;
|
|
|
|
tableEl.innerHTML = '';
|
|
|
|
const visits = Array.isArray(trackingData.recentVisits) ? trackingData.recentVisits.slice(0, 50) : [];
|
|
if (visits.length === 0) {
|
|
tableEl.innerHTML = '<tr><td colspan="4" class="empty-state">No recent visits</td></tr>';
|
|
return;
|
|
}
|
|
|
|
visits.forEach((visit) => {
|
|
const row = document.createElement('tr');
|
|
|
|
const timeCell = document.createElement('td');
|
|
timeCell.className = 'time-cell';
|
|
timeCell.textContent = new Date(visit.timestamp).toLocaleString();
|
|
|
|
const pathCell = document.createElement('td');
|
|
pathCell.className = 'path-cell';
|
|
pathCell.textContent = visit.path;
|
|
|
|
const referrerCell = document.createElement('td');
|
|
referrerCell.textContent = visit.referrer === 'direct' ? 'Direct' : visit.referrer;
|
|
referrerCell.style.maxWidth = '200px';
|
|
referrerCell.style.overflow = 'hidden';
|
|
referrerCell.style.textOverflow = 'ellipsis';
|
|
referrerCell.style.whiteSpace = 'nowrap';
|
|
referrerCell.title = visit.referrer;
|
|
|
|
const ipCell = document.createElement('td');
|
|
ipCell.textContent = visit.ip;
|
|
ipCell.style.fontFamily = 'monospace';
|
|
ipCell.style.fontSize = '13px';
|
|
|
|
row.appendChild(timeCell);
|
|
row.appendChild(pathCell);
|
|
row.appendChild(referrerCell);
|
|
row.appendChild(ipCell);
|
|
tableEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Admin controls
|
|
byId('admin-refresh')?.addEventListener('click', () => {
|
|
loadTrackingData();
|
|
});
|
|
|
|
byId('admin-logout')?.addEventListener('click', async () => {
|
|
try {
|
|
await fetch('/api/admin/logout', { method: 'POST', credentials: 'same-origin' });
|
|
window.location.href = '/admin/login';
|
|
} catch (error) {
|
|
console.error('Logout error:', error);
|
|
window.location.href = '/admin/login';
|
|
}
|
|
});
|
|
|
|
// Load data on page load
|
|
loadTrackingData();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|