Files
inventory/inventory/src/components/dashboard/RealtimeAnalytics.jsx
2026-01-18 21:39:27 -05:00

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;