Skip to content

Commit af80fec

Browse files
aadereikoaadereiko
andauthored
[OPIK-5223][FE]: kpi cards; (#6013)
* init kpi cards; * finish kpi cards and graph; * refactor; * eslint issues; * baz review comments; * eslint issues; * revert endtime; --------- Co-authored-by: aadereiko <aliaksandr@comet.com>
1 parent 80c9e64 commit af80fec

10 files changed

Lines changed: 689 additions & 122 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { QueryFunctionContext, useQuery } from "@tanstack/react-query";
2+
import api, { PROJECTS_REST_ENDPOINT, QueryConfig } from "@/api/api";
3+
import { Filters } from "@/types/filters";
4+
import { processFilters } from "@/lib/filters";
5+
6+
export type KpiEntityType = "traces" | "spans" | "threads";
7+
8+
export type KpiMetricType = "count" | "errors" | "avg_duration" | "total_cost";
9+
10+
export type KpiMetric = {
11+
type: KpiMetricType;
12+
current_value: number | null;
13+
previous_value: number | null;
14+
};
15+
16+
export type KpiCardResponse = {
17+
stats: KpiMetric[];
18+
};
19+
20+
type UseProjectKpiCardsParams = {
21+
projectId: string;
22+
entityType: KpiEntityType;
23+
filters?: Filters;
24+
intervalStart?: string;
25+
intervalEnd?: string;
26+
};
27+
28+
const getProjectKpiCards = async (
29+
{ signal }: QueryFunctionContext,
30+
{
31+
projectId,
32+
entityType,
33+
filters,
34+
intervalStart,
35+
intervalEnd,
36+
}: UseProjectKpiCardsParams,
37+
) => {
38+
const processedFilters = processFilters(filters);
39+
40+
const { data } = await api.post<KpiCardResponse>(
41+
`${PROJECTS_REST_ENDPOINT}${projectId}/kpi-cards`,
42+
{
43+
entity_type: entityType,
44+
...processedFilters,
45+
...(intervalStart && { interval_start: intervalStart }),
46+
...(intervalEnd && { interval_end: intervalEnd }),
47+
},
48+
{ signal },
49+
);
50+
51+
return data;
52+
};
53+
54+
export default function useProjectKpiCards(
55+
params: UseProjectKpiCardsParams,
56+
options?: QueryConfig<KpiCardResponse>,
57+
) {
58+
return useQuery({
59+
queryKey: ["project-kpi-cards", params],
60+
queryFn: (context) => getProjectKpiCards(context, params),
61+
...options,
62+
});
63+
}

apps/opik-frontend/src/api/projects/useProjectMetric.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export enum METRIC_NAME_TYPE {
2020
SPAN_DURATION = "SPAN_DURATION",
2121
SPAN_FEEDBACK_SCORES = "SPAN_FEEDBACK_SCORES",
2222
SPAN_TOKEN_USAGE = "SPAN_TOKEN_USAGE",
23+
TRACE_AVERAGE_DURATION = "TRACE_AVERAGE_DURATION",
24+
SPAN_AVERAGE_DURATION = "SPAN_AVERAGE_DURATION",
25+
THREAD_AVERAGE_DURATION = "THREAD_AVERAGE_DURATION",
26+
TRACE_ERROR_RATE = "TRACE_ERROR_RATE",
27+
SPAN_ERROR_RATE = "SPAN_ERROR_RATE",
2328
}
2429

2530
export enum INTERVAL_TYPE {

apps/opik-frontend/src/main.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
--chart-purple: #945fcf;
8888
--chart-pink: #ed4a7b;
8989
--chart-orange: #fb9341;
90+
--chart-teal: #12a4b4;
9091

9192
/* Template icon colors */
9293
--template-icon-metrics: #5899da;
@@ -353,6 +354,7 @@
353354
--chart-purple: #945fcf;
354355
--chart-pink: #ed4a7b;
355356
--chart-orange: #fb9341;
357+
--chart-teal: #12a4b4;
356358

357359
/* Template icon colors - Dark Mode */
358360
--template-icon-metrics: #5899da;

apps/opik-frontend/src/v2/pages-shared/dashboards/widgets/ProjectMetricsWidget/MetricChart/MetricBarChart.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface MetricBarChartProps {
2323
isPending: boolean;
2424
labelActions?: Record<string, LegendLabelAction>;
2525
isAggregateTotal?: boolean;
26+
showLegend?: boolean;
2627
}
2728

2829
const MetricBarChart: React.FunctionComponent<MetricBarChartProps> = ({
@@ -35,6 +36,7 @@ const MetricBarChart: React.FunctionComponent<MetricBarChartProps> = ({
3536
data,
3637
labelActions,
3738
isAggregateTotal = false,
39+
showLegend = true,
3840
}) => {
3941
const renderChartTooltipHeader = useCallback(
4042
({ payload }: ChartTooltipRenderHeaderArguments) => {
@@ -83,7 +85,7 @@ const MetricBarChart: React.FunctionComponent<MetricBarChartProps> = ({
8385
customYTickFormatter={customYTickFormatter}
8486
renderTooltipValue={renderValue}
8587
renderTooltipHeader={renderChartTooltipHeader}
86-
showLegend
88+
showLegend={showLegend}
8789
labelActions={labelActions}
8890
/>
8991
);

apps/opik-frontend/src/v2/pages-shared/dashboards/widgets/ProjectMetricsWidget/MetricChart/MetricChartContainer.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ interface MetricContainerChartProps {
5858
getLabelAction?: (label: string) => LegendLabelAction | undefined;
5959
isAggregateTotal?: boolean;
6060
customEmptyState?: React.ReactNode;
61+
customLoadingState?: React.ReactNode;
6162
logsSource?: LOGS_SOURCE;
63+
showLegend?: boolean;
64+
colorMap?: Record<string, string>;
6265
}
6366

6467
const customColorMap = {
@@ -100,7 +103,10 @@ const MetricContainerChart = ({
100103
getLabelAction,
101104
isAggregateTotal = false,
102105
customEmptyState,
106+
customLoadingState,
103107
logsSource,
108+
showLegend = true,
109+
colorMap,
104110
}: MetricContainerChartProps) => {
105111
const { data: response, isPending } = useProjectMetric(
106112
{
@@ -175,7 +181,7 @@ const MetricContainerChart = ({
175181
return data.every((record) => lines.every((line) => isNil(record[line])));
176182
}, [data, lines, isPending]);
177183

178-
const config = useChartConfig(lines, labelsMap, customColorMap);
184+
const config = useChartConfig(lines, labelsMap, colorMap ?? customColorMap);
179185

180186
const labelActions = useMemo(() => {
181187
if (!getLabelAction) return undefined;
@@ -194,26 +200,37 @@ const MetricContainerChart = ({
194200

195201
const CHART = METRIC_CHART_TYPE[chartType];
196202

197-
const chartContent = noData ? (
198-
customEmptyState || (
199-
<NoData
200-
className="h-[var(--chart-height)] min-h-32 text-light-slate"
201-
message="No data to show"
203+
const getChartContent = () => {
204+
if (isPending && customLoadingState) return customLoadingState;
205+
206+
if (noData) {
207+
return (
208+
customEmptyState || (
209+
<NoData
210+
className="h-[var(--chart-height)] min-h-32 text-light-slate"
211+
message="No data to show"
212+
/>
213+
)
214+
);
215+
}
216+
217+
return (
218+
<CHART
219+
config={config}
220+
interval={interval}
221+
renderValue={renderValue}
222+
customYTickFormatter={customYTickFormatter}
223+
chartId={chartId}
224+
isPending={isPending}
225+
data={data}
226+
labelActions={labelActions}
227+
isAggregateTotal={isAggregateTotal}
228+
showLegend={showLegend}
202229
/>
203-
)
204-
) : (
205-
<CHART
206-
config={config}
207-
interval={interval}
208-
renderValue={renderValue}
209-
customYTickFormatter={customYTickFormatter}
210-
chartId={chartId}
211-
isPending={isPending}
212-
data={data}
213-
labelActions={labelActions}
214-
isAggregateTotal={isAggregateTotal}
215-
/>
216-
);
230+
);
231+
};
232+
233+
const chartContent = getChartContent();
217234

218235
if (chartOnly) return chartContent;
219236

apps/opik-frontend/src/v2/pages-shared/dashboards/widgets/ProjectMetricsWidget/MetricChart/MetricLineChart.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface MetricLineChartProps {
2323
isPending: boolean;
2424
labelActions?: Record<string, LegendLabelAction>;
2525
isAggregateTotal?: boolean;
26+
showLegend?: boolean;
2627
}
2728

2829
const MetricLineChart: React.FunctionComponent<MetricLineChartProps> = ({
@@ -35,6 +36,7 @@ const MetricLineChart: React.FunctionComponent<MetricLineChartProps> = ({
3536
data,
3637
labelActions,
3738
isAggregateTotal = false,
39+
showLegend = true,
3840
}) => {
3941
const renderChartTooltipHeader = useCallback(
4042
({ payload }: ChartTooltipRenderHeaderArguments) => {
@@ -83,7 +85,7 @@ const MetricLineChart: React.FunctionComponent<MetricLineChartProps> = ({
8385
customYTickFormatter={customYTickFormatter}
8486
renderTooltipValue={renderValue}
8587
renderTooltipHeader={renderChartTooltipHeader}
86-
showLegend
88+
showLegend={showLegend}
8789
showArea
8890
connectNulls
8991
labelActions={labelActions}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from "react";
2+
import { ArrowUp, ArrowDown, LucideIcon } from "lucide-react";
3+
import { cn } from "@/lib/utils";
4+
import { PercentageTrendType } from "@/shared/PercentageTrend/PercentageTrend";
5+
6+
const computePercentageChange = (
7+
current: number | null,
8+
previous: number | null,
9+
): number | undefined => {
10+
if (current === null && previous === null) return undefined;
11+
const c = current ?? 0;
12+
const p = previous ?? 0;
13+
if (p === 0) return c === 0 ? 0 : c > 0 ? Infinity : -Infinity;
14+
if (c === 0) return -100;
15+
return ((c - p) / p) * 100;
16+
};
17+
18+
export type MetricCardProps = {
19+
icon: LucideIcon;
20+
label: string;
21+
value: string;
22+
currentRaw?: number | null;
23+
previousRaw?: number | null;
24+
trend: PercentageTrendType;
25+
selected?: boolean;
26+
onClick?: () => void;
27+
className?: string;
28+
};
29+
30+
const MetricCard: React.FC<MetricCardProps> = ({
31+
icon: Icon,
32+
label,
33+
value,
34+
currentRaw,
35+
previousRaw,
36+
trend,
37+
selected = false,
38+
onClick,
39+
className,
40+
}) => {
41+
const percentage = computePercentageChange(
42+
currentRaw ?? null,
43+
previousRaw ?? null,
44+
);
45+
46+
const renderChange = () => {
47+
if (percentage === undefined || !isFinite(percentage)) return null;
48+
if (percentage === 0) {
49+
return <span className="text-xs text-light-slate">No changes</span>;
50+
}
51+
52+
const isUp = percentage > 0;
53+
const isBetter = trend === "direct" ? isUp : !isUp;
54+
const ChangeIcon = isUp ? ArrowUp : ArrowDown;
55+
const colorClass = selected
56+
? isBetter
57+
? "text-primary"
58+
: "text-chart-red"
59+
: "text-muted-slate";
60+
return (
61+
<span
62+
className={cn(
63+
"inline-flex items-center gap-1 text-xs font-medium",
64+
colorClass,
65+
)}
66+
>
67+
<ChangeIcon className="size-3" />
68+
{`${Math.abs(percentage).toFixed(1)}%`}
69+
</span>
70+
);
71+
};
72+
73+
return (
74+
<div
75+
className={cn(
76+
"flex h-11 cursor-pointer items-center justify-between border px-4 transition-colors [&:not(:last-child)]:-mr-px",
77+
selected ? "bg-background" : "bg-soft-background hover:bg-background",
78+
className,
79+
)}
80+
onClick={onClick}
81+
>
82+
<div className="flex items-center gap-3">
83+
<Icon className="size-4 shrink-0 text-muted-slate" />
84+
<div className="flex items-center gap-2">
85+
<span
86+
className={cn(
87+
"comet-body-s",
88+
selected ? "text-foreground" : "text-muted-slate",
89+
)}
90+
>
91+
{label}
92+
</span>
93+
<span
94+
className={cn(
95+
"comet-body-s",
96+
selected ? "text-foreground" : "text-muted-slate",
97+
value === "N/A" && "comet-body-xs text-light-slate",
98+
)}
99+
>
100+
{value}
101+
</span>
102+
{renderChange()}
103+
</div>
104+
</div>
105+
</div>
106+
);
107+
};
108+
109+
export default MetricCard;

0 commit comments

Comments
 (0)