504 lines
14 KiB
JavaScript
504 lines
14 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
import {
|
|
Tooltip as UITooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
TooltipProvider,
|
|
} from "@/components/ui/tooltip";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { format } from "date-fns";
|
|
|
|
// Import shared components and tokens
|
|
import {
|
|
DashboardChartTooltip,
|
|
DashboardSectionHeader,
|
|
DashboardStatCard,
|
|
DashboardTable,
|
|
StatCardSkeleton,
|
|
ChartSkeleton,
|
|
TableSkeleton,
|
|
DashboardErrorState,
|
|
CARD_STYLES,
|
|
TYPOGRAPHY,
|
|
METRIC_COLORS,
|
|
} from "@/components/dashboard/shared";
|
|
|
|
// Realtime-specific colors using the standardized palette
|
|
const REALTIME_COLORS = {
|
|
activeUsers: {
|
|
color: METRIC_COLORS.aov, // Purple
|
|
className: "text-chart-aov",
|
|
},
|
|
pages: {
|
|
color: METRIC_COLORS.revenue, // Emerald
|
|
className: "text-chart-revenue",
|
|
},
|
|
sources: {
|
|
color: METRIC_COLORS.comparison, // Amber
|
|
className: "text-chart-comparison",
|
|
},
|
|
};
|
|
|
|
// Export for backwards compatibility
|
|
export { REALTIME_COLORS as METRIC_COLORS };
|
|
|
|
export const SkeletonSummaryCard = () => (
|
|
<StatCardSkeleton size="default" hasIcon={false} hasSubtitle />
|
|
);
|
|
|
|
export const SkeletonBarChart = () => (
|
|
<ChartSkeleton type="bar" height="sm" withCard={false} />
|
|
);
|
|
|
|
export const SkeletonTable = () => (
|
|
<TableSkeleton rows={8} columns={2} scrollable maxHeight="sm" />
|
|
);
|
|
|
|
export 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(),
|
|
};
|
|
};
|
|
|
|
export const QuotaInfo = ({ tokenQuota }) => {
|
|
if (!tokenQuota || typeof tokenQuota !== "object") return null;
|
|
|
|
const {
|
|
projectHourly = {},
|
|
daily = {},
|
|
serverErrors = {},
|
|
thresholdedRequests = {},
|
|
} = tokenQuota;
|
|
|
|
const {
|
|
remaining: projectHourlyRemaining = 0,
|
|
} = projectHourly;
|
|
|
|
const { remaining: dailyRemaining = 0 } = daily;
|
|
|
|
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
|
|
serverErrors;
|
|
|
|
const {
|
|
remaining: thresholdRemaining = 120,
|
|
consumed: thresholdConsumed = 0,
|
|
} = thresholdedRequests;
|
|
|
|
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);
|
|
|
|
const getStatusColor = (percentage) => {
|
|
const numericPercentage = parseFloat(percentage);
|
|
if (isNaN(numericPercentage) || numericPercentage < 20)
|
|
return "text-trend-negative";
|
|
if (numericPercentage < 40) return "text-chart-comparison";
|
|
return "text-trend-positive";
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center font-semibold rounded-md space-x-1">
|
|
<span>Quota:</span>
|
|
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
|
|
{hourlyPercentage}%
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-3 mt-2">
|
|
<div>
|
|
<div className="font-semibold text-foreground">
|
|
Project Hourly
|
|
</div>
|
|
<div className={`${getStatusColor(hourlyPercentage)}`}>
|
|
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-foreground">
|
|
Daily
|
|
</div>
|
|
<div className={`${getStatusColor(dailyPercentage)}`}>
|
|
{dailyRemaining.toLocaleString()} / 200,000 remaining
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-foreground">
|
|
Server Errors
|
|
</div>
|
|
<div className={`${getStatusColor(errorPercentage)}`}>
|
|
{errorsConsumed} / 10 used this hour
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-foreground">
|
|
Thresholded Requests
|
|
</div>
|
|
<div className={`${getStatusColor(thresholdPercentage)}`}>
|
|
{thresholdConsumed} / 120 used this hour
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Custom tooltip for the realtime chart
|
|
const RealtimeTooltip = ({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
const timestamp = new Date(
|
|
Date.now() + payload[0].payload.minute * 60000
|
|
);
|
|
return (
|
|
<DashboardChartTooltip
|
|
active={active}
|
|
payload={[{
|
|
name: "Active Users",
|
|
value: payload[0].value,
|
|
color: REALTIME_COLORS.activeUsers.color,
|
|
}]}
|
|
label={format(timestamp, "h:mm a")}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
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 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/dashboard-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/dashboard-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]);
|
|
|
|
// Column definitions for pages table
|
|
const pagesColumns = [
|
|
{
|
|
key: "path",
|
|
header: "Page",
|
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
|
},
|
|
{
|
|
key: "activeUsers",
|
|
header: "Active Users",
|
|
align: "right",
|
|
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
|
|
},
|
|
];
|
|
|
|
// Column definitions for sources table
|
|
const sourcesColumns = [
|
|
{
|
|
key: "source",
|
|
header: "Source",
|
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
|
},
|
|
{
|
|
key: "activeUsers",
|
|
header: "Active Users",
|
|
align: "right",
|
|
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
|
|
},
|
|
];
|
|
|
|
if (loading && !basicData && !detailedData) {
|
|
return (
|
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
|
<DashboardSectionHeader title="Real-Time Analytics" className="pb-2" />
|
|
|
|
<CardContent className="p-6 pt-0">
|
|
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
|
|
<SkeletonSummaryCard />
|
|
<SkeletonSummaryCard />
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex gap-2">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="h-8 w-20 bg-muted animate-pulse rounded-md" />
|
|
))}
|
|
</div>
|
|
<SkeletonBarChart />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
|
<DashboardSectionHeader
|
|
title="Real-Time Analytics"
|
|
className="pb-2"
|
|
actions={
|
|
<TooltipProvider>
|
|
<UITooltip>
|
|
<TooltipTrigger>
|
|
<div className={TYPOGRAPHY.label}>
|
|
Last updated:{" "}
|
|
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="p-3">
|
|
<QuotaInfo tokenQuota={basicData.tokenQuota} />
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</TooltipProvider>
|
|
}
|
|
/>
|
|
|
|
<CardContent className="p-6 pt-0">
|
|
{error && (
|
|
<DashboardErrorState error={error} className="mx-0 mb-4" />
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4 mt-1 mb-3 dashboard-stagger">
|
|
<DashboardStatCard
|
|
title="Last 30 minutes"
|
|
subtitle="Active users"
|
|
value={basicData.last30MinUsers}
|
|
size="large"
|
|
/>
|
|
<DashboardStatCard
|
|
title="Last 5 minutes"
|
|
subtitle="Active users"
|
|
value={basicData.last5MinUsers}
|
|
size="large"
|
|
/>
|
|
</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-[235px] rounded-lg">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={basicData.byMinute}
|
|
margin={{ top: 5, right: 5, left: -35, bottom: -5 }}
|
|
>
|
|
<XAxis
|
|
dataKey="minute"
|
|
tickFormatter={(value) => value + "m"}
|
|
className="text-xs fill-muted-foreground"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
className="text-xs fill-muted-foreground"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<RealtimeTooltip />} />
|
|
<Bar
|
|
dataKey="users"
|
|
fill={REALTIME_COLORS.activeUsers.color}
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="pages">
|
|
<div className="h-[230px]">
|
|
<DashboardTable
|
|
columns={pagesColumns}
|
|
data={detailedData.currentPages}
|
|
loading={loading}
|
|
getRowKey={(page, index) => `${page.path}-${index}`}
|
|
maxHeight="sm"
|
|
compact
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="sources">
|
|
<div className="h-[230px]">
|
|
<DashboardTable
|
|
columns={sourcesColumns}
|
|
data={detailedData.sources}
|
|
loading={loading}
|
|
getRowKey={(source, index) => `${source.source}-${index}`}
|
|
maxHeight="sm"
|
|
compact
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default RealtimeAnalytics;
|