Files
dashboard/examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx

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;