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] && (
)}
) : (
)}
);
}