Restore to commit 74e578279624c6045ca440a3459ebfa1f8d54191
This commit is contained in:
990
chat/public/admin-tracking.html
Normal file
990
chat/public/admin-tracking.html
Normal file
@@ -0,0 +1,990 @@
|
||||
<!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/contact-messages">Contact Messages</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>
|
||||
Reference in New Issue
Block a user