530 lines
19 KiB
JavaScript
530 lines
19 KiB
JavaScript
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 { googleAnalyticsService } from "../../services/googleAnalyticsService";
|
|
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>
|
|
);
|
|
};
|
|
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);
|
|
|
|
useEffect(() => {
|
|
let basicInterval;
|
|
let detailedInterval;
|
|
|
|
const fetchBasicData = async () => {
|
|
if (isPaused) return;
|
|
try {
|
|
const response = await fetch("/api/analytics/realtime/basic", {
|
|
credentials: "include",
|
|
});
|
|
const result = await response.json();
|
|
|
|
const processed = await googleAnalyticsService.getRealTimeBasicData();
|
|
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 result = await googleAnalyticsService.getRealTimeDetailedData();
|
|
setDetailedData(result);
|
|
} 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);
|
|
};
|
|
}, []);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Pie chart colors
|
|
const COLORS = [
|
|
"#8b5cf6",
|
|
"#10b981",
|
|
"#f59e0b",
|
|
"#3b82f6",
|
|
"#0088FE",
|
|
"#00C49F",
|
|
"#FFBB28",
|
|
];
|
|
|
|
// Handle 'other' in data
|
|
const totalUsers = detailedData.sources.reduce(
|
|
(sum, source) => sum + source.users,
|
|
0
|
|
);
|
|
const sourcesData = detailedData.sources.map((source) => {
|
|
const percent = (source.users / totalUsers) * 100;
|
|
return { ...source, percent };
|
|
});
|
|
|
|
return (
|
|
<Card className="bg-white dark:bg-gray-900">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
|
Realtime Analytics
|
|
</CardTitle>
|
|
<div className="flex items-center space-x-4">
|
|
{basicData?.data?.quotaInfo && (
|
|
<QuotaInfo tokenQuota={basicData.data.quotaInfo} />
|
|
)}
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
Last updated:{" "}
|
|
{basicData.lastUpdated
|
|
? format(new Date(basicData.lastUpdated), "p")
|
|
: "N/A"}
|
|
</div>
|
|
</div>{" "}
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="destructive" className="mt-4">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
{[
|
|
{
|
|
label: "Last 30 Minutes",
|
|
sublabel: "Active Users",
|
|
value: basicData.last30MinUsers,
|
|
},
|
|
{
|
|
label: "Last 5 Minutes",
|
|
sublabel: "Active Users",
|
|
value: basicData.last5MinUsers,
|
|
},
|
|
].map((card) => (
|
|
<div key={card.label}>
|
|
{summaryCard(card.label, card.sublabel, card.value)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-4">
|
|
{/* User Activity Chart */}
|
|
<div className="mb-6">
|
|
<Card className="bg-white dark:bg-gray-900">
|
|
<CardHeader className="pb-1">
|
|
<CardTitle className="text-md font-bold">
|
|
Active Users Per Minute
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={basicData.byMinute}
|
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
|
>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
|
|
<p className="text-gray-900 dark:text-gray-100">{`${payload[0].value} active users`}</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{payload[0].payload.timestamp}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>{" "}
|
|
<Bar dataKey="users" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tabs for Detailed Data */}
|
|
<Tabs defaultValue="pages" className="w-full">
|
|
<TabsList className="p-0 sm:p-1 md:p-0 lg:p-1 -ml-4 sm:ml-0 md:-ml-4 lg:ml-0">
|
|
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
|
<TabsTrigger value="events">Recent Events</TabsTrigger>
|
|
<TabsTrigger value="sources">Active Devices</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Current Pages Tab */}
|
|
<TabsContent
|
|
value="pages"
|
|
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
|
|
>
|
|
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
|
|
Last updated:{" "}
|
|
{detailedData.lastUpdated
|
|
? format(new Date(detailedData.lastUpdated), "p")
|
|
: "N/A"}
|
|
</div>
|
|
<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">
|
|
Views
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{detailedData.currentPages.map(({ page, views }, index) => (
|
|
<TableRow key={index} className="dark:border-gray-800">
|
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
|
{page}
|
|
</TableCell>
|
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
|
{formatNumber(views)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TabsContent>
|
|
{/* Recent Events Tab */}
|
|
<TabsContent
|
|
value="events"
|
|
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
|
|
>
|
|
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
|
|
Last updated:{" "}
|
|
{detailedData.lastUpdated
|
|
? format(new Date(detailedData.lastUpdated), "p")
|
|
: "N/A"}
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="dark:border-gray-800">
|
|
<TableHead className="text-gray-900 dark:text-gray-100">
|
|
Event
|
|
</TableHead>
|
|
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
|
Count
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{detailedData.recentEvents.map(({ event, count }, index) => (
|
|
<TableRow key={index} className="dark:border-gray-800">
|
|
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
|
{event}
|
|
</TableCell>
|
|
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
|
{formatNumber(count)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TabsContent>
|
|
{/* Active Devices Tab */}
|
|
<TabsContent
|
|
value="sources"
|
|
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
|
|
>
|
|
{" "}
|
|
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
|
|
Last updated:{" "}
|
|
{detailedData.lastUpdated
|
|
? format(new Date(detailedData.lastUpdated), "p")
|
|
: "N/A"}
|
|
</div>
|
|
<div className="h-60">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={sourcesData}
|
|
dataKey="users"
|
|
nameKey="device"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={80}
|
|
labelLine={false}
|
|
label={({ device, percent }) =>
|
|
`${device}: ${percent.toFixed(0)}%`
|
|
}
|
|
>
|
|
{sourcesData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={COLORS[index % COLORS.length]}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
|
|
<p className="text-gray-900 dark:text-gray-100">
|
|
{payload[0].payload.device}
|
|
</p>
|
|
<p className="text-gray-600 dark:text-gray-300">{`${formatNumber(
|
|
payload[0].value
|
|
)} users`}</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
</PieChart>{" "}
|
|
</ResponsiveContainer>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-2 gap-4">
|
|
{sourcesData.map((source, index) => (
|
|
<div
|
|
key={index}
|
|
className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
|
|
>
|
|
<div className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
{source.device}
|
|
</div>
|
|
<div className="mt-1">
|
|
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
|
{formatNumber(source.users)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{source.percent.toFixed(0)}% of users
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default RealtimeAnalytics;
|