first commit
This commit is contained in:
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
540
ui/app/workspace/logs/views/logsVolumeChart.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user