Add analytics components and services
This commit is contained in:
BIN
dashboard-server/._google-server
Normal file
BIN
dashboard-server/._google-server
Normal file
Binary file not shown.
@@ -24,6 +24,9 @@ const authEnv = loadEnvFile(path.resolve(__dirname, 'auth-server/.env'));
|
|||||||
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
|
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
|
||||||
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
|
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
|
||||||
const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env'));
|
const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env'));
|
||||||
|
const googleAnalyticsEnv = require('dotenv').config({
|
||||||
|
path: path.resolve(__dirname, 'google-server/.env')
|
||||||
|
}).parsed || {};
|
||||||
|
|
||||||
// Common log settings for all apps
|
// Common log settings for all apps
|
||||||
const logSettings = {
|
const logSettings = {
|
||||||
@@ -145,6 +148,24 @@ module.exports = {
|
|||||||
out_file: "./logs/gorgias-server-out.log",
|
out_file: "./logs/gorgias-server-out.log",
|
||||||
log_file: "./logs/gorgias-server-combined.log",
|
log_file: "./logs/gorgias-server-combined.log",
|
||||||
time: true
|
time: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...commonSettings,
|
||||||
|
name: 'google-analytics-server',
|
||||||
|
script: path.resolve(__dirname, './google-server/server.js'),
|
||||||
|
watch: false,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
GOOGLE_ANALYTICS_PORT: 3007,
|
||||||
|
...googleAnalyticsEnv
|
||||||
|
},
|
||||||
|
error_file: path.resolve(__dirname, './google-server/logs/pm2/err.log'),
|
||||||
|
out_file: path.resolve(__dirname, './google-server/logs/pm2/out.log'),
|
||||||
|
log_file: path.resolve(__dirname, './google-server/logs/pm2/combined.log'),
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
GOOGLE_ANALYTICS_PORT: 3007
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
dashboard-server/google-server/._.env
Normal file
BIN
dashboard-server/google-server/._.env
Normal file
Binary file not shown.
BIN
dashboard-server/google-server/._logs
Normal file
BIN
dashboard-server/google-server/._logs
Normal file
Binary file not shown.
BIN
dashboard-server/google-server/._models
Normal file
BIN
dashboard-server/google-server/._models
Normal file
Binary file not shown.
BIN
dashboard-server/google-server/._package.json
Normal file
BIN
dashboard-server/google-server/._package.json
Normal file
Binary file not shown.
BIN
dashboard-server/google-server/._server.js
Normal file
BIN
dashboard-server/google-server/._server.js
Normal file
Binary file not shown.
2506
dashboard-server/google-server/package-lock.json
generated
Normal file
2506
dashboard-server/google-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
dashboard-server/google-server/package.json
Normal file
21
dashboard-server/google-server/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "google-analytics-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Google Analytics server for dashboard",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google-analytics/data": "^4.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"redis": "^4.6.11",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dashboard-server/google-server/routes/._analytics.js
Normal file
BIN
dashboard-server/google-server/routes/._analytics.js
Normal file
Binary file not shown.
BIN
dashboard-server/google-server/routes/._analytics.routes.js
Normal file
BIN
dashboard-server/google-server/routes/._analytics.routes.js
Normal file
Binary file not shown.
254
dashboard-server/google-server/routes/analytics.js
Normal file
254
dashboard-server/google-server/routes/analytics.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Initialize GA4 client
|
||||||
|
const analyticsClient = new BetaAnalyticsDataClient({
|
||||||
|
credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON)
|
||||||
|
});
|
||||||
|
|
||||||
|
const propertyId = process.env.GA_PROPERTY_ID;
|
||||||
|
|
||||||
|
// Cache durations
|
||||||
|
const CACHE_DURATIONS = {
|
||||||
|
REALTIME_BASIC: 60, // 1 minute
|
||||||
|
REALTIME_DETAILED: 300, // 5 minutes
|
||||||
|
BASIC_METRICS: 3600, // 1 hour
|
||||||
|
USER_BEHAVIOR: 3600 // 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
// Basic metrics endpoint
|
||||||
|
router.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate = '7daysAgo' } = req.query;
|
||||||
|
const cacheKey = `analytics:basic_metrics:${startDate}`;
|
||||||
|
|
||||||
|
// Check Redis cache
|
||||||
|
const cachedData = await req.redisClient.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.info('Returning cached basic metrics data');
|
||||||
|
return res.json({ success: true, data: JSON.parse(cachedData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from GA4
|
||||||
|
const [response] = await analyticsClient.runReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dateRanges: [{ startDate, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'date' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'activeUsers' },
|
||||||
|
{ name: 'newUsers' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'conversions' }
|
||||||
|
],
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await req.redisClient.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: CACHE_DURATIONS.BASIC_METRICS
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: response });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching basic metrics:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime basic data endpoint
|
||||||
|
router.get('/realtime/basic', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = 'analytics:realtime:basic';
|
||||||
|
|
||||||
|
// Check Redis cache
|
||||||
|
const cachedData = await req.redisClient.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.info('Returning cached realtime basic data');
|
||||||
|
return res.json({ success: true, data: JSON.parse(cachedData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch active users
|
||||||
|
const [userResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch last 5 minutes
|
||||||
|
const [fiveMinResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch time series data
|
||||||
|
const [timeSeriesResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dimensions: [{ name: 'minutesAgo' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
userResponse,
|
||||||
|
fiveMinResponse,
|
||||||
|
timeSeriesResponse,
|
||||||
|
quotaInfo: {
|
||||||
|
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
|
||||||
|
daily: userResponse.propertyQuota.tokensPerDay,
|
||||||
|
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
|
||||||
|
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await req.redisClient.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: CACHE_DURATIONS.REALTIME_BASIC
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: response });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching realtime basic data:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime detailed data endpoint
|
||||||
|
router.get('/realtime/detailed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cacheKey = 'analytics:realtime:detailed';
|
||||||
|
|
||||||
|
// Check Redis cache
|
||||||
|
const cachedData = await req.redisClient.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.info('Returning cached realtime detailed data');
|
||||||
|
return res.json({ success: true, data: JSON.parse(cachedData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current pages
|
||||||
|
const [pageResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dimensions: [{ name: 'unifiedScreenName' }],
|
||||||
|
metrics: [{ name: 'screenPageViews' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch events
|
||||||
|
const [eventResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dimensions: [{ name: 'eventName' }],
|
||||||
|
metrics: [{ name: 'eventCount' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch device categories
|
||||||
|
const [deviceResponse] = await analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
|
||||||
|
limit: 10,
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
pageResponse,
|
||||||
|
eventResponse,
|
||||||
|
sourceResponse: deviceResponse
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await req.redisClient.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: CACHE_DURATIONS.REALTIME_DETAILED
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: response });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching realtime detailed data:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User behavior endpoint
|
||||||
|
router.get('/user-behavior', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange = '30' } = req.query;
|
||||||
|
const cacheKey = `analytics:user_behavior:${timeRange}`;
|
||||||
|
|
||||||
|
// Check Redis cache
|
||||||
|
const cachedData = await req.redisClient.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.info('Returning cached user behavior data');
|
||||||
|
return res.json({ success: true, data: JSON.parse(cachedData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch page data
|
||||||
|
const [pageResponse] = await analyticsClient.runReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'pagePath' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'sessions' }
|
||||||
|
],
|
||||||
|
orderBy: [{
|
||||||
|
metric: { metricName: 'screenPageViews' },
|
||||||
|
desc: true
|
||||||
|
}],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch device data
|
||||||
|
const [deviceResponse] = await analyticsClient.runReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'sessions' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch source data
|
||||||
|
const [sourceResponse] = await analyticsClient.runReport({
|
||||||
|
property: `properties/${propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'sessionSource' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'sessions' },
|
||||||
|
{ name: 'conversions' }
|
||||||
|
],
|
||||||
|
orderBy: [{
|
||||||
|
metric: { metricName: 'sessions' },
|
||||||
|
desc: true
|
||||||
|
}],
|
||||||
|
limit: 25,
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
pageResponse,
|
||||||
|
deviceResponse,
|
||||||
|
sourceResponse
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await req.redisClient.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: CACHE_DURATIONS.USER_BEHAVIOR
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: response });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching user behavior data:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
91
dashboard-server/google-server/routes/analytics.routes.js
Normal file
91
dashboard-server/google-server/routes/analytics.routes.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const analyticsService = require('../services/analytics.service');
|
||||||
|
|
||||||
|
// Basic metrics endpoint
|
||||||
|
router.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate = '7daysAgo' } = req.query;
|
||||||
|
console.log(`Fetching metrics with startDate: ${startDate}`);
|
||||||
|
|
||||||
|
const data = await analyticsService.getBasicMetrics(startDate);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metrics error:', {
|
||||||
|
startDate: req.query.startDate,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch metrics',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime basic data endpoint
|
||||||
|
router.get('/realtime/basic', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching realtime basic data');
|
||||||
|
const data = await analyticsService.getRealTimeBasicData();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Realtime basic error:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch realtime basic data',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime detailed data endpoint
|
||||||
|
router.get('/realtime/detailed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching realtime detailed data');
|
||||||
|
const data = await analyticsService.getRealTimeDetailedData();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Realtime detailed error:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch realtime detailed data',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User behavior endpoint
|
||||||
|
router.get('/user-behavior', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange = '30' } = req.query;
|
||||||
|
console.log(`Fetching user behavior with timeRange: ${timeRange}`);
|
||||||
|
|
||||||
|
const data = await analyticsService.getUserBehavior(timeRange);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User behavior error:', {
|
||||||
|
timeRange: req.query.timeRange,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch user behavior data',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
65
dashboard-server/google-server/server.js
Normal file
65
dashboard-server/google-server/server.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const analyticsRoutes = require('./routes/analytics.routes');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.GOOGLE_ANALYTICS_PORT || 3007;
|
||||||
|
|
||||||
|
// Redis client setup
|
||||||
|
const redisClient = createClient({
|
||||||
|
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
|
||||||
|
redisClient.on('connect', () => console.log('Redis Client Connected'));
|
||||||
|
|
||||||
|
// Connect to Redis
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await redisClient.connect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Redis connection error:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Make Redis client available in requests
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.redisClient = redisClient;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Server error:', err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: err.message || 'Internal server error',
|
||||||
|
error: process.env.NODE_ENV === 'production' ? err : {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Google Analytics server running on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('SIGTERM received. Shutting down gracefully...');
|
||||||
|
await redisClient.quit();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('SIGINT received. Shutting down gracefully...');
|
||||||
|
await redisClient.quit();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
BIN
dashboard-server/google-server/services/._analytics.service.js
Normal file
BIN
dashboard-server/google-server/services/._analytics.service.js
Normal file
Binary file not shown.
283
dashboard-server/google-server/services/analytics.service.js
Normal file
283
dashboard-server/google-server/services/analytics.service.js
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
|
||||||
|
class AnalyticsService {
|
||||||
|
constructor() {
|
||||||
|
// Initialize Redis client
|
||||||
|
this.redis = createClient({
|
||||||
|
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('error', err => console.error('Redis Client Error:', err));
|
||||||
|
this.redis.connect().catch(err => console.error('Redis connection error:', err));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize GA4 client
|
||||||
|
const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON;
|
||||||
|
this.analyticsClient = new BetaAnalyticsDataClient({
|
||||||
|
credentials: typeof credentials === 'string' ? JSON.parse(credentials) : credentials
|
||||||
|
});
|
||||||
|
|
||||||
|
this.propertyId = process.env.GA_PROPERTY_ID;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize GA4 client:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache durations
|
||||||
|
CACHE_DURATIONS = {
|
||||||
|
REALTIME_BASIC: 60, // 1 minute
|
||||||
|
REALTIME_DETAILED: 300, // 5 minutes
|
||||||
|
BASIC_METRICS: 3600, // 1 hour
|
||||||
|
USER_BEHAVIOR: 3600 // 1 hour
|
||||||
|
};
|
||||||
|
|
||||||
|
async getBasicMetrics(startDate = '7daysAgo') {
|
||||||
|
const cacheKey = `analytics:basic_metrics:${startDate}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('Analytics metrics found in Redis cache');
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from GA4
|
||||||
|
console.log('Fetching fresh metrics data from GA4');
|
||||||
|
const [response] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'date' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'activeUsers' },
|
||||||
|
{ name: 'newUsers' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'conversions' }
|
||||||
|
],
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: this.CACHE_DURATIONS.BASIC_METRICS
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics metrics:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealTimeBasicData() {
|
||||||
|
const cacheKey = 'analytics:realtime:basic';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('Realtime basic data found in Redis cache');
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching fresh realtime data from GA4');
|
||||||
|
|
||||||
|
// Fetch active users
|
||||||
|
const [userResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch last 5 minutes
|
||||||
|
const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch time series data
|
||||||
|
const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'minutesAgo' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
userResponse,
|
||||||
|
fiveMinResponse,
|
||||||
|
timeSeriesResponse,
|
||||||
|
quotaInfo: {
|
||||||
|
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
|
||||||
|
daily: userResponse.propertyQuota.tokensPerDay,
|
||||||
|
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
|
||||||
|
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: this.CACHE_DURATIONS.REALTIME_BASIC
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching realtime basic data:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealTimeDetailedData() {
|
||||||
|
const cacheKey = 'analytics:realtime:detailed';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('Realtime detailed data found in Redis cache');
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching fresh realtime detailed data from GA4');
|
||||||
|
|
||||||
|
// Fetch current pages
|
||||||
|
const [pageResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'unifiedScreenName' }],
|
||||||
|
metrics: [{ name: 'screenPageViews' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch events
|
||||||
|
const [eventResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'eventName' }],
|
||||||
|
metrics: [{ name: 'eventCount' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch device categories
|
||||||
|
const [deviceResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
|
||||||
|
limit: 10,
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
pageResponse,
|
||||||
|
eventResponse,
|
||||||
|
sourceResponse: deviceResponse
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: this.CACHE_DURATIONS.REALTIME_DETAILED
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching realtime detailed data:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBehavior(timeRange = '30') {
|
||||||
|
const cacheKey = `analytics:user_behavior:${timeRange}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('User behavior data found in Redis cache');
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching fresh user behavior data from GA4');
|
||||||
|
|
||||||
|
// Fetch page data
|
||||||
|
const [pageResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'pagePath' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'sessions' }
|
||||||
|
],
|
||||||
|
orderBy: [{
|
||||||
|
metric: { metricName: 'screenPageViews' },
|
||||||
|
desc: true
|
||||||
|
}],
|
||||||
|
limit: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch device data
|
||||||
|
const [deviceResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'sessions' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch source data
|
||||||
|
const [sourceResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'sessionSource' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'sessions' },
|
||||||
|
{ name: 'conversions' }
|
||||||
|
],
|
||||||
|
orderBy: [{
|
||||||
|
metric: { metricName: 'sessions' },
|
||||||
|
desc: true
|
||||||
|
}],
|
||||||
|
limit: 25,
|
||||||
|
returnPropertyQuota: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
pageResponse,
|
||||||
|
deviceResponse,
|
||||||
|
sourceResponse
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the response
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(response), {
|
||||||
|
EX: this.CACHE_DURATIONS.USER_BEHAVIOR
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user behavior data:', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new AnalyticsService();
|
||||||
BIN
dashboard-server/google-server/utils/._logger.js
Normal file
BIN
dashboard-server/google-server/utils/._logger.js
Normal file
Binary file not shown.
35
dashboard-server/google-server/utils/logger.js
Normal file
35
dashboard-server/google-server/utils/logger.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(__dirname, '../logs/pm2/error.log'),
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 10485760, // 10MB
|
||||||
|
maxFiles: 5
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(__dirname, '../logs/pm2/combined.log'),
|
||||||
|
maxsize: 10485760, // 10MB
|
||||||
|
maxFiles: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add console transport in development
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -24,6 +24,9 @@ import SalesChart from "./components/dashboard/SalesChart";
|
|||||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||||
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
||||||
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
||||||
|
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
||||||
|
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||||
|
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||||
|
|
||||||
// Public layout
|
// Public layout
|
||||||
const PublicLayout = () => (
|
const PublicLayout = () => (
|
||||||
@@ -90,6 +93,9 @@ const DashboardLayout = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
</div>
|
</div>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<AnalyticsDashboard />
|
||||||
|
<UserBehaviorDashboard />
|
||||||
|
<RealtimeAnalytics />
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||||
<div className="xl:col-span-4 col-span-6">
|
<div className="xl:col-span-4 col-span-6">
|
||||||
|
|||||||
399
dashboard/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
399
dashboard/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const AnalyticsDashboard = () => {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [timeRange, setTimeRange] = useState("30");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/analytics/metrics?startDate=${timeRange}daysAgo`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch metrics");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result?.data?.rows) {
|
||||||
|
console.log("No result data received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedData = result.data.rows.map((row) => ({
|
||||||
|
date: formatGADate(row.dimensionValues[0].value),
|
||||||
|
activeUsers: parseInt(row.metricValues[0].value),
|
||||||
|
newUsers: parseInt(row.metricValues[1].value),
|
||||||
|
avgSessionDuration: parseFloat(row.metricValues[2].value),
|
||||||
|
pageViews: parseInt(row.metricValues[3].value),
|
||||||
|
bounceRate: parseFloat(row.metricValues[4].value) * 100,
|
||||||
|
conversions: parseInt(row.metricValues[5].value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortedData = processedData.sort((a, b) => a.date - b.date);
|
||||||
|
setData(sortedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch analytics:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
const formatGADate = (gaDate) => {
|
||||||
|
const year = gaDate.substring(0, 4);
|
||||||
|
const month = gaDate.substring(4, 6);
|
||||||
|
const day = gaDate.substring(6, 8);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [selectedMetrics, setSelectedMetrics] = useState({
|
||||||
|
activeUsers: true,
|
||||||
|
newUsers: true,
|
||||||
|
pageViews: true,
|
||||||
|
conversions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetricToggle = ({ label, checked, onChange }) => (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={label}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className="dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={label}
|
||||||
|
className="text-sm font-medium leading-none text-gray-900 dark:text-gray-200 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomLegend = ({ metrics, selectedMetrics }) => {
|
||||||
|
// Separate items for left and right axes
|
||||||
|
const leftAxisItems = Object.entries(metrics).filter(
|
||||||
|
([key, metric]) => metric.yAxis === "left" && selectedMetrics[key]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightAxisItems = Object.entries(metrics).filter(
|
||||||
|
([key, metric]) => metric.yAxis === "right" && selectedMetrics[key]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Left Axis
|
||||||
|
</h4>
|
||||||
|
{leftAxisItems.map(([key, metric]) => (
|
||||||
|
<div key={key} className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: metric.color }}
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
></div>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">
|
||||||
|
{metric.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Right Axis
|
||||||
|
</h4>
|
||||||
|
{rightAxisItems.map(([key, metric]) => (
|
||||||
|
<div key={key} className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
style={{ backgroundColor: metric.color }}
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
></div>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100">
|
||||||
|
{metric.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
activeUsers: {
|
||||||
|
label: "Active Users",
|
||||||
|
color: "#8b5cf6",
|
||||||
|
yAxis: "left"
|
||||||
|
},
|
||||||
|
newUsers: {
|
||||||
|
label: "New Users",
|
||||||
|
color: "#10b981",
|
||||||
|
yAxis: "left"
|
||||||
|
},
|
||||||
|
pageViews: {
|
||||||
|
label: "Page Views",
|
||||||
|
color: "#f59e0b",
|
||||||
|
yAxis: "right"
|
||||||
|
},
|
||||||
|
conversions: {
|
||||||
|
label: "Conversions",
|
||||||
|
color: "#3b82f6",
|
||||||
|
yAxis: "right"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSummary = () => {
|
||||||
|
if (!data.length) return null;
|
||||||
|
|
||||||
|
const totals = data.reduce(
|
||||||
|
(acc, day) => ({
|
||||||
|
activeUsers: acc.activeUsers + (Number(day.activeUsers) || 0),
|
||||||
|
newUsers: acc.newUsers + (Number(day.newUsers) || 0),
|
||||||
|
pageViews: acc.pageViews + (Number(day.pageViews) || 0),
|
||||||
|
conversions: acc.conversions + (Number(day.conversions) || 0),
|
||||||
|
avgSessionDuration:
|
||||||
|
acc.avgSessionDuration + (Number(day.avgSessionDuration) || 0),
|
||||||
|
bounceRate: acc.bounceRate + (Number(day.bounceRate) || 0),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
activeUsers: 0,
|
||||||
|
newUsers: 0,
|
||||||
|
pageViews: 0,
|
||||||
|
conversions: 0,
|
||||||
|
avgSessionDuration: 0,
|
||||||
|
bounceRate: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...totals,
|
||||||
|
avgSessionDuration: totals.avgSessionDuration / data.length,
|
||||||
|
bounceRate: totals.bounceRate / data.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = calculateSummary();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="h-96 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatXAxisDate = (date) => {
|
||||||
|
if (!(date instanceof Date)) return "";
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||||
|
<p className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||||
|
{label instanceof Date ? label.toLocaleDateString() : label}
|
||||||
|
</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||||
|
{`${entry.name}: ${Number(entry.value).toLocaleString()}`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Analytics Overview
|
||||||
|
</CardTitle>
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||||
|
<SelectValue placeholder="Select range" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Total Users
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{summary.activeUsers.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
New Users
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{summary.newUsers.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Page Views
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{summary.pageViews.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Conversions
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{summary.conversions.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[400px] mt-4">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-gray-200 dark:stroke-gray-700"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatXAxisDate}
|
||||||
|
className="text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
className="text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
className="text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend content={<CustomLegend metrics={metrics} selectedMetrics={selectedMetrics} />} />
|
||||||
|
{selectedMetrics.activeUsers && (
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="activeUsers"
|
||||||
|
name="Active Users"
|
||||||
|
stroke={metrics.activeUsers.color}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMetrics.newUsers && (
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="newUsers"
|
||||||
|
name="New Users"
|
||||||
|
stroke={metrics.newUsers.color}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMetrics.pageViews && (
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="pageViews"
|
||||||
|
name="Page Views"
|
||||||
|
stroke={metrics.pageViews.color}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMetrics.conversions && (
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="conversions"
|
||||||
|
name="Conversions"
|
||||||
|
stroke={metrics.conversions.color}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mt-4">
|
||||||
|
<MetricToggle
|
||||||
|
label="Active Users"
|
||||||
|
checked={selectedMetrics.activeUsers}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setSelectedMetrics((prev) => ({ ...prev, activeUsers: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricToggle
|
||||||
|
label="New Users"
|
||||||
|
checked={selectedMetrics.newUsers}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setSelectedMetrics((prev) => ({ ...prev, newUsers: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricToggle
|
||||||
|
label="Page Views"
|
||||||
|
checked={selectedMetrics.pageViews}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setSelectedMetrics((prev) => ({ ...prev, pageViews: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricToggle
|
||||||
|
label="Conversions"
|
||||||
|
checked={selectedMetrics.conversions}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setSelectedMetrics((prev) => ({ ...prev, conversions: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsDashboard;
|
||||||
494
dashboard/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
494
dashboard/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { Loader2, AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip as UITooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipProvider,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
const formatNumber = (value, decimalPlaces = 0) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: decimalPlaces,
|
||||||
|
maximumFractionDigits: decimalPlaces,
|
||||||
|
}).format(value || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value, decimalPlaces = 1) =>
|
||||||
|
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||||
|
|
||||||
|
const summaryCard = (label, sublabel, value, options = {}) => {
|
||||||
|
const {
|
||||||
|
isMonetary = false,
|
||||||
|
isPercentage = false,
|
||||||
|
decimalPlaces = 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let displayValue;
|
||||||
|
if (isMonetary) {
|
||||||
|
displayValue = formatCurrency(value, decimalPlaces);
|
||||||
|
} else if (isPercentage) {
|
||||||
|
displayValue = formatPercent(value, decimalPlaces);
|
||||||
|
} else {
|
||||||
|
displayValue = formatNumber(value, decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="text-lg md:text-sm lg:text-lg text-gray-900 dark:text-gray-100">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{sublabel}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuotaInfo = ({ tokenQuota }) => {
|
||||||
|
// Add early return if tokenQuota is null or undefined
|
||||||
|
if (!tokenQuota || typeof tokenQuota !== "object") return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
projectHourly = {},
|
||||||
|
daily = {},
|
||||||
|
serverErrors = {},
|
||||||
|
thresholdedRequests = {},
|
||||||
|
} = tokenQuota;
|
||||||
|
|
||||||
|
// Add null checks and default values for all properties
|
||||||
|
const {
|
||||||
|
remaining: projectHourlyRemaining = 0,
|
||||||
|
consumed: projectHourlyConsumed = 0,
|
||||||
|
} = projectHourly;
|
||||||
|
|
||||||
|
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
|
||||||
|
|
||||||
|
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
|
||||||
|
serverErrors;
|
||||||
|
|
||||||
|
const {
|
||||||
|
remaining: thresholdRemaining = 120,
|
||||||
|
consumed: thresholdConsumed = 0,
|
||||||
|
} = thresholdedRequests;
|
||||||
|
|
||||||
|
// Calculate percentages with safe math
|
||||||
|
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
|
||||||
|
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
|
||||||
|
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
|
||||||
|
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
|
||||||
|
|
||||||
|
// Determine color based on remaining percentage
|
||||||
|
const getStatusColor = (percentage) => {
|
||||||
|
const numericPercentage = parseFloat(percentage);
|
||||||
|
if (isNaN(numericPercentage) || numericPercentage < 20)
|
||||||
|
return "text-red-500 dark:text-red-400";
|
||||||
|
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
|
||||||
|
return "text-green-500 dark:text-green-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<UITooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
|
||||||
|
<span>Quota:</span>
|
||||||
|
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
|
||||||
|
{hourlyPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="bg-white dark:bg-gray-800 border dark:border-gray-700">
|
||||||
|
<div className="space-y-3 p-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Project Hourly
|
||||||
|
</div>
|
||||||
|
<div className={`${getStatusColor(hourlyPercentage)}`}>
|
||||||
|
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Daily
|
||||||
|
</div>
|
||||||
|
<div className={`${getStatusColor(dailyPercentage)}`}>
|
||||||
|
{dailyRemaining.toLocaleString()} / 200,000 remaining
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Server Errors
|
||||||
|
</div>
|
||||||
|
<div className={`${getStatusColor(errorPercentage)}`}>
|
||||||
|
{errorsConsumed} / 10 used this hour
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Thresholded Requests
|
||||||
|
</div>
|
||||||
|
<div className={`${getStatusColor(thresholdPercentage)}`}>
|
||||||
|
{thresholdConsumed} / 120 used this hour
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</UITooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RealtimeAnalytics = () => {
|
||||||
|
const [basicData, setBasicData] = useState({
|
||||||
|
last30MinUsers: 0,
|
||||||
|
last5MinUsers: 0,
|
||||||
|
byMinute: [],
|
||||||
|
tokenQuota: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [detailedData, setDetailedData] = useState({
|
||||||
|
currentPages: [],
|
||||||
|
sources: [],
|
||||||
|
recentEvents: [],
|
||||||
|
lastUpdated: null,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const processBasicData = (data) => {
|
||||||
|
const last30MinUsers = parseInt(
|
||||||
|
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||||
|
);
|
||||||
|
const last5MinUsers = parseInt(
|
||||||
|
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const byMinute = Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const matchingRow = data.timeSeriesResponse?.rows?.find(
|
||||||
|
(row) => parseInt(row.dimensionValues[0].value) === i
|
||||||
|
);
|
||||||
|
const users = matchingRow ? parseInt(matchingRow.metricValues[0].value) : 0;
|
||||||
|
const timestamp = new Date(Date.now() - i * 60000);
|
||||||
|
return {
|
||||||
|
minute: -i,
|
||||||
|
users,
|
||||||
|
timestamp: timestamp.toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}).reverse();
|
||||||
|
|
||||||
|
const tokenQuota = data.quotaInfo
|
||||||
|
? {
|
||||||
|
projectHourly: data.quotaInfo.projectHourly || {},
|
||||||
|
daily: data.quotaInfo.daily || {},
|
||||||
|
serverErrors: data.quotaInfo.serverErrors || {},
|
||||||
|
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
last30MinUsers,
|
||||||
|
last5MinUsers,
|
||||||
|
byMinute,
|
||||||
|
tokenQuota,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const processDetailedData = (data) => {
|
||||||
|
return {
|
||||||
|
currentPages:
|
||||||
|
data.pageResponse?.rows?.map((row) => ({
|
||||||
|
path: row.dimensionValues[0].value,
|
||||||
|
activeUsers: parseInt(row.metricValues[0].value),
|
||||||
|
})) || [],
|
||||||
|
|
||||||
|
sources:
|
||||||
|
data.sourceResponse?.rows?.map((row) => ({
|
||||||
|
source: row.dimensionValues[0].value,
|
||||||
|
activeUsers: parseInt(row.metricValues[0].value),
|
||||||
|
})) || [],
|
||||||
|
|
||||||
|
recentEvents:
|
||||||
|
data.eventResponse?.rows
|
||||||
|
?.filter(
|
||||||
|
(row) =>
|
||||||
|
!["session_start", "(other)"].includes(row.dimensionValues[0].value)
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
event: row.dimensionValues[0].value,
|
||||||
|
count: parseInt(row.metricValues[0].value),
|
||||||
|
})) || [],
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let basicInterval;
|
||||||
|
let detailedInterval;
|
||||||
|
|
||||||
|
const fetchBasicData = async () => {
|
||||||
|
if (isPaused) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/analytics/realtime/basic", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch basic realtime data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const processed = processBasicData(result.data);
|
||||||
|
setBasicData(processed);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error details:", {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
response: error.response,
|
||||||
|
});
|
||||||
|
if (error.message === "QUOTA_EXCEEDED") {
|
||||||
|
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||||
|
setIsPaused(true);
|
||||||
|
} else {
|
||||||
|
setError("Failed to fetch analytics data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDetailedData = async () => {
|
||||||
|
if (isPaused) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/analytics/realtime/detailed", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch detailed realtime data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const processed = processDetailedData(result.data);
|
||||||
|
setDetailedData(processed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch detailed realtime data:", error);
|
||||||
|
if (error.message === "QUOTA_EXCEEDED") {
|
||||||
|
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||||
|
setIsPaused(true);
|
||||||
|
} else {
|
||||||
|
setError("Failed to fetch analytics data");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial fetches
|
||||||
|
fetchBasicData();
|
||||||
|
fetchDetailedData();
|
||||||
|
|
||||||
|
// Set up intervals
|
||||||
|
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
|
||||||
|
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(basicInterval);
|
||||||
|
clearInterval(detailedInterval);
|
||||||
|
};
|
||||||
|
}, [isPaused]);
|
||||||
|
|
||||||
|
const togglePause = () => {
|
||||||
|
setIsPaused(!isPaused);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !basicData && !detailedData) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="h-96 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Real-Time Analytics
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Last updated: {format(new Date(basicData.lastUpdated), "HH:mm:ss")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<QuotaInfo tokenQuota={basicData.tokenQuota} />
|
||||||
|
<Button
|
||||||
|
variant={isPaused ? "default" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
onClick={togglePause}
|
||||||
|
>
|
||||||
|
{isPaused ? "Resume" : "Pause"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
|
{summaryCard(
|
||||||
|
"Active Users",
|
||||||
|
"Last 30 minutes",
|
||||||
|
basicData.last30MinUsers
|
||||||
|
)}
|
||||||
|
{summaryCard(
|
||||||
|
"Recent Activity",
|
||||||
|
"Last 5 minutes",
|
||||||
|
basicData.last5MinUsers
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="activity" className="w-full">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
|
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
||||||
|
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="activity">
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={basicData.byMinute}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="minute"
|
||||||
|
tickFormatter={(value) => value + "m ago"}
|
||||||
|
className="text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
<YAxis className="text-gray-600 dark:text-gray-300" />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{payload[0].payload.minute}m ago
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{payload[0].value} active users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="users" fill="#8b5cf6" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="pages">
|
||||||
|
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="dark:border-gray-800">
|
||||||
|
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||||
|
Page
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Active Users
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailedData.currentPages.map((page, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{page.path}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{page.activeUsers}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sources">
|
||||||
|
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="dark:border-gray-800">
|
||||||
|
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||||
|
Source
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Active Users
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailedData.sources.map((source, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{source.source}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{source.activeUsers}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealtimeAnalytics;
|
||||||
349
dashboard/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
349
dashboard/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const UserBehaviorDashboard = () => {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [timeRange, setTimeRange] = useState("30");
|
||||||
|
|
||||||
|
const processPageData = (data) => {
|
||||||
|
if (!data?.rows) {
|
||||||
|
console.log("No rows in page data");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.rows.map((row) => ({
|
||||||
|
path: row.dimensionValues[0].value || "Unknown",
|
||||||
|
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||||
|
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
|
||||||
|
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
|
||||||
|
engagedSessions: parseInt(row.metricValues[3].value || 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const processDeviceData = (data) => {
|
||||||
|
if (!data?.rows) {
|
||||||
|
console.log("No rows in device data");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.rows
|
||||||
|
.filter((row) => {
|
||||||
|
const device = (row.dimensionValues[0].value || "").toLowerCase();
|
||||||
|
return ["desktop", "mobile", "tablet"].includes(device);
|
||||||
|
})
|
||||||
|
.map((row) => {
|
||||||
|
const device = row.dimensionValues[0].value || "Unknown";
|
||||||
|
return {
|
||||||
|
device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
|
||||||
|
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||||
|
sessions: parseInt(row.metricValues[1].value || 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.pageViews - a.pageViews);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processSourceData = (data) => {
|
||||||
|
if (!data?.rows) {
|
||||||
|
console.log("No rows in source data");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.rows.map((row) => ({
|
||||||
|
source: row.dimensionValues[0].value || "Unknown",
|
||||||
|
sessions: parseInt(row.metricValues[0].value || 0),
|
||||||
|
conversions: parseInt(row.metricValues[1].value || 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/analytics/user-behavior?timeRange=${timeRange}`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch user behavior");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Raw user behavior response:", result);
|
||||||
|
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error("Invalid response structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle both data structures
|
||||||
|
const rawData = result.data?.data || result.data;
|
||||||
|
|
||||||
|
// Try to access the data differently based on the structure
|
||||||
|
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
|
||||||
|
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
|
||||||
|
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
|
||||||
|
|
||||||
|
console.log("Extracted responses:", {
|
||||||
|
pageResponse,
|
||||||
|
deviceResponse,
|
||||||
|
sourceResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processed = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pageData: {
|
||||||
|
pageData: processPageData(pageResponse),
|
||||||
|
deviceData: processDeviceData(deviceResponse),
|
||||||
|
},
|
||||||
|
sourceData: processSourceData(sourceResponse),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Final processed data:", processed);
|
||||||
|
setData(processed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch behavior data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardContent className="h-96 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
desktop: "#8b5cf6", // Purple
|
||||||
|
mobile: "#10b981", // Green
|
||||||
|
tablet: "#f59e0b", // Yellow
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceData = data?.data?.pageData?.deviceData || [];
|
||||||
|
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
|
||||||
|
const totalSessions = deviceData.reduce(
|
||||||
|
(sum, item) => sum + item.sessions,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
|
||||||
|
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{data.device}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{data.pageViews.toLocaleString()} views ({percentage}%)
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white dark:bg-gray-900">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
User Behavior Analysis
|
||||||
|
</CardTitle>
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||||
|
<SelectValue>
|
||||||
|
{timeRange === "7" && "Last 7 days"}
|
||||||
|
{timeRange === "14" && "Last 14 days"}
|
||||||
|
{timeRange === "30" && "Last 30 days"}
|
||||||
|
{timeRange === "90" && "Last 90 days"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="pages" className="w-full">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="pages">Top Pages</TabsTrigger>
|
||||||
|
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||||
|
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="pages"
|
||||||
|
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="dark:border-gray-800">
|
||||||
|
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||||
|
Page Path
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Views
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Bounce Rate
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Avg. Duration
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data?.pageData?.pageData.map((page, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{page.path}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{page.pageViews.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{page.bounceRate.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{formatDuration(page.avgSessionDuration)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="sources"
|
||||||
|
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="dark:border-gray-800">
|
||||||
|
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||||
|
Source
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Sessions
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Conversions
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||||
|
Conv. Rate
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data?.sourceData?.map((source, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{source.source}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{source.sessions.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{source.conversions.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{((source.conversions / source.sessions) * 100).toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="devices"
|
||||||
|
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||||
|
>
|
||||||
|
<div className="h-60">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={deviceData}
|
||||||
|
dataKey="pageViews"
|
||||||
|
nameKey="device"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} ${(percent * 100).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deviceData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[entry.device.toLowerCase()]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserBehaviorDashboard;
|
||||||
28
nginx.conf
28
nginx.conf
@@ -25,3 +25,31 @@ location /api/gorgias/ {
|
|||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Google Analytics API endpoints
|
||||||
|
location /api/analytics/ {
|
||||||
|
proxy_pass http://localhost:3007/api/analytics/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# CORS headers
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
|
||||||
|
# Handle OPTIONS method
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user