Skip to content

Commit ced468d

Browse files
authored
feat: events chart, delete event def's (#1018)
* feat: add traces statistics chart * feat: fix comments * feat: update colors * feat: type array * feat: add colors * feat: remove max value, and response check * feat: events chart WIP * feat: update compact date range filter * feat: refactor events chart * feat: refactor * fix: comments * feat: parametrize * feat: fix comments * feat: unify component * feat: refactor absolute date range filter * feat: add validation * feat: add events delete endpoint * feat: add events chart
1 parent 441f06e commit ced468d

File tree

24 files changed

+1108
-286
lines changed

24 files changed

+1108
-286
lines changed

frontend/app/api/projects/[projectId]/event-definitions/route.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { prettifyError, ZodError } from "zod/v4";
33

4-
import { createEventDefinition, getEventDefinitions } from "@/lib/actions/event-definitions";
4+
import { createEventDefinition, deleteEventDefinitions, getEventDefinitions } from "@/lib/actions/event-definitions";
55

66
export async function GET(_request: NextRequest, props: { params: Promise<{ projectId: string }> }) {
77
const params = await props.params;
@@ -12,7 +12,7 @@ export async function GET(_request: NextRequest, props: { params: Promise<{ proj
1212
return NextResponse.json(result);
1313
} catch (error) {
1414
if (error instanceof ZodError) {
15-
return Response.json({ error: prettifyError(error) }, { status: 400 });
15+
return NextResponse.json({ error: prettifyError(error) }, { status: 400 });
1616
}
1717
return NextResponse.json(
1818
{ error: error instanceof Error ? error.message : "Failed to fetch event definitions." },
@@ -33,11 +33,32 @@ export async function POST(request: NextRequest, props: { params: Promise<{ proj
3333
return NextResponse.json(result);
3434
} catch (error) {
3535
if (error instanceof ZodError) {
36-
return Response.json({ error: prettifyError(error) }, { status: 400 });
36+
return NextResponse.json({ error: prettifyError(error) }, { status: 400 });
3737
}
3838
return NextResponse.json(
3939
{ error: error instanceof Error ? error.message : "Failed to create event definition." },
4040
{ status: 500 }
4141
);
4242
}
4343
}
44+
45+
export async function DELETE(request: NextRequest, props: { params: Promise<{ projectId: string }> }) {
46+
const params = await props.params;
47+
const projectId = params.projectId;
48+
49+
try {
50+
const body = await request.json();
51+
52+
const result = await deleteEventDefinitions({ projectId, ...body });
53+
54+
return NextResponse.json(result);
55+
} catch (error) {
56+
if (error instanceof ZodError) {
57+
return NextResponse.json({ error: prettifyError(error) }, { status: 400 });
58+
}
59+
return NextResponse.json(
60+
{ error: error instanceof Error ? error.message : "Failed to delete event definitions." },
61+
{ status: 500 }
62+
);
63+
}
64+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextRequest } from "next/server";
2+
import { prettifyError, ZodError } from "zod/v4";
3+
4+
import { parseUrlParams } from "@/lib/actions/common/utils";
5+
import { getEventStats, GetEventStatsSchema } from "@/lib/actions/events/stats";
6+
7+
export async function GET(
8+
req: NextRequest,
9+
props: { params: Promise<{ projectId: string; name: string }> }
10+
): Promise<Response> {
11+
const params = await props.params;
12+
const { projectId, name } = params;
13+
14+
const parseResult = parseUrlParams(
15+
req.nextUrl.searchParams,
16+
GetEventStatsSchema.omit({ projectId: true, eventName: true })
17+
);
18+
19+
if (!parseResult.success) {
20+
return Response.json({ error: prettifyError(parseResult.error) }, { status: 400 });
21+
}
22+
23+
try {
24+
const result = await getEventStats({ ...parseResult.data, projectId, eventName: name });
25+
return Response.json(result);
26+
} catch (error) {
27+
if (error instanceof ZodError) {
28+
return Response.json({ error: prettifyError(error) }, { status: 400 });
29+
}
30+
return Response.json(
31+
{ error: error instanceof Error ? error.message : "Failed to fetch event stats." },
32+
{ status: 500 }
33+
);
34+
}
35+
}
36+
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { isNil } from "lodash";
2+
import React from "react";
3+
4+
import { TimeSeriesChartConfig, TimeSeriesDataPoint } from "./types";
5+
6+
interface CustomBarProps {
7+
fill?: string;
8+
x?: number;
9+
y?: number;
10+
width?: number;
11+
height?: number;
12+
payload?: TimeSeriesDataPoint;
13+
dataKey?: string;
14+
chartConfig?: TimeSeriesChartConfig;
15+
fields?: readonly string[];
16+
}
17+
18+
const MIN_BAR_HEIGHT = 3;
19+
20+
function getBarPositionInStack(
21+
dataKey: string,
22+
payload: TimeSeriesDataPoint,
23+
chartConfig: TimeSeriesChartConfig,
24+
fields: readonly string[]
25+
): "top" | "bottom" | "middle" | "solo" {
26+
const currentConfig = chartConfig[dataKey];
27+
28+
if (!currentConfig?.stackId) {
29+
return "solo";
30+
}
31+
32+
const stackId = currentConfig.stackId;
33+
34+
// Find all fields in the same stack that have non-zero values
35+
const stackedFieldsWithValues = fields.filter((field) => {
36+
const config = chartConfig[field];
37+
if (!config || config.stackId !== stackId) return false;
38+
const value = payload[field];
39+
return typeof value === "number" && value > 0;
40+
});
41+
42+
if (stackedFieldsWithValues.length <= 1) {
43+
return "solo";
44+
}
45+
46+
const currentIndex = stackedFieldsWithValues.indexOf(dataKey);
47+
48+
if (currentIndex === 0) {
49+
return "bottom";
50+
} else if (currentIndex === stackedFieldsWithValues.length - 1) {
51+
return "top";
52+
} else {
53+
return "middle";
54+
}
55+
}
56+
57+
function getCornerRadius(position: "top" | "bottom" | "middle" | "solo"): [number, number, number, number] {
58+
const radius = 4;
59+
60+
switch (position) {
61+
case "top":
62+
return [radius, radius, 0, 0];
63+
case "bottom":
64+
return [0, 0, radius, radius];
65+
case "middle":
66+
return [0, 0, 0, 0];
67+
case "solo":
68+
return [radius, radius, radius, radius];
69+
}
70+
}
71+
72+
const RoundedBar = (props: CustomBarProps) => {
73+
const { fill, x, y, width, height = 0, payload, dataKey, chartConfig, fields } = props;
74+
75+
if (isNil(x) || isNil(y) || isNil(width) || !fill || !payload || !dataKey) {
76+
return <></>;
77+
}
78+
79+
const value = payload[dataKey];
80+
if (!value || (typeof value === "number" && value <= 0)) {
81+
return <></>;
82+
}
83+
84+
const barHeight = height > 0 && height < MIN_BAR_HEIGHT ? MIN_BAR_HEIGHT : height;
85+
const barY = barHeight > height ? y - (barHeight - height) : y;
86+
87+
let cornerRadius: [number, number, number, number];
88+
89+
if (chartConfig && fields) {
90+
const position = getBarPositionInStack(dataKey, payload, chartConfig, fields);
91+
cornerRadius = getCornerRadius(position);
92+
} else {
93+
cornerRadius = [4, 4, 4, 4];
94+
}
95+
96+
const [tl, tr, br, bl] = cornerRadius;
97+
98+
return (
99+
<path
100+
d={`
101+
M ${x} ${barY + tl}
102+
Q ${x} ${barY}, ${x + tl} ${barY}
103+
L ${x + width - tr} ${barY}
104+
Q ${x + width} ${barY}, ${x + width} ${barY + tr}
105+
L ${x + width} ${barY + barHeight - br}
106+
Q ${x + width} ${barY + barHeight}, ${x + width - br} ${barY + barHeight}
107+
L ${x + bl} ${barY + barHeight}
108+
Q ${x} ${barY + barHeight}, ${x} ${barY + barHeight - bl}
109+
Z
110+
`}
111+
fill={fill}
112+
/>
113+
);
114+
};
115+
116+
export default RoundedBar;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"use client";
2+
3+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
4+
import React, { useCallback, useMemo, useState } from "react";
5+
import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis, YAxis } from "recharts";
6+
import { CategoricalChartFunc } from "recharts/types/chart/generateCategoricalChart";
7+
8+
import { numberFormatter, selectNiceTicksFromData } from "@/components/chart-builder/charts/utils";
9+
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
10+
11+
import RoundedBar from "./bar";
12+
import { TimeSeriesChartProps, TimeSeriesDataPoint } from "./types";
13+
import { getTickCountForWidth, isValidZoomRange, normalizeTimeRange } from "./utils";
14+
15+
const formatter = new Intl.DateTimeFormat("en-US", {
16+
weekday: "short",
17+
month: "short",
18+
day: "numeric",
19+
hour: "numeric",
20+
minute: "numeric",
21+
});
22+
23+
const countNumberFormatter = new Intl.NumberFormat("en-US", {
24+
maximumFractionDigits: 3,
25+
});
26+
27+
export default function TimeSeriesChart<T extends TimeSeriesDataPoint>({
28+
data,
29+
chartConfig,
30+
fields,
31+
containerWidth,
32+
onZoom,
33+
formatValue = numberFormatter.format,
34+
showTotal = true,
35+
}: Omit<TimeSeriesChartProps<T>, "isLoading" | "className">) {
36+
const router = useRouter();
37+
const pathName = usePathname();
38+
const searchParams = useSearchParams();
39+
const [refArea, setRefArea] = useState<{ left?: string; right?: string }>({});
40+
41+
const targetTickCount = useMemo(() => {
42+
if (!containerWidth) return 8;
43+
return getTickCountForWidth(containerWidth);
44+
}, [containerWidth]);
45+
46+
const smartTicksResult = useMemo(() => {
47+
if (!data || data.length === 0) return null;
48+
const timestamps = data.map((d) => d.timestamp);
49+
return selectNiceTicksFromData(timestamps, targetTickCount);
50+
}, [data, targetTickCount]);
51+
52+
const totalCount = useMemo(() => {
53+
if (!data || data.length === 0) return 0;
54+
return data.reduce(
55+
(sum, dataPoint) =>
56+
sum +
57+
Object.entries(dataPoint).reduce((rowSum, [key, value]) => {
58+
if (key === "timestamp") return rowSum;
59+
return rowSum + (typeof value === "number" ? value : 0);
60+
}, 0),
61+
0
62+
);
63+
}, [data]);
64+
65+
const zoom = useCallback(() => {
66+
if (!isValidZoomRange(refArea.left, refArea.right)) {
67+
setRefArea({});
68+
return;
69+
}
70+
71+
const normalized = normalizeTimeRange(refArea.left!, refArea.right!);
72+
73+
if (onZoom) {
74+
onZoom(normalized.start, normalized.end);
75+
} else {
76+
const params = new URLSearchParams(searchParams.toString());
77+
params.delete("pastHours");
78+
params.set("startDate", normalized.start);
79+
params.set("endDate", normalized.end);
80+
router.push(`${pathName}?${params.toString()}`);
81+
}
82+
83+
setRefArea({});
84+
}, [refArea.left, refArea.right, onZoom, pathName, router, searchParams]);
85+
86+
const onMouseDown: CategoricalChartFunc = useCallback((e) => {
87+
if (e && e.activeLabel) {
88+
setRefArea({ left: e.activeLabel });
89+
}
90+
}, []);
91+
92+
const onMouseMove: CategoricalChartFunc = useCallback(
93+
(e) => {
94+
if (refArea.left && e && e.activeLabel) {
95+
setRefArea({ left: refArea.left, right: e.activeLabel });
96+
}
97+
},
98+
[refArea.left]
99+
);
100+
101+
const BarShapeWithConfig = useCallback(
102+
(props: any) => <RoundedBar {...props} chartConfig={chartConfig} fields={fields} />,
103+
[chartConfig, fields]
104+
);
105+
106+
return (
107+
<div className="flex flex-col items-start">
108+
<ChartContainer config={chartConfig} className="h-48 w-full">
109+
<BarChart
110+
data={data}
111+
margin={{ left: -8, top: 8 }}
112+
onMouseDown={onMouseDown}
113+
onMouseMove={onMouseMove}
114+
onMouseUp={zoom}
115+
barCategoryGap={2}
116+
style={{ userSelect: "none", cursor: "crosshair" }}
117+
>
118+
<CartesianGrid vertical={false} />
119+
<XAxis
120+
dataKey="timestamp"
121+
tickLine={false}
122+
axisLine={false}
123+
tickFormatter={smartTicksResult?.formatter}
124+
allowDataOverflow
125+
ticks={smartTicksResult?.ticks}
126+
/>
127+
<YAxis tickLine={false} axisLine={false} tickFormatter={formatValue} />
128+
<ChartTooltip
129+
content={
130+
<ChartTooltipContent
131+
labelKey="timestamp"
132+
labelFormatter={(_, payload) =>
133+
payload && payload[0] ? formatter.format(new Date(payload[0].payload.timestamp)) : "-"
134+
}
135+
/>
136+
}
137+
/>
138+
{fields.map((fieldKey) => {
139+
const config = chartConfig[fieldKey];
140+
if (!config) return null;
141+
142+
return (
143+
<Bar
144+
key={fieldKey}
145+
dataKey={fieldKey}
146+
fill={config.color}
147+
stackId={config.stackId}
148+
shape={BarShapeWithConfig}
149+
/>
150+
);
151+
})}
152+
{refArea.left && refArea.right && (
153+
<ReferenceArea
154+
x1={refArea.left}
155+
x2={refArea.right}
156+
stroke="hsl(var(--primary))"
157+
strokeDasharray="5 5"
158+
strokeOpacity={0.5}
159+
fill="hsl(var(--primary))"
160+
fillOpacity={0.3}
161+
/>
162+
)}
163+
</BarChart>
164+
</ChartContainer>
165+
{showTotal && (
166+
<div className="text-xs text-muted-foreground text-center" title={String(totalCount)}>
167+
Total: {countNumberFormatter.format(totalCount)}
168+
</div>
169+
)}
170+
</div>
171+
);
172+
}

0 commit comments

Comments
 (0)