import { Card } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Skeleton } from "@/components/ui/skeleton"; import type { HistogramBucket, LogsHistogramResponse } from "@/lib/types/logs"; import { ChevronDown, RotateCcw } from "lucide-react"; import { Component, type ErrorInfo, type ReactNode, useCallback, useMemo, useState, } from "react"; import { Bar, BarChart, CartesianGrid, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; const requestFormatter = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1, }); function formatRequest(requests: number): string { return requestFormatter.format(requests); } // Empty chart placeholder when data fails to render function EmptyChart() { return ( ); } // Error boundary to catch Recharts rendering errors class ChartErrorBoundary extends Component< { children: ReactNode; resetKey?: string }, { hasError: boolean } > { constructor(props: { children: ReactNode; resetKey?: string }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(_: Error) { return { hasError: true }; } static getDerivedStateFromProps( props: { resetKey?: string }, state: { hasError: boolean; prevResetKey?: string }, ) { // Reset error state when resetKey changes if (props.resetKey !== state.prevResetKey) { return { hasError: false, prevResetKey: props.resetKey }; } return null; } componentDidCatch(error: Error, _errorInfo: ErrorInfo) { console.warn("Chart rendering error:", error.message); } render() { if (this.state.hasError) { return ; } return this.props.children; } } interface LogsVolumeChartProps { data: LogsHistogramResponse | null; loading?: boolean; onTimeRangeChange: (startTime: number, endTime: number) => void; onResetZoom?: () => void; isZoomed?: boolean; startTime: number; // Unix timestamp in seconds endTime: number; // Unix timestamp in seconds isOpen: boolean; onOpenChange: (open: boolean) => void; } // Format timestamp based on bucket size function formatTimestamp(timestamp: string, bucketSizeSeconds: number): string { const date = new Date(timestamp); if (bucketSizeSeconds >= 86400) { // Daily buckets: "Jan 20" return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } else if (bucketSizeSeconds >= 3600) { // Hourly buckets: "10:00" return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); } else { // Sub-hourly: "10:15" return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }); } } // Format full timestamp for tooltip function formatFullTimestamp(timestamp: string): string { const date = new Date(timestamp); return date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, }); } type LogVolumeDataPoint = HistogramBucket & { formattedTime: string; index?: number; }; interface CustomTooltipProps { active?: boolean; payload?: Array<{ payload?: LogVolumeDataPoint }>; } type ChartMouseEvent = { activeTooltipIndex?: number | string | null }; // Custom tooltip component function CustomTooltip({ active, payload }: CustomTooltipProps) { if (!active || !payload || !payload.length) return null; const data = payload[0]?.payload; if (!data) return null; return (
{formatFullTimestamp(data.timestamp)}
Total {data.count.toLocaleString()}
Success {data.success.toLocaleString()}
Error {data.error.toLocaleString()}
); } export function LogsVolumeChart({ data, loading, onTimeRangeChange, onResetZoom, isZoomed, startTime, endTime, isOpen, onOpenChange, }: LogsVolumeChartProps) { // State for drag selection const [refAreaLeft, setRefAreaLeft] = useState(null); const [refAreaRight, setRefAreaRight] = useState(null); const [isSelecting, setIsSelecting] = useState(false); // Transform data for chart, filling in empty buckets for the full time range const chartData = useMemo(() => { // Need bucket_size_seconds and valid time range if ( !data?.bucket_size_seconds || !startTime || !endTime || startTime >= endTime ) { return []; } const bucketSizeMs = data.bucket_size_seconds * 1000; // Align start time to bucket boundary const minTime = Math.floor((startTime * 1000) / bucketSizeMs) * bucketSizeMs; const maxTime = endTime * 1000; // Safety: limit maximum number of buckets to prevent performance issues const maxBuckets = 500; const estimatedBuckets = Math.ceil((maxTime - minTime) / bucketSizeMs); if (estimatedBuckets > maxBuckets) { // If too many buckets, just return the original data without filling const result = (data.buckets || []).map((bucket, index) => ({ ...bucket, index, formattedTime: formatTimestamp( bucket.timestamp, data.bucket_size_seconds, ), })); // Ensure at least 2 data points for Recharts if (result.length === 1) { const nextTimestamp = new Date( new Date(result[0].timestamp).getTime() + bucketSizeMs, ).toISOString(); result.push({ timestamp: nextTimestamp, count: 0, success: 0, error: 0, index: 1, formattedTime: formatTimestamp( nextTimestamp, data.bucket_size_seconds, ), }); } return result; } // First, create all empty buckets for the time range const filledBuckets: Array< HistogramBucket & { formattedTime: string; index: number } > = []; for ( let time = minTime, idx = 0; time < maxTime; time += bucketSizeMs, idx++ ) { const timestamp = new Date(time).toISOString(); filledBuckets.push({ timestamp, count: 0, success: 0, error: 0, index: idx, formattedTime: formatTimestamp(timestamp, data.bucket_size_seconds), }); } // Then, place API buckets at their correct positions using index calculation // This is more robust than exact timestamp matching for (const bucket of data.buckets || []) { const bucketTime = new Date(bucket.timestamp).getTime(); // Calculate the index for this bucket based on its offset from minTime const bucketIndex = Math.round((bucketTime - minTime) / bucketSizeMs); if (bucketIndex >= 0 && bucketIndex < filledBuckets.length) { filledBuckets[bucketIndex] = { ...bucket, index: bucketIndex, formattedTime: formatTimestamp( bucket.timestamp, data.bucket_size_seconds, ), }; } } // Ensure at least 2 data points for Recharts if (filledBuckets.length === 1) { const nextTimestamp = new Date( new Date(filledBuckets[0].timestamp).getTime() + bucketSizeMs, ).toISOString(); filledBuckets.push({ timestamp: nextTimestamp, count: 0, success: 0, error: 0, index: 1, formattedTime: formatTimestamp(nextTimestamp, data.bucket_size_seconds), }); } return filledBuckets; }, [data, startTime, endTime]); // Handle mouse down on chart (start selection) const handleMouseDown = useCallback((e: ChartMouseEvent) => { if (typeof e?.activeTooltipIndex === "number") { setRefAreaLeft(e.activeTooltipIndex); setIsSelecting(true); } }, []); // Handle mouse move on chart (during selection) const handleMouseMove = useCallback( (e: ChartMouseEvent) => { if (isSelecting && typeof e?.activeTooltipIndex === "number") { setRefAreaRight(e.activeTooltipIndex); } }, [isSelecting], ); // Handle mouse up on chart (end selection) const handleMouseUp = useCallback(() => { if ( refAreaLeft === null || refAreaRight === null || !data?.bucket_size_seconds || chartData.length === 0 ) { setRefAreaLeft(null); setRefAreaRight(null); setIsSelecting(false); return; } // Get the buckets by index const leftBucket = chartData[refAreaLeft]; const rightBucket = chartData[refAreaRight]; if (leftBucket && rightBucket) { const leftTime = new Date(leftBucket.timestamp).getTime() / 1000; const rightTime = new Date(rightBucket.timestamp).getTime() / 1000; // Ensure left < right; the end edge is one bucket past the later timestamp const selectionStart = Math.min(leftTime, rightTime); const selectionEnd = Math.max(leftTime, rightTime) + data.bucket_size_seconds; // Only trigger if selection spans at least one bucket if (selectionEnd - selectionStart >= data.bucket_size_seconds) { onTimeRangeChange(selectionStart, selectionEnd); } } setRefAreaLeft(null); setRefAreaRight(null); setIsSelecting(false); }, [refAreaLeft, refAreaRight, data, chartData, onTimeRangeChange]); // Handle click on a bar (zoom into that bucket) const handleBarClick = useCallback( (barData: LogVolumeDataPoint | undefined) => { if (!data || !barData?.timestamp) return; const startTime = new Date(barData.timestamp).getTime() / 1000; const endTime = startTime + data.bucket_size_seconds; onTimeRangeChange(startTime, endTime); }, [data, onTimeRangeChange], ); // Check if we have valid data for the chart const hasValidData = data && startTime && endTime && chartData.length >= 2; return (
Request Volume
{isOpen && (
Success Error
)} {isZoomed && onResetZoom && ( )}
{loading ? ( ) : hasValidData ? ( chartData[Math.round(idx)]?.formattedTime || "" } interval="preserveStartEnd" /> formatRequest(v)} domain={[0, (dataMax: number) => Math.max(dataMax, 5)]} allowDataOverflow={false} /> } cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }} /> handleBarClick(data?.payload as LogVolumeDataPoint | undefined)} /> handleBarClick(data?.payload as LogVolumeDataPoint | undefined)} /> {refAreaLeft !== null && refAreaRight !== null && chartData[refAreaLeft] && chartData[refAreaRight] && ( )} ) : ( )}
); }