541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
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 (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={[
|
|
{ name: "", value: 0 },
|
|
{ name: " ", value: 0 },
|
|
]}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
vertical={false}
|
|
className="stroke-zinc-200 dark:stroke-zinc-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fontSize: 13, className: "fill-zinc-500", dy: 5 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 13, className: "fill-zinc-500" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={40}
|
|
domain={[0, 1]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
// 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 <EmptyChart />;
|
|
}
|
|
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 (
|
|
<div className="rounded-sm border border-zinc-200 bg-white px-3 py-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
|
<div className="mb-1 text-xs text-zinc-500">
|
|
{formatFullTimestamp(data.timestamp)}
|
|
</div>
|
|
<div className="space-y-1 text-sm">
|
|
<div className="mt-2 flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
|
<span className="text-zinc-600 dark:text-zinc-400">Total</span>
|
|
</span>
|
|
<span className="font-medium">{data.count.toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
|
<span className="text-zinc-600 dark:text-zinc-400">Success</span>
|
|
</span>
|
|
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
|
{data.success.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-red-500" />
|
|
<span className="text-zinc-600 dark:text-zinc-400">Error</span>
|
|
</span>
|
|
<span className="font-medium text-red-600 dark:text-red-400">
|
|
{data.error.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LogsVolumeChart({
|
|
data,
|
|
loading,
|
|
onTimeRangeChange,
|
|
onResetZoom,
|
|
isZoomed,
|
|
startTime,
|
|
endTime,
|
|
isOpen,
|
|
onOpenChange,
|
|
}: LogsVolumeChartProps) {
|
|
// State for drag selection
|
|
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
|
const [refAreaRight, setRefAreaRight] = useState<number | null>(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 (
|
|
<Card className="rounded-sm px-2 py-2 shadow-none">
|
|
<Collapsible open={isOpen} onOpenChange={onOpenChange}>
|
|
<div className="flex items-center justify-between">
|
|
<CollapsibleTrigger
|
|
data-testid="logs-volume-chart-trigger"
|
|
className="flex items-center gap-2 hover:opacity-80"
|
|
>
|
|
<ChevronDown
|
|
className={`text-muted-foreground h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
|
/>
|
|
<span className="text-muted-foreground text-sm font-medium">
|
|
Request Volume
|
|
</span>
|
|
</CollapsibleTrigger>
|
|
<div className="mr-2 flex items-center gap-4">
|
|
{isOpen && (
|
|
<div className="flex items-center gap-3 text-xs">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
|
<span className="text-muted-foreground">Success</span>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="h-2 w-2 rounded-full bg-red-500" />
|
|
<span className="text-muted-foreground">Error</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
{isZoomed && onResetZoom && (
|
|
<button
|
|
data-testid="logs-volume-chart-reset-zoom"
|
|
onClick={onResetZoom}
|
|
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors"
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Reset zoom
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<CollapsibleContent className="data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down overflow-hidden">
|
|
<div className="mt-2 h-32 select-none">
|
|
{loading ? (
|
|
<Skeleton className="h-full w-full" />
|
|
) : hasValidData ? (
|
|
<ChartErrorBoundary
|
|
resetKey={`${startTime}-${endTime}-${chartData.length}`}
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={chartData}
|
|
margin={{ top: 6, right: 4, left: 12, bottom: 0 }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
barCategoryGap={1}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
vertical={false}
|
|
className="stroke-zinc-200 dark:stroke-zinc-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="index"
|
|
type="number"
|
|
domain={[-0.5, chartData.length - 0.5]}
|
|
tick={{ fontSize: 11, className: "fill-zinc-500", dy: 5 }}
|
|
tickLine={true}
|
|
axisLine={false}
|
|
tickFormatter={(idx) =>
|
|
chartData[Math.round(idx)]?.formattedTime || ""
|
|
}
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11, className: "fill-zinc-500" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={40}
|
|
tickFormatter={(v) => formatRequest(v)}
|
|
domain={[0, (dataMax: number) => Math.max(dataMax, 5)]}
|
|
allowDataOverflow={false}
|
|
/>
|
|
<Tooltip
|
|
content={<CustomTooltip />}
|
|
cursor={{ fill: "#8c8c8f", fillOpacity: 0.15 }}
|
|
/>
|
|
<Bar
|
|
dataKey="success"
|
|
stackId="requests"
|
|
barSize={30}
|
|
fill="#10b981"
|
|
fillOpacity={0.7}
|
|
radius={[0, 0, 0, 0]}
|
|
cursor="pointer"
|
|
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
|
/>
|
|
<Bar
|
|
dataKey="error"
|
|
stackId="requests"
|
|
fill="#ef4444"
|
|
barSize={30}
|
|
fillOpacity={0.7}
|
|
radius={[2, 2, 0, 0]}
|
|
cursor="pointer"
|
|
onClick={(data) => handleBarClick(data?.payload as LogVolumeDataPoint | undefined)}
|
|
/>
|
|
{refAreaLeft !== null &&
|
|
refAreaRight !== null &&
|
|
chartData[refAreaLeft] &&
|
|
chartData[refAreaRight] && (
|
|
<ReferenceArea
|
|
x1={refAreaLeft}
|
|
x2={refAreaRight}
|
|
strokeOpacity={0.3}
|
|
fill="#6366f1"
|
|
fillOpacity={0.2}
|
|
/>
|
|
)}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</ChartErrorBoundary>
|
|
) : (
|
|
<EmptyChart />
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</Card>
|
|
);
|
|
}
|