Compare commits

..

80 Commits

Author SHA1 Message Date
eKuG
7206bb82fe Merge branch 'trace_operator_implementation' into demo/trace_operators_backend 2025-08-21 13:44:01 +05:30
eKuG
a1ad2b7835 feat: resolved conflicts 2025-08-21 13:43:17 +05:30
eKuG
a2ab97a347 Merge branch 'demo/trace-operators' of github.com:SigNoz/signoz into demo/trace_operators_backend 2025-08-21 10:18:35 +05:30
ahrefabhi
7c1ca7544d Merge branch 'feature/trace-operators' into demo/trace-operators 2025-08-21 10:15:51 +05:30
ahrefabhi
1b0dcb86b5 chore: linter fix 2025-08-21 09:50:35 +05:30
ahrefabhi
cb49bc795b chore: minor pr review change 2025-08-21 01:33:10 +05:30
ahrefabhi
3f1aeb3077 chore: added traceoperators in alerts 2025-08-21 01:31:40 +05:30
ahrefabhi
cc2a905e0b chore: minor changes in queryaddon and aggregation for support 2025-08-21 01:30:37 +05:30
ahrefabhi
eba024fc5d chore: removed traceoperations and reused queryoperations 2025-08-21 01:29:30 +05:30
ahrefabhi
561ec8fd40 chore: added ui changes in the editor 2025-08-21 01:28:32 +05:30
ahrefabhi
aa1dfc6eb1 feat: added span selector 2025-08-21 01:27:58 +05:30
eKuG
3248012716 feat: resolved conflicts 2025-08-20 18:59:21 +05:30
eKuG
4ce56ebab4 feat: resolved conflicts 2025-08-20 18:58:43 +05:30
eKuG
bb80d69819 feat: resolved conflicts 2025-08-20 17:32:15 +05:30
eKuG
49aaecd02c feat: resolved conflicts 2025-08-20 17:30:52 +05:30
eKuG
98f4e840cd feat: resolved conflicts 2025-08-20 17:20:44 +05:30
eKuG
74824e7853 feat: resolved conflicts 2025-08-20 16:59:01 +05:30
ahrefabhi
b574fee2d4 chore: fixed minor styles + minor ux fix 2025-08-20 15:18:11 +05:30
eKuG
675b66a7b9 feat: resolved conflicts 2025-08-20 12:18:37 +05:30
Ekansh Gupta
f55aeb5b5a Merge branch 'main' into trace_operator_implementation 2025-08-20 11:45:46 +05:30
eKuG
ae3806ce64 feat: resolved conflicts 2025-08-20 11:45:04 +05:30
ahrefabhi
9c489ebc84 chore: Added changes to prepare request payload 2025-08-20 11:24:19 +05:30
ahrefabhi
f6d432cfce chore: added initialvalue for trace operators 2025-08-20 11:23:42 +05:30
ahrefabhi
6ca6f615b0 chore: type changes 2025-08-20 11:22:40 +05:30
ahrefabhi
36e7820edd chore: minor UI fixes 2025-08-20 11:21:16 +05:30
ahrefabhi
f51cce844b feat: added conditions for traceoperator 2025-08-20 11:20:51 +05:30
ahrefabhi
b2d3d61b44 chore: minor style improvments 2025-08-20 11:20:06 +05:30
ahrefabhi
4e2c7c6309 feat: added traceoperator component and styles 2025-08-20 11:19:35 +05:30
eKuG
885045d704 feat: resolved conflicts 2025-08-19 13:41:23 +05:30
Ekansh Gupta
9dc2e82ce1 Merge branch 'main' into trace_operator_implementation 2025-08-19 13:10:39 +05:30
eKuG
19e60ee688 feat: resolved conflicts 2025-08-19 12:26:51 +05:30
eKuG
ea89714cb4 feat: resolved conflicts 2025-08-19 11:20:32 +05:30
eKuG
4be618bcde feat: resolved conflicts 2025-08-18 16:45:47 +05:30
eKuG
2bfecce3cb feat: resolved conflicts 2025-08-18 16:17:48 +05:30
eKuG
eefbcbd1eb feat: resolved conflicts 2025-08-18 15:43:49 +05:30
eKuG
a3f366ee36 feat: resolved conflicts 2025-08-18 15:35:45 +05:30
eKuG
cff547c303 feat: resolved conflicts 2025-08-18 15:28:53 +05:30
Ekansh Gupta
d6287cba52 Merge branch 'main' into trace_operator_implementation 2025-08-18 15:26:31 +05:30
eKuG
44b09fbef2 feat: resolved conflicts 2025-08-18 15:26:08 +05:30
eKuG
081eb64893 feat: resolved conflicts 2025-08-11 13:03:23 +05:30
eKuG
6338af55dd feat: resolved conflicts 2025-08-11 12:44:17 +05:30
eKuG
5450b92650 feat: resolved conflicts 2025-08-11 11:52:33 +05:30
Ekansh Gupta
a9179321e1 Merge branch 'main' into trace_operator_implementation 2025-08-11 11:48:28 +05:30
eKuG
90366975d8 feat: resolved conflicts 2025-08-11 11:48:13 +05:30
eKuG
33f47993d3 feat: resolved conflicts 2025-08-11 11:46:47 +05:30
eKuG
9170846111 feat: resolved conflicts 2025-08-11 11:44:03 +05:30
Ekansh Gupta
54baa9d76d Merge branch 'main' into trace_operator_implementation 2025-07-29 15:43:40 +05:30
eKuG
0ed6aac74e feat: refactored the consume function 2025-07-29 13:09:49 +05:30
Ekansh Gupta
b994fed409 Merge branch 'main' into trace_operator_implementation 2025-07-29 13:08:40 +05:30
eKuG
a9eb992f67 feat: refactored the consume function 2025-07-29 13:08:20 +05:30
eKuG
ed95815a6a feat: refactored the consume function 2025-07-29 13:06:32 +05:30
eKuG
2e2888346f feat: refactored the consume function 2025-07-29 12:24:44 +05:30
eKuG
525c5ac081 feat: refactored the consume function 2025-07-29 12:23:22 +05:30
eKuG
66cede4c03 feat: added postprocess 2025-07-28 23:29:27 +05:30
eKuG
33ea94991a feat: added postprocess 2025-07-28 23:28:10 +05:30
Ekansh Gupta
bae461d1f8 Merge branch 'main' into trace_operator_implementation 2025-07-28 21:24:02 +05:30
eKuG
9df82cc952 feat: added postprocess 2025-07-28 21:19:53 +05:30
Ekansh Gupta
d3d927c84d Merge branch 'main' into trace_operator_implementation 2025-07-28 14:24:46 +05:30
eKuG
36ab1ce8a2 feat: refactor trace operator 2025-07-25 17:55:13 +05:30
Ekansh Gupta
7bbf3ffba3 Merge branch 'main' into trace_operator_implementation 2025-07-25 13:56:43 +05:30
Ekansh Gupta
6ab5c3cf2e Merge branch 'main' into trace_operator_implementation 2025-07-23 15:35:13 +05:30
eKuG
c2384e387d feat: added implementation of trace operators 2025-07-07 21:18:46 +05:30
eKuG
a00f263bad feat: added implementation of trace operators 2025-06-29 13:35:49 +05:30
eKuG
9d648915cc feat: added implementation of trace operators 2025-06-23 16:24:01 +05:30
eKuG
e6bd7484fa feat: added implementation of trace operators 2025-06-23 16:13:02 +05:30
Ekansh Gupta
d780c7482e Merge branch 'main' into trace_operator_implementation 2025-06-23 16:00:33 +05:30
eKuG
ffa8d0267e feat: added implementation of trace operators 2025-06-23 15:59:53 +05:30
Ekansh Gupta
f0505a9c0e Merge branch 'main' into trace_operator_implementation 2025-06-22 15:44:55 +05:30
eKuG
09e212bd64 feat: added implementation of trace operators 2025-06-22 15:43:33 +05:30
eKuG
75f3131e65 feat: added implementation of trace operators 2025-06-22 15:39:43 +05:30
eKuG
b1b571ace9 feat: added implementation of trace operators 2025-06-22 15:38:42 +05:30
Ekansh Gupta
876f580f75 Merge branch 'main' into trace_operator_implementation 2025-06-20 15:45:15 +05:30
eKuG
7999f261ef feat: added implementation of trace operators 2025-06-20 14:41:12 +05:30
eKuG
66b8574f74 feat: added implementation of trace operators 2025-06-20 14:37:07 +05:30
eKuG
d7b8be11a4 feat: [draft] added implementation of trace operators 2025-06-20 00:18:27 +05:30
eKuG
aa3935cc31 feat: [draft] added implementation of trace operators 2025-06-20 00:08:52 +05:30
Ekansh Gupta
002c755ca5 Merge branch 'main' into trace_operator_implementation 2025-06-19 15:03:00 +05:30
eKuG
558739b4e7 feat: [draft] added implementation of trace operators 2025-06-19 00:08:41 +05:30
Ekansh Gupta
efdfa48ad0 Merge branch 'main' into trace_operator_implementation 2025-06-18 23:52:48 +05:30
eKuG
693c4451ee feat: [draft] added implementation of trace operators 2025-06-18 23:49:49 +05:30
125 changed files with 2785 additions and 7042 deletions

View File

@@ -5,7 +5,10 @@ import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import {
BaseBuilderQuery,
FieldContext,
@@ -276,6 +279,103 @@ export function convertBuilderQueriesToV5(
);
}
function createTraceOperatorBaseSpec(
queryData: IBuilderTraceOperator,
requestType: RequestType,
panelType?: PANEL_TYPES,
): BaseBuilderQuery {
const nonEmptySelectColumns = (queryData.selectColumns as (
| BaseAutocompleteData
| TelemetryFieldKey
)[])?.filter((c) => ('key' in c ? c?.key : c?.name));
return {
stepInterval: queryData?.stepInterval || undefined,
groupBy:
queryData.groupBy?.length > 0
? queryData.groupBy.map(
(item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType,
fieldContext: item?.type,
description: item?.description,
unit: item?.unit,
signal: item?.signal,
materialized: item?.materialized,
}),
)
: undefined,
limit:
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
? queryData.limit || queryData.pageSize || undefined
: queryData.limit || undefined,
offset:
requestType === 'raw' || requestType === 'trace'
? queryData.offset
: undefined,
order:
queryData.orderBy?.length > 0
? queryData.orderBy.map(
(order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}),
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(
(column: any): TelemetryFieldKey => ({
name: column.name ?? column.key,
fieldDataType:
column?.fieldDataType ?? (column?.dataType as FieldDataType),
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
signal: column?.signal ?? undefined,
}),
),
};
}
export function convertTraceOperatorToV5(
traceOperator: Record<string, IBuilderTraceOperator>,
requestType: RequestType,
panelType?: PANEL_TYPES,
): QueryEnvelope[] {
return Object.entries(traceOperator).map(
([queryName, traceOperatorData]): QueryEnvelope => {
const baseSpec = createTraceOperatorBaseSpec(
traceOperatorData,
requestType,
panelType,
);
let spec: QueryEnvelope['spec'];
// Skip aggregation for raw request type
const aggregations =
requestType === 'raw'
? undefined
: createAggregation(traceOperatorData, panelType);
spec = {
name: queryName,
returnSpansFrom: traceOperatorData.returnSpansFrom || '',
...baseSpec,
expression: traceOperatorData.expression || '',
aggregations: aggregations as TraceAggregation[],
};
return {
type: 'builder_trace_operator' as QueryType,
spec,
};
},
);
}
/**
* Converts PromQL queries to V5 format
*/
@@ -357,14 +457,27 @@ export const prepareQueryRangePayloadV5 = ({
switch (query.queryType) {
case EQueryType.QUERY_BUILDER: {
const { queryData: data, queryFormulas } = query.builder;
const { queryData: data, queryFormulas, queryTraceOperator } = query.builder;
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
const filteredTraceOperator =
queryTraceOperator && queryTraceOperator.length > 0
? queryTraceOperator.filter((traceOperator) =>
Boolean(traceOperator.expression.trim()),
)
: [];
const currentTraceOperator = mapQueryDataToApi(
filteredTraceOperator,
'queryName',
);
// Combine legend maps
legendMap = {
...currentQueryData.newLegendMap,
...currentFormulas.newLegendMap,
...currentTraceOperator.newLegendMap,
};
// Convert builder queries
@@ -397,8 +510,36 @@ export const prepareQueryRangePayloadV5 = ({
}),
);
const traceOperatorQueries = convertTraceOperatorToV5(
currentTraceOperator.data,
requestType,
graphType,
);
// const traceOperatorQueries = Object.entries(currentTraceOperator.data).map(
// ([queryName, traceOperatorData]): QueryEnvelope => ({
// type: 'builder_trace_operator' as const,
// spec: {
// name: queryName,
// expression: traceOperatorData.expression || '',
// legend: isEmpty(traceOperatorData.legend)
// ? undefined
// : traceOperatorData.legend,
// limit: 10,
// order: traceOperatorData.orderBy?.map(
// // eslint-disable-next-line sonarjs/no-identical-functions
// (order: any): OrderBy => ({
// key: {
// name: order.columnName,
// },
// direction: order.order,
// }),
// ),
// },
// }),
// );
// Combine both types
queries = [...builderQueries, ...formulaQueries];
queries = [...builderQueries, ...formulaQueries, ...traceOperatorQueries];
break;
}
case EQueryType.PROM: {

View File

@@ -169,7 +169,6 @@
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}

View File

@@ -22,6 +22,10 @@
flex: 1;
position: relative;
.qb-trace-view-selector-container {
padding: 12px 8px 8px 8px;
}
}
.qb-content-section {
@@ -179,7 +183,7 @@
flex-direction: column;
gap: 8px;
margin-left: 32px;
margin-left: 26px;
padding-bottom: 16px;
padding-left: 8px;
@@ -195,8 +199,8 @@
}
.formula-container {
margin-left: 82px;
padding: 4px 0px;
padding: 8px;
margin-left: 74px;
.ant-col {
&::before {
@@ -331,6 +335,12 @@
);
left: 15px;
}
&.has-trace-operator {
&::before {
height: 0px;
}
}
}
.formula-name {
@@ -347,7 +357,7 @@
&::before {
content: '';
height: 65px;
height: 128px;
content: '';
position: absolute;
left: 0;

View File

@@ -5,11 +5,13 @@ import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo, useEffect, useMemo, useRef } from 'react';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
import { QueryV2 } from './QueryV2/QueryV2';
import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
export const QueryBuilderV2 = memo(function QueryBuilderV2({
config,
@@ -18,6 +20,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryComponents,
isListViewPanel = false,
showOnlyWhereClause = false,
showTraceOperator = false,
version,
}: QueryBuilderProps): JSX.Element {
const {
@@ -25,6 +28,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
addNewBuilderQuery,
addNewFormula,
handleSetConfig,
addTraceOperator,
panelType,
initialDataSource,
} = useQueryBuilder();
@@ -54,6 +58,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
newPanelType,
]);
const isMultiQueryAllowed = useMemo(
() =>
!showOnlyWhereClause ||
!isListViewPanel ||
(currentDataSource === DataSource.TRACES && showTraceOperator),
[showOnlyWhereClause, currentDataSource, showTraceOperator, isListViewPanel],
);
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: true, isDisabled: true },
@@ -97,11 +109,45 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
listViewTracesFilterConfigs,
]);
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
if (
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0
) {
return currentQuery.builder.queryTraceOperator[0];
}
return undefined;
}, [currentQuery.builder.queryTraceOperator]);
const shouldShowTraceOperator = useMemo(
() =>
showTraceOperator &&
currentDataSource === DataSource.TRACES &&
Boolean(traceOperator),
[currentDataSource, showTraceOperator, traceOperator],
);
const shouldShowFooter = useMemo(
() =>
(!showOnlyWhereClause && !isListViewPanel) ||
(currentDataSource === DataSource.TRACES && showTraceOperator),
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
);
const showFormula = useMemo(() => {
if (currentDataSource === DataSource.TRACES) {
return !isListViewPanel;
}
return true;
}, [isListViewPanel, currentDataSource]);
return (
<QueryBuilderV2Provider>
<div className="query-builder-v2">
<div className="qb-content-container">
{isListViewPanel && (
{!isMultiQueryAllowed ? (
<QueryV2
ref={containerRef}
key={currentQuery.builder.queryData[0].queryName}
@@ -109,15 +155,15 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
query={currentQuery.builder.queryData[0]}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={shouldShowTraceOperator}
version={version}
isAvailableToDisable={false}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
/>
)}
{!isListViewPanel &&
) : (
currentQuery.builder.queryData.map((query, index) => (
<QueryV2
ref={containerRef}
@@ -127,13 +173,16 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
showTraceOperator={shouldShowTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={config?.signalSource || ''}
/>
))}
))
)}
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
<div className="qb-formulas-container">
@@ -158,15 +207,25 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
</div>
)}
{!showOnlyWhereClause && !isListViewPanel && (
{shouldShowFooter && (
<QueryFooter
showAddFormula={showFormula}
addNewBuilderQuery={addNewBuilderQuery}
addNewFormula={addNewFormula}
addTraceOperator={addTraceOperator}
showAddTraceOperator={showTraceOperator && !traceOperator}
/>
)}
{shouldShowTraceOperator && (
<TraceOperator
isListViewPanel={isListViewPanel}
traceOperator={traceOperator as IBuilderTraceOperator}
/>
)}
</div>
{!showOnlyWhereClause && !isListViewPanel && (
{isMultiQueryAllowed && (
<div className="query-names-section">
{currentQuery.builder.queryData.map((query) => (
<div key={query.queryName} className="query-name">

View File

@@ -1,7 +1,11 @@
.query-add-ons {
width: 100%;
}
.add-ons-list {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.add-ons-tabs {
display: flex;

View File

@@ -144,6 +144,8 @@ function QueryAddOns({
showReduceTo,
panelType,
index,
isForTraceOperator = false,
children,
}: {
query: IBuilderQuery;
version: string;
@@ -151,6 +153,8 @@ function QueryAddOns({
showReduceTo: boolean;
panelType: PANEL_TYPES | null;
index: number;
isForTraceOperator?: boolean;
children?: React.ReactNode;
}): JSX.Element {
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
@@ -160,6 +164,7 @@ function QueryAddOns({
index,
query,
entityVersion: '',
isForTraceOperator,
});
const { handleSetQueryData } = useQueryBuilder();
@@ -486,6 +491,7 @@ function QueryAddOns({
</Tooltip>
))}
</Radio.Group>
{children}
</div>
</div>
);

View File

@@ -4,7 +4,10 @@ import { Tooltip } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationSelect from './QueryAggregationSelect';
@@ -20,7 +23,7 @@ function QueryAggregationOptions({
panelType?: string;
onAggregationIntervalChange: (value: number) => void;
onChange?: (value: string) => void;
queryData: IBuilderQuery;
queryData: IBuilderQuery | IBuilderTraceOperator;
}): JSX.Element {
const showAggregationInterval = useMemo(() => {
// eslint-disable-next-line sonarjs/prefer-single-boolean-return

View File

@@ -4,9 +4,15 @@ import { Plus, Sigma } from 'lucide-react';
export default function QueryFooter({
addNewBuilderQuery,
addNewFormula,
addTraceOperator,
showAddFormula = true,
showAddTraceOperator = false,
}: {
addNewBuilderQuery: () => void;
addNewFormula: () => void;
addTraceOperator?: () => void;
showAddTraceOperator: boolean;
showAddFormula?: boolean;
}): JSX.Element {
return (
<div className="qb-footer">
@@ -22,32 +28,62 @@ export default function QueryFooter({
</Tooltip>
</div>
<div className="qb-add-formula">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button
className="add-formula-button periscope-btn secondary"
icon={<Sigma size={16} />}
onClick={addNewFormula}
{showAddFormula && (
<div className="qb-add-formula">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
Add Formula
</Button>
</Tooltip>
</div>
<Button
className="add-formula-button periscope-btn secondary"
icon={<Sigma size={16} />}
onClick={addNewFormula}
>
Add Formula
</Button>
</Tooltip>
</div>
)}
{showAddTraceOperator && (
<div className="qb-add-formula">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button
className="add-formula-button periscope-btn secondary"
icon={<Sigma size={16} />}
onClick={() => addTraceOperator?.()}
>
Add Trace Matching
</Button>
</Tooltip>
</div>
)}
</div>
</div>
);

View File

@@ -7,6 +7,7 @@
'Helvetica Neue', sans-serif;
.query-where-clause-editor-container {
position: relative;
display: flex;
flex-direction: row;

View File

@@ -26,9 +26,11 @@ export const QueryV2 = memo(function QueryV2({
query,
filterConfigs,
isListViewPanel = false,
showTraceOperator = false,
version,
showOnlyWhereClause = false,
signalSource = '',
isMultiQueryAllowed = false,
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
const { cloneQuery, panelType } = useQueryBuilder();
@@ -108,11 +110,15 @@ export const QueryV2 = memo(function QueryV2({
ref={ref}
>
<div className="qb-content-section">
{!showOnlyWhereClause && (
{isMultiQueryAllowed && (
<div className="qb-header-container">
<div className="query-actions-container">
<div className="query-actions-left-container">
<QBEntityOptions
hasTraceOperator={
showTraceOperator ||
(isListViewPanel && dataSource === DataSource.TRACES)
}
isMetricsDataSource={dataSource === DataSource.METRICS}
showFunctions={
(version && version === ENTITY_VERSION_V4) ||
@@ -139,7 +145,30 @@ export const QueryV2 = memo(function QueryV2({
/>
</div>
{!isListViewPanel && (
{!isCollapsed &&
(showTraceOperator ||
(isListViewPanel && dataSource === DataSource.TRACES)) && (
<div className="qb-search-filter-container" style={{ flex: 1 }}>
<div className="query-search-container">
<QuerySearch
key={`query-search-${query.queryName}-${query.dataSource}`}
onChange={handleSearchChange}
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
/>
</div>
{showSpanScopeSelector && (
<div className="traces-search-filter-container">
<div className="traces-search-filter-in">in</div>
<SpanScopeSelector query={query} />
</div>
)}
</div>
)}
{isMultiQueryAllowed && (
<Dropdown
className="query-actions-dropdown"
menu={{
@@ -181,28 +210,32 @@ export const QueryV2 = memo(function QueryV2({
</div>
)}
<div className="qb-search-filter-container">
<div className="query-search-container">
<QuerySearch
key={`query-search-${query.queryName}-${query.dataSource}`}
onChange={handleSearchChange}
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
/>
</div>
{!showTraceOperator &&
!(isListViewPanel && dataSource === DataSource.TRACES) && (
<div className="qb-search-filter-container">
<div className="query-search-container">
<QuerySearch
key={`query-search-${query.queryName}-${query.dataSource}`}
onChange={handleSearchChange}
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
/>
</div>
{showSpanScopeSelector && (
<div className="traces-search-filter-container">
<div className="traces-search-filter-in">in</div>
<SpanScopeSelector query={query} />
{showSpanScopeSelector && (
<div className="traces-search-filter-container">
<div className="traces-search-filter-in">in</div>
<SpanScopeSelector query={query} />
</div>
)}
</div>
)}
</div>
</div>
{!showOnlyWhereClause &&
!isListViewPanel &&
!showTraceOperator &&
dataSource !== DataSource.METRICS && (
<QueryAggregation
dataSource={dataSource}
@@ -225,7 +258,7 @@ export const QueryV2 = memo(function QueryV2({
/>
)}
{!showOnlyWhereClause && (
{!showOnlyWhereClause && !isListViewPanel && !showTraceOperator && (
<QueryAddOns
index={index}
query={query}

View File

@@ -0,0 +1,180 @@
.qb-trace-operator {
padding: 8px;
display: flex;
gap: 8px;
&.non-list-view {
padding-left: 40px;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 12px;
height: calc(100% - 48px);
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
}
&-span-source-label {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
&-query {
font-size: 14px;
font-weight: 400;
color: var(--bg-vanilla-100);
}
&-query-name {
width: 18px;
height: 18px;
display: grid;
place-content: center;
padding: 2px;
border-radius: 2px;
border: 1px solid rgba(242, 71, 105, 0.2);
background: rgba(242, 71, 105, 0.1);
color: var(--Sakura-400, #f56c87);
font-size: 12px;
}
}
&-arrow {
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
left: -26px;
height: 1px;
width: 20px;
background: repeating-linear-gradient(
to right,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: -10px;
transform: translateY(-50%);
height: 4px;
width: 4px;
border-radius: 50%;
background-color: var(--bg-slate-400);
}
}
&-input {
width: 100%;
}
&-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
&-aggregation-container {
display: flex;
flex-direction: column;
gap: 8px;
}
&-add-ons-container {
width: 100%;
display: flex;
flex-direction: row;
gap: 16px;
}
&-add-ons-input {
position: relative;
display: flex;
align-items: center;
flex-direction: row;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
&::before {
content: '';
position: absolute;
left: -16px;
top: 50%;
height: 1px;
width: 16px;
background-color: var(--bg-slate-400);
}
.label {
color: var(--bg-vanilla-400);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0px 8px;
border-right: 1px solid var(--bg-slate-400);
}
}
}
.lightMode {
.qb-trace-operator {
&-arrow {
&::before {
background: repeating-linear-gradient(
to right,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
&::after {
background-color: var(--bg-vanilla-300);
}
}
&.non-list-view {
&::before {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
}
&-add-ons-input {
border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100) !important;
.label {
color: var(--bg-ink-500) !important;
border-right: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100) !important;
}
}
}
}

View File

@@ -0,0 +1,157 @@
/* eslint-disable react/require-default-props */
/* eslint-disable sonarjs/no-duplicate-string */
import './TraceOperator.styles.scss';
import { Button, Select, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAddOns from '../QueryAddOns/QueryAddOns';
import QueryAggregation from '../QueryAggregation/QueryAggregation';
export default function TraceOperator({
traceOperator,
isListViewPanel = false,
}: {
traceOperator: IBuilderTraceOperator;
isListViewPanel?: boolean;
}): JSX.Element {
const { panelType, currentQuery, removeTraceOperator } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: traceOperator,
entityVersion: '',
isForTraceOperator: true,
});
const handleTraceOperatorChange = useCallback(
(traceOperatorExpression: string) => {
handleChangeQueryData('expression', traceOperatorExpression);
},
[handleChangeQueryData],
);
const handleChangeAggregateEvery = useCallback(
(value: IBuilderQuery['stepInterval']) => {
handleChangeQueryData('stepInterval', value);
},
[handleChangeQueryData],
);
const handleChangeAggregation = useCallback(
(value: string) => {
handleChangeQueryData('aggregations', [
{
expression: value,
},
]);
},
[handleChangeQueryData],
);
const handleChangeSpanSource = useCallback(
(value: string) => {
handleChangeQueryData('returnSpansFrom', value);
},
[handleChangeQueryData],
);
const defaultSpanSource = useMemo(
() =>
traceOperator.returnSpansFrom ||
currentQuery.builder.queryData[0].queryName ||
'',
[currentQuery.builder.queryData, traceOperator?.returnSpansFrom],
);
const spanSourceOptions = useMemo(
() =>
currentQuery.builder.queryData.map((query) => ({
value: query.queryName,
label: (
<div className="qb-trace-operator-span-source-label">
<span className="qb-trace-operator-span-source-label-query">Query</span>
<p className="qb-trace-operator-span-source-label-query-name">
{query.queryName}
</p>
</div>
),
})),
[currentQuery.builder.queryData],
);
return (
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
<div className="qb-trace-operator-container">
<InputWithLabel
className={cx(
'qb-trace-operator-input',
!isListViewPanel && 'qb-trace-operator-arrow',
)}
initialValue={traceOperator?.expression || ''}
label="TRACES MATCHING"
placeholder="Add condition..."
type="text"
onChange={handleTraceOperatorChange}
/>
{!isListViewPanel && (
<div className="qb-trace-operator-aggregation-container">
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
<QueryAggregation
dataSource={DataSource.TRACES}
key={`query-search-${traceOperator.queryName}`}
panelType={panelType || undefined}
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
queryData={traceOperator}
/>
</div>
<div
className={cx(
'qb-trace-operator-add-ons-container',
!isListViewPanel && 'qb-trace-operator-arrow',
)}
>
<QueryAddOns
index={0}
query={traceOperator}
version="v3"
isForTraceOperator
isListViewPanel={false}
showReduceTo={false}
panelType={panelType}
>
<div className="qb-trace-operator-add-ons-input">
<Typography.Text className="label">Using spans from</Typography.Text>
<Select
bordered={false}
defaultValue={defaultSpanSource}
style={{ minWidth: 120 }}
onChange={handleChangeSpanSource}
options={spanSourceOptions}
listItemHeight={24}
/>
</div>
</QueryAddOns>
</div>
</div>
)}
</div>
<Tooltip title="Remove Trace Operator" placement="topLeft">
<Button className="periscope-btn ghost" onClick={removeTraceOperator}>
<Trash2 size={14} />
</Button>
</Tooltip>
</div>
);
}

View File

@@ -1,27 +1,9 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
} from '../utils';
jest.mock('utils/queryContextUtils', () => ({
extractQueryPairs: jest.fn(),
}));
// Type the mocked functions
const mockExtractQueryPairs = extractQueryPairs as jest.MockedFunction<
typeof extractQueryPairs
>;
import { convertFiltersToExpression } from '../utils';
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle empty, null, and undefined inputs', () => {
// Test null and undefined
expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' });
@@ -551,364 +533,4 @@ describe('convertFiltersToExpression', () => {
"user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
});
});
it('should return filters with new expression when no existing query', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS['='],
value: 'test-service',
},
],
op: 'AND',
};
const result = convertFiltersToExpressionWithExistingQuery(
filters,
undefined,
);
expect(result.filters).toEqual(filters);
expect(result.filter.expression).toBe("service.name = 'test-service'");
});
it('should handle empty filters', () => {
const filters = {
items: [],
op: 'AND',
};
const result = convertFiltersToExpressionWithExistingQuery(
filters,
undefined,
);
expect(result.filters).toEqual(filters);
expect(result.filter.expression).toBe('');
});
it('should handle existing query with matching filters', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS['='],
value: 'updated-service',
},
],
op: 'AND',
};
const existingQuery = "service.name = 'old-service'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: OPERATORS['='],
value: "'old-service'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 13,
valueStart: 15,
valueEnd: 28,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters).toBeDefined();
expect(result.filter).toBeDefined();
expect(result.filter.expression).toBe("service.name = 'updated-service'");
expect(mockExtractQueryPairs).toHaveBeenCalledWith(
"service.name = 'old-service'",
);
});
it('should handle IN operator with existing query', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS.IN,
value: ['service1', 'service2'],
},
],
op: 'AND',
};
const existingQuery = "service.name IN ['old-service']";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: 'IN',
value: "['old-service']",
valueList: ["'old-service'"],
valuesPosition: [
{
start: 17,
end: 29,
},
],
hasNegation: false,
isMultiValue: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 14,
valueStart: 16,
valueEnd: 30,
negationStart: 0,
negationEnd: 0,
},
isComplete: true,
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters).toBeDefined();
expect(result.filter).toBeDefined();
expect(result.filter.expression).toBe(
"service.name IN ['service1', 'service2']",
);
});
it('should handle IN operator conversion from equals', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: OPERATORS.IN,
value: ['service1', 'service2'],
},
],
op: 'AND',
};
const existingQuery = "service.name = 'old-service'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: OPERATORS['='],
value: "'old-service'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 13,
valueStart: 15,
valueEnd: 28,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters.items).toHaveLength(1);
expect(result.filter.expression).toBe(
"service.name IN ['service1', 'service2'] ",
);
});
it('should handle NOT IN operator conversion from not equals', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'service.name', key: 'service.name', type: 'string' },
op: negateOperator(OPERATORS.IN),
value: ['service1', 'service2'],
},
],
op: 'AND',
};
const existingQuery = "service.name != 'old-service'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: OPERATORS['!='],
value: "'old-service'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 14,
valueStart: 16,
valueEnd: 28,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters.items).toHaveLength(1);
expect(result.filter.expression).toBe(
"service.name NOT IN ['service1', 'service2'] ",
);
});
it('should add new filters when they do not exist in existing query', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'new.key', key: 'new.key', type: 'string' },
op: OPERATORS['='],
value: 'new-value',
},
],
op: 'AND',
};
const existingQuery = "service.name = 'old-service'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: OPERATORS['='],
value: "'old-service'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 13,
valueStart: 15,
valueEnd: 28,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters.items).toHaveLength(2); // Original + new filter
expect(result.filter.expression).toBe(
"service.name = 'old-service' new.key = 'new-value'",
);
});
it('should handle simple value replacement', () => {
const filters = {
items: [
{
id: '1',
key: { id: 'status', key: 'status', type: 'string' },
op: OPERATORS['='],
value: 'error',
},
],
op: 'AND',
};
const existingQuery = "status = 'success'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'status',
operator: OPERATORS['='],
value: "'success'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 6,
operatorStart: 7,
operatorEnd: 7,
valueStart: 9,
valueEnd: 19,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters.items).toHaveLength(1);
expect(result.filter.expression).toBe("status = 'error'");
});
it('should handle filters with no key gracefully', () => {
const filters = {
items: [
{
id: '1',
key: undefined,
op: OPERATORS['='],
value: 'test-value',
},
],
op: 'AND',
};
const existingQuery = "service.name = 'old-service'";
mockExtractQueryPairs.mockReturnValue([
{
key: 'service.name',
operator: OPERATORS['='],
value: "'old-service'",
hasNegation: false,
isMultiValue: false,
isComplete: true,
position: {
keyStart: 0,
keyEnd: 11,
operatorStart: 13,
operatorEnd: 13,
valueStart: 15,
valueEnd: 28,
},
},
]);
const result = convertFiltersToExpressionWithExistingQuery(
filters,
existingQuery,
);
expect(result.filters.items).toHaveLength(2);
expect(result.filter.expression).toBe("service.name = 'old-service'");
});
});

View File

@@ -6,7 +6,7 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
import { cloneDeep } from 'lodash-es';
import { IQueryPair } from 'types/antlrQueryTypes';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -163,19 +163,6 @@ export const convertExpressionToFilters = (
return filters;
};
const getQueryPairsMap = (query: string): Map<string, IQueryPair> => {
const queryPairs = extractQueryPairs(query);
const queryPairsMap: Map<string, IQueryPair> = new Map();
queryPairs.forEach((pair) => {
const key = pair.hasNegation
? `${pair.key}-not ${pair.operator}`.trim().toLowerCase()
: `${pair.key}-${pair.operator}`.trim().toLowerCase();
queryPairsMap.set(key, pair);
});
return queryPairsMap;
};
export const convertFiltersToExpressionWithExistingQuery = (
filters: TagFilter,
@@ -208,12 +195,24 @@ export const convertFiltersToExpressionWithExistingQuery = (
};
}
// Extract query pairs from the existing query
const queryPairs = extractQueryPairs(existingQuery.trim());
let queryPairsMap: Map<string, IQueryPair> = new Map();
const nonExistingFilters: TagFilterItem[] = [];
let modifiedQuery = existingQuery; // We'll modify this query as we proceed
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs
// Map extracted query pairs to key-specific pair information for faster access
let queryPairsMap = getQueryPairsMap(existingQuery.trim());
if (queryPairs.length > 0) {
queryPairsMap = new Map(
queryPairs.map((pair) => {
const key = pair.hasNegation
? `${pair.key}-not ${pair.operator}`.trim().toLowerCase()
: `${pair.key}-${pair.operator}`.trim().toLowerCase();
return [key, pair];
}),
);
}
filters?.items?.forEach((filter) => {
const { key, op, value } = filter;
@@ -242,37 +241,10 @@ export const convertFiltersToExpressionWithExistingQuery = (
existingPair.position?.valueEnd
) {
visitedPairs.add(`${key.key}-${op}`.trim().toLowerCase());
// Check if existing values match current filter values (for array-based operators)
if (existingPair.valueList && filter.value && Array.isArray(filter.value)) {
// Clean quotes from string values for comparison
const cleanValues = (values: any[]): any[] =>
values.map((val) => (typeof val === 'string' ? unquote(val) : val));
const cleanExistingValues = cleanValues(existingPair.valueList);
const cleanFilterValues = cleanValues(filter.value);
// Compare arrays (order-independent) - if identical, keep existing value
const isSameValues =
cleanExistingValues.length === cleanFilterValues.length &&
isEqual(sortBy(cleanExistingValues), sortBy(cleanFilterValues));
if (isSameValues) {
// Values are identical, preserve existing formatting
modifiedQuery =
modifiedQuery.slice(0, existingPair.position.valueStart) +
existingPair.value +
modifiedQuery.slice(existingPair.position.valueEnd + 1);
return;
}
}
modifiedQuery =
modifiedQuery.slice(0, existingPair.position.valueStart) +
formattedValue +
modifiedQuery.slice(existingPair.position.valueEnd + 1);
queryPairsMap = getQueryPairsMap(modifiedQuery);
return;
}
@@ -298,7 +270,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
notInPair.position.valueEnd + 1,
)}`;
queryPairsMap = getQueryPairsMap(modifiedQuery.trim());
}
shouldAddToNonExisting = false; // Don't add this to non-existing filters
} else if (
@@ -315,7 +286,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
equalsPair.position.valueEnd + 1,
)}`;
queryPairsMap = getQueryPairsMap(modifiedQuery);
}
shouldAddToNonExisting = false; // Don't add this to non-existing filters
} else if (
@@ -332,7 +302,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
notEqualsPair.position.valueEnd + 1,
)}`;
queryPairsMap = getQueryPairsMap(modifiedQuery);
}
shouldAddToNonExisting = false; // Don't add this to non-existing filters
}
@@ -354,7 +323,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
} ${formattedValue} ${modifiedQuery.slice(
notEqualsPair.position.valueEnd + 1,
)}`;
queryPairsMap = getQueryPairsMap(modifiedQuery);
}
shouldAddToNonExisting = false; // Don't add this to non-existing filters
}
@@ -367,23 +335,6 @@ export const convertFiltersToExpressionWithExistingQuery = (
if (
queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase())
) {
const existingPair = queryPairsMap.get(
`${filter.key?.key}-${filter.op}`.trim().toLowerCase(),
);
if (
existingPair &&
existingPair.position?.valueStart &&
existingPair.position?.valueEnd
) {
const formattedValue = formatValueForExpression(value, op);
// replace the value with the new value
modifiedQuery =
modifiedQuery.slice(0, existingPair.position.valueStart) +
formattedValue +
modifiedQuery.slice(existingPair.position.valueEnd + 1);
queryPairsMap = getQueryPairsMap(modifiedQuery);
}
visitedPairs.add(`${filter.key?.key}-${filter.op}`.trim().toLowerCase());
}

View File

@@ -17,6 +17,19 @@
font-weight: var(--font-weight-normal);
}
.view-title-container {
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
.icon-container {
display: flex;
align-items: center;
justify-content: center;
}
}
.tab {
border: 1px solid var(--bg-slate-400);
&:hover {

View File

@@ -6,6 +6,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
icon?: React.ReactNode;
}
interface SignozRadioGroupProps {
@@ -37,7 +38,10 @@ function SignozRadioGroup({
value={option.value}
className={value === option.value ? 'selected_view tab' : 'tab'}
>
{option.label}
<div className="view-title-container">
{option.icon && <div className="icon-container">{option.icon}</div>}
{option.label}
</div>
</Radio.Button>
))}
</Radio.Group>

View File

@@ -46,7 +46,6 @@ export enum QueryParams {
msgSystem = 'msgSystem',
destination = 'destination',
kindString = 'kindString',
summaryFilters = 'summaryFilters',
tab = 'tab',
thresholds = 'thresholds',
selectedExplorerView = 'selectedExplorerView',

View File

@@ -12,6 +12,7 @@ import {
HavingForm,
IBuilderFormula,
IBuilderQuery,
IBuilderTraceOperator,
IClickHouseQuery,
IPromQLQuery,
Query,
@@ -50,6 +51,8 @@ import {
export const MAX_FORMULAS = 20;
export const MAX_QUERIES = 26;
export const TRACE_OPERATOR_QUERY_NAME = 'T1';
export const idDivider = '--';
export const selectValueDivider = '__';
@@ -265,6 +268,11 @@ export const initialFormulaBuilderFormValues: IBuilderFormula = {
legend: '',
};
export const initialQueryBuilderFormTraceOperatorValues: IBuilderTraceOperator = {
...initialQueryBuilderFormTracesValues,
queryName: TRACE_OPERATOR_QUERY_NAME,
};
export const initialQueryPromQLData: IPromQLQuery = {
name: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
query: '',
@@ -282,6 +290,7 @@ export const initialClickHouseData: IClickHouseQuery = {
export const initialQueryBuilderData: QueryBuilderData = {
queryData: [initialQueryBuilderFormValues],
queryFormulas: [],
queryTraceOperator: [],
};
export const initialSingleQueryMap: Record<

View File

@@ -54,6 +54,7 @@ function QuerySection({
queryVariant: 'static',
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
}}
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
showFunctions={
(alertType === AlertTypes.METRICS_BASED_ALERT &&
alertDef.version === ENTITY_VERSION_V4) ||

View File

@@ -150,6 +150,7 @@ function FormAlertRules({
const queryOptions = useMemo(() => {
const queryConfig: Record<EQueryType, () => SelectProps['options']> = {
// TODO: Filter out queries who are used in trace operator
[EQueryType.QUERY_BUILDER]: () => [
...(getSelectedQueryOptions(currentQuery.builder.queryData) || []),
...(getSelectedQueryOptions(currentQuery.builder.queryFormulas) || []),

View File

@@ -23,7 +23,7 @@ export const flattenLabels = (labels: Labels): ILabelRecord[] => {
if (!hiddenLabels.includes(key)) {
recs.push({
key,
value: labels[key] || '',
value: labels[key],
});
}
});

View File

@@ -4,9 +4,7 @@
overflow-y: hidden;
.full-view-header-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 40px;
}
.graph-container {

View File

@@ -1,4 +1,3 @@
/* eslint-disable sonarjs/cognitive-complexity */
import './WidgetFullView.styles.scss';
import {
@@ -9,31 +8,24 @@ import {
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import { ToggleGraphProps } from 'components/Graph/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import Spinner from 'components/Spinner';
import TimePreference from 'components/TimePreferenceDropDown';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import {
timeItems,
timePreferance,
} from 'container/NewWidget/RightContainer/timeItems';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useChartMutable } from 'hooks/useChartMutable';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -60,7 +52,6 @@ function FullView({
onClickHandler,
customOnDragSelect,
setCurrentGraphRef,
enableDrillDown = false,
}: FullViewProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedTime: globalSelectedTime } = useSelector<
@@ -72,16 +63,12 @@ function FullView({
const location = useLocation();
const fullViewRef = useRef<HTMLDivElement>(null);
const { handleRunQuery } = useQueryBuilder();
useEffect(() => {
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { selectedDashboard, isDashboardLocked } = useDashboard();
const { user } = useAppContext();
const [editWidget] = useComponentPermission(['edit_widget'], user.role);
const getSelectedTime = useCallback(
() =>
@@ -127,13 +114,6 @@ function FullView({
};
});
const { dashboardEditView, handleResetQuery, showResetQuery } = useDrilldown({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
});
useEffect(() => {
setRequestData((prev) => ({
...prev,
@@ -224,117 +204,71 @@ function FullView({
return (
<div className="full-view-container">
<OverlayScrollbar>
<>
<div className="full-view-header-container">
{fullViewOptions && (
<TimeContainer $panelType={widget.panelTypes}>
{enableDrillDown && (
<div className="drildown-options-container">
{showResetQuery && (
<Button type="link" onClick={handleResetQuery}>
Reset Query
</Button>
)}
{editWidget && (
<Button
className="switch-edit-btn"
disabled={response.isFetching || response.isLoading}
onClick={(): void => {
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
}}
>
Switch to Edit Mode
</Button>
)}
</div>
)}
<div className="time-container">
{response.isFetching && (
<Spin spinning indicator={<LoadingOutlined spin />} />
)}
<TimePreference
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
/>
<Button
style={{
marginLeft: '4px',
}}
onClick={(): void => {
response.refetch();
}}
type="primary"
icon={<SyncOutlined />}
/>
</div>
</TimeContainer>
<div className="full-view-header-container">
{fullViewOptions && (
<TimeContainer $panelType={widget.panelTypes}>
{response.isFetching && (
<Spin spinning indicator={<LoadingOutlined spin />} />
)}
{enableDrillDown && (
<>
<QueryBuilderV2
panelType={widget.panelTypes}
version={selectedDashboard?.data?.version || 'v3'}
isListViewPanel={widget.panelTypes === PANEL_TYPES.LIST}
// filterConfigs={filterConfigs}
// queryComponents={queryComponents}
/>
<RightToolbarActions
onStageRunQuery={(): void => {
handleRunQuery(true, true);
}}
/>
</>
)}
</div>
<div
className={cx('graph-container', {
disabled: isDashboardLocked,
'height-widget':
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
})}
ref={fullViewRef}
>
<GraphContainer
<TimePreference
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
/>
<Button
style={{
height: isListView ? '100%' : '90%',
marginLeft: '4px',
}}
isGraphLegendToggleAvailable={canModifyChart}
>
{isTablePanel && (
<Input
addonBefore={<SearchOutlined size={14} />}
className="global-search"
placeholder="Search..."
allowClear
key={widget.id}
onChange={(e): void => {
setSearchTerm(e.target.value || '');
}}
/>
)}
<PanelWrapper
queryResponse={response}
widget={widget}
setRequestData={setRequestData}
isFullViewMode
onToggleModelHandler={onToggleModelHandler}
setGraphVisibility={setGraphsVisibilityStates}
graphVisibility={graphsVisibilityStates}
onDragSelect={customOnDragSelect ?? onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
searchTerm={searchTerm}
onClickHandler={onClickHandler}
enableDrillDown={enableDrillDown}
/>
</GraphContainer>
</div>
</>
</OverlayScrollbar>
onClick={(): void => {
response.refetch();
}}
type="primary"
icon={<SyncOutlined />}
/>
</TimeContainer>
)}
</div>
<div
className={cx('graph-container', {
disabled: isDashboardLocked,
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
'list-graph-container': isListView,
})}
ref={fullViewRef}
>
<GraphContainer
style={{
height: isListView ? '100%' : '90%',
}}
isGraphLegendToggleAvailable={canModifyChart}
>
{isTablePanel && (
<Input
addonBefore={<SearchOutlined size={14} />}
className="global-search"
placeholder="Search..."
allowClear
key={widget.id}
onChange={(e): void => {
setSearchTerm(e.target.value || '');
}}
/>
)}
<PanelWrapper
queryResponse={response}
widget={widget}
setRequestData={setRequestData}
isFullViewMode
onToggleModelHandler={onToggleModelHandler}
setGraphVisibility={setGraphsVisibilityStates}
graphVisibility={graphsVisibilityStates}
onDragSelect={customOnDragSelect ?? onDragSelect}
tableProcessedDataRef={tableProcessedDataRef}
searchTerm={searchTerm}
onClickHandler={onClickHandler}
/>
</GraphContainer>
</div>
</div>
);
}

View File

@@ -18,7 +18,6 @@ export const NotFoundContainer = styled.div`
export const TimeContainer = styled.div<Props>`
display: flex;
justify-content: flex-end;
gap: 16px;
align-items: center;
${({ $panelType }): FlattenSimpleInterpolation =>
$panelType === PANEL_TYPES.TABLE
@@ -26,10 +25,6 @@ export const TimeContainer = styled.div<Props>`
margin-bottom: 1rem;
`
: css``}
.time-container {
display: flex;
}
`;
export const GraphContainer = styled.div<GraphContainerProps>`

View File

@@ -59,7 +59,6 @@ export interface FullViewProps {
isDependedDataLoaded?: boolean;
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
enableDrillDown?: boolean;
}
export interface GraphManagerProps extends UplotProps {

View File

@@ -1,84 +0,0 @@
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
export interface DrilldownQueryProps {
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
enableDrillDown: boolean;
selectedDashboard: Dashboard | undefined;
}
export interface UseDrilldownReturn {
dashboardEditView: string;
handleResetQuery: () => void;
showResetQuery: boolean;
}
const useDrilldown = ({
enableDrillDown,
widget,
setRequestData,
selectedDashboard,
}: DrilldownQueryProps): UseDrilldownReturn => {
const isMounted = useRef(false);
const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
useEffect(() => {
if (enableDrillDown && !!compositeQuery) {
setRequestData((prev) => ({
...prev,
query: compositeQuery,
}));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentQuery, compositeQuery]);
// update composite query with widget query if composite query is not present in url.
// Composite query should be in the url if switch to edit mode is clicked or drilldown happens from dashboard.
useEffect(() => {
if (enableDrillDown && !isMounted.current) {
redirectWithQueryBuilderData(compositeQuery || widget.query);
}
isMounted.current = true;
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
const dashboardEditView = selectedDashboard?.id
? generateExportToDashboardLink({
query: currentQuery,
panelType: widget.panelTypes,
dashboardId: selectedDashboard?.id || '',
widgetId: widget.id,
})
: '';
const showResetQuery = useMemo(
() =>
JSON.stringify(widget.query?.builder) !==
JSON.stringify(compositeQuery?.builder),
[widget.query, compositeQuery],
);
const handleResetQuery = useCallback((): void => {
redirectWithQueryBuilderData(widget.query);
}, [redirectWithQueryBuilderData, widget.query]);
return {
dashboardEditView,
handleResetQuery,
showResetQuery,
};
};
export default useDrilldown;

View File

@@ -62,7 +62,6 @@ function WidgetGraphComponent({
customErrorMessage,
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -237,7 +236,6 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {
const {
@@ -366,7 +364,6 @@ function WidgetGraphComponent({
onClickHandler={onClickHandler ?? graphClickHandler}
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={enableDrillDown}
/>
</Modal>
@@ -417,7 +414,6 @@ function WidgetGraphComponent({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customSeries={customSeries}
customOnRowClick={customOnRowClick}
enableDrillDown={enableDrillDown}
/>
</div>
)}
@@ -430,7 +426,6 @@ WidgetGraphComponent.defaultProps = {
setLayout: undefined,
onClickHandler: undefined,
customTimeRangeWindowForCoRelation: undefined,
enableDrillDown: false,
};
export default WidgetGraphComponent;

View File

@@ -53,7 +53,6 @@ function GridCardGraph({
customTimeRange,
customOnRowClick,
customTimeRangeWindowForCoRelation,
enableDrillDown,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -318,7 +317,6 @@ function GridCardGraph({
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
customOnRowClick={customOnRowClick}
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
enableDrillDown={enableDrillDown}
/>
)}
</div>
@@ -334,7 +332,6 @@ GridCardGraph.defaultProps = {
version: 'v3',
analyticsEvent: undefined,
customTimeRangeWindowForCoRelation: undefined,
enableDrillDown: false,
};
export default memo(GridCardGraph);

View File

@@ -41,7 +41,6 @@ export interface WidgetGraphComponentProps {
customErrorMessage?: string;
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
}
export interface GridCardGraphProps {
@@ -70,7 +69,6 @@ export interface GridCardGraphProps {
};
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
enableDrillDown?: boolean;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -53,12 +53,11 @@ import { WidgetRowHeader } from './WidgetRow';
interface GraphLayoutProps {
handle: FullScreenHandle;
enableDrillDown?: boolean;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function GraphLayout(props: GraphLayoutProps): JSX.Element {
const { handle, enableDrillDown = false } = props;
const { handle } = props;
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
@@ -585,7 +584,6 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
enableDrillDown={enableDrillDown}
/>
</Card>
</CardContainer>
@@ -672,7 +670,3 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}
export default GraphLayout;
GraphLayout.defaultProps = {
enableDrillDown: false,
};

View File

@@ -4,17 +4,10 @@ import GraphLayoutContainer from './GridCardLayout';
interface GridGraphProps {
handle: FullScreenHandle;
enableDrillDown?: boolean;
}
function GridGraph(props: GridGraphProps): JSX.Element {
const { handle, enableDrillDown = false } = props;
return (
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
);
const { handle } = props;
return <GraphLayoutContainer handle={handle} />;
}
export default GridGraph;
GridGraph.defaultProps = {
enableDrillDown: false,
};

View File

@@ -46,7 +46,6 @@ function GridTableComponent({
onOpenTraceBtnClick,
customOnRowClick,
widgetId,
panelType,
...props
}: GridTableComponentProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
@@ -267,7 +266,6 @@ function GridTableComponent({
dataSource={dataSource}
sticky={sticky}
widgetId={widgetId}
panelType={panelType}
onRow={
openTracesButton || customOnRowClick
? (record): React.HTMLAttributes<HTMLElement> => ({

View File

@@ -1,5 +1,4 @@
import { TableProps } from 'antd';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
import {
ThresholdOperators,
@@ -7,7 +6,7 @@ import {
} from 'container/NewWidget/RightContainer/Threshold/types';
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ColumnUnit, ContextLinksData } from 'types/api/dashboard/getAll';
import { ColumnUnit } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export type GridTableComponentProps = {
@@ -23,9 +22,6 @@ export type GridTableComponentProps = {
widgetId?: string;
renderColumnCell?: QueryTableProps['renderColumnCell'];
customColTitles?: Record<string, string>;
enableDrillDown?: boolean;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
} & Pick<LogsExplorerTableProps, 'data'> &
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { ColumnType } from 'antd/es/table';
import { ColumnsType, ColumnType } from 'antd/es/table';
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
@@ -9,12 +9,6 @@ import { isEmpty, isNaN } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
// Custom column type that extends ColumnType to include isValueColumn
export interface CustomDataColumnType<T> extends ColumnType<T> {
isValueColumn?: boolean;
queryName?: string;
}
// Helper function to evaluate the condition based on the operator
function evaluateCondition(
operator: string | undefined,
@@ -186,9 +180,9 @@ export function createColumnsAndDataSource(
data: TableData,
currentQuery: Query,
renderColumnCell?: QueryTableProps['renderColumnCell'],
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
const columns: CustomDataColumnType<RowData>[] =
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
const columns: ColumnsType<RowData> =
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
// is the column is the value column then we need to check for the available legend
const legend = item.isValueColumn
? getQueryLegend(currentQuery, item.queryName)
@@ -199,13 +193,11 @@ export function createColumnsAndDataSource(
(query) => query.queryName === item.queryName,
)?.aggregations?.length || 0;
const column: CustomDataColumnType<RowData> = {
const column: ColumnType<RowData> = {
dataIndex: item.id || item.name,
// if no legend present then rely on the column name value
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
width: QUERY_TABLE_CONFIG.width,
isValueColumn: item.isValueColumn,
queryName: item.queryName,
render: renderColumnCell && renderColumnCell[item.name],
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
};

View File

@@ -272,11 +272,12 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
width: 80,
key: 'severity',
sorter: (a, b): number =>
(a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0),
(a.labels ? a.labels.severity.length : 0) -
(b.labels ? b.labels.severity.length : 0),
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const objectKeys = Object.keys(value);
const withSeverityKey = objectKeys.find((e) => e === 'severity') || '';
const severityValue = withSeverityKey ? value[withSeverityKey] : '-';
const severityValue = value[withSeverityKey];
return <Typography>{severityValue}</Typography>;
},
@@ -289,7 +290,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
align: 'center',
width: 100,
render: (value): JSX.Element => {
const objectKeys = value ? Object.keys(value) : [];
const objectKeys = Object.keys(value);
const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity');
if (withOutSeverityKeys.length === 0) {

View File

@@ -50,6 +50,7 @@ function LogsExplorerList({
isFilterApplied,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { activeLogId } = useCopyLogLink();
const {

View File

@@ -1,5 +1,4 @@
import GridGraphLayout from 'container/GridCardLayout';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { FullScreenHandle } from 'react-full-screen';
import { GridComponentSliderContainer } from './styles';
@@ -12,7 +11,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
const { handle } = props;
return (
<GridComponentSliderContainer>
<GridGraphLayout handle={handle} enableDrillDown={isDrilldownEnabled()} />
<GridGraphLayout handle={handle} />
</GridComponentSliderContainer>
);
}

View File

@@ -16,7 +16,6 @@ function WidgetGraphContainer({
setRequestData,
selectedWidget,
isLoadingPanelData,
enableDrillDown = false,
}: WidgetGraphContainerProps): JSX.Element {
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
@@ -87,7 +86,6 @@ function WidgetGraphContainer({
queryResponse={queryResponse}
setRequestData={setRequestData}
selectedGraph={selectedGraph}
enableDrillDown={enableDrillDown}
/>
);
}

View File

@@ -36,7 +36,6 @@ function WidgetGraph({
queryResponse,
setRequestData,
selectedGraph,
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const lineChartRef = useRef<ToggleGraphProps>();
@@ -189,7 +188,6 @@ function WidgetGraph({
onClickHandler={graphClickHandler}
graphVisibility={graphVisibility}
setGraphVisibility={setGraphVisibility}
enableDrillDown={enableDrillDown}
/>
</div>
);
@@ -203,11 +201,6 @@ interface WidgetGraphProps {
>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;
}
export default WidgetGraph;
WidgetGraph.defaultProps = {
enableDrillDown: false,
};

View File

@@ -21,7 +21,6 @@ function WidgetGraph({
setRequestData,
selectedWidget,
isLoadingPanelData,
enableDrillDown = false,
}: WidgetGraphContainerProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
@@ -58,7 +57,6 @@ function WidgetGraph({
queryResponse={queryResponse}
setRequestData={setRequestData}
selectedWidget={selectedWidget}
enableDrillDown={enableDrillDown}
/>
</Container>
);

View File

@@ -27,7 +27,6 @@ function LeftContainer({
setRequestData,
isLoadingPanelData,
setQueryResponse,
enableDrillDown = false,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
// const { selectedDashboard } = useDashboard();
@@ -65,7 +64,6 @@ function LeftContainer({
setRequestData={setRequestData}
selectedWidget={selectedWidget}
isLoadingPanelData={isLoadingPanelData}
enableDrillDown={enableDrillDown}
/>
<QueryContainer className="query-section-left-container">
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />

View File

@@ -36,11 +36,6 @@
}
}
.right-header {
display: flex;
gap: 16px;
}
.save-btn {
display: flex;
height: 32px;

View File

@@ -1,87 +0,0 @@
.context-link-form-container {
margin-top: 16px;
.form-label {
margin-left: 4px;
}
.add-url-parameter-btn {
display: flex;
align-items: center;
width: fit-content;
margin-top: 16px;
}
.url-parameters-section {
margin-top: 16px;
margin-bottom: 16px;
.parameter-header {
margin-bottom: 8px;
strong {
color: #666;
font-size: 14px;
}
}
.parameter-row {
margin-bottom: 8px;
align-items: center;
.ant-input {
border-radius: 4px;
}
.delete-parameter-btn {
color: var(--bg-vanilla-400);
padding: 4px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--bg-cherry-400) !important;
border-color: var(--bg-cherry-400) !important;
}
}
}
}
.params-container {
margin-left: 16px;
}
.context-link-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--bg-slate-400);
}
}
.lightMode {
.context-link-form-container {
.url-parameters-section {
.parameter-row {
.delete-parameter-btn {
color: var(--bg-slate-400);
&:hover {
color: var(--bg-cherry-500) !important;
border-color: var(--bg-cherry-500) !important;
background-color: var(--bg-cherry-100);
}
}
}
}
.context-link-footer {
border-top-color: var(--bg-vanilla-200);
}
}
}

View File

@@ -1,229 +0,0 @@
import './UpdateContextLinks.styles.scss';
import {
Button,
Col,
Form,
Input as AntInput,
Input,
Row,
Typography,
} from 'antd';
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
import {
getInitialValues,
getUrlParams,
updateUrlWithParams,
} from 'container/NewWidget/RightContainer/ContextLinks/utils';
import { Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ContextLinkProps } from 'types/api/dashboard/getAll';
const { TextArea } = AntInput;
interface UpdateContextLinksProps {
selectedContextLink: ContextLinkProps | null;
onSave: (newContextLink: ContextLinkProps) => void;
onCancel: () => void;
}
function UpdateContextLinks({
selectedContextLink,
onSave,
onCancel,
}: UpdateContextLinksProps): JSX.Element {
const [form] = Form.useForm();
const label = Form.useWatch(CONTEXT_LINK_FIELDS.LABEL, form);
const url = Form.useWatch(CONTEXT_LINK_FIELDS.URL, form);
const [params, setParams] = useState<
{
key: string;
value: string;
}[]
>([]);
// Function to get current domain
const getCurrentDomain = (): string => window.location.origin;
console.log('FORM VALUES', { label, url });
useEffect(() => {
((window as unknown) as Record<string, unknown>).form = form;
}, [form]);
// Parse URL and update params when URL changes
useEffect(() => {
if (url) {
const urlParams = getUrlParams(url);
setParams(urlParams);
}
}, [url]);
const handleSave = async (): Promise<void> => {
try {
// Validate form fields
await form.validateFields();
const newContextLink = {
id: form.getFieldValue(CONTEXT_LINK_FIELDS.ID),
label:
form.getFieldValue(CONTEXT_LINK_FIELDS.LABEL) ||
form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
url: form.getFieldValue(CONTEXT_LINK_FIELDS.URL),
};
// If validation passes, call onSave
onSave(newContextLink);
} catch (error) {
// Form validation failed, don't call onSave
console.log('Form validation failed:', error);
}
};
const handleAddUrlParameter = (): void => {
const isLastParamEmpty =
params.length > 0 &&
params[params.length - 1].key.trim() === '' &&
params[params.length - 1].value.trim() === '';
const canAddParam = params.length === 0 || !isLastParamEmpty;
if (canAddParam) {
const newParams = [
...params,
{
key: '',
value: '',
},
];
setParams(newParams);
const updatedUrl = updateUrlWithParams(url, newParams);
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
}
};
const handleDeleteParameter = (index: number): void => {
const newParams = params.filter((_, i) => i !== index);
setParams(newParams);
const updatedUrl = updateUrlWithParams(url, newParams);
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
};
const handleParamChange = (
index: number,
field: 'key' | 'value',
value: string,
): void => {
const newParams = [...params];
newParams[index][field] = value;
setParams(newParams);
const updatedUrl = updateUrlWithParams(url, newParams);
form.setFieldValue(CONTEXT_LINK_FIELDS.URL, updatedUrl);
};
return (
<div className="context-link-form-container">
<Form
form={form}
name="contextLink"
initialValues={getInitialValues(selectedContextLink)}
// onFinish={() => {}}
>
{/* //label */}
<Typography.Text className="form-label">Label</Typography.Text>
<Form.Item
name={CONTEXT_LINK_FIELDS.LABEL}
rules={[{ required: false, message: 'Please input the label' }]}
>
<Input placeholder="View Traces details: {{_traceId}}" />
</Form.Item>
{/* //url */}
<Typography.Text className="form-label">
URL <span className="required-asterisk">*</span>
</Typography.Text>
<Form.Item
name={CONTEXT_LINK_FIELDS.URL}
// label="URL"
rules={[
{ required: true, message: 'Please input the URL' },
{
pattern: /^(https?:\/\/|\/|{{.*}}\/)/,
message: 'URLs must start with http(s), /, or {{.*}}/',
},
]}
>
<Input
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
placeholder={`${getCurrentDomain()}/trace/{{_traceId}}`}
/>
</Form.Item>
</Form>
<div className="params-container">
{/* URL Parameters Section */}
{params.length > 0 && (
<div className="url-parameters-section">
<Row gutter={[8, 8]} className="parameter-header">
<Col span={11}>Key</Col>
<Col span={11}>Value</Col>
<Col span={2}>{/* Empty column for spacing */}</Col>
</Row>
{params.map((param, index) => (
// eslint-disable-next-line react/no-array-index-key
<Row gutter={[8, 8]} key={index} className="parameter-row">
<Col span={11}>
<Input
id={`param-key-${index}`}
placeholder="Key"
value={param.key}
onChange={(e): void =>
handleParamChange(index, 'key', e.target.value)
}
/>
</Col>
<Col span={11}>
<TextArea
rows={1}
placeholder="Value"
value={param.value}
onChange={(event): void =>
handleParamChange(index, 'value', event.target.value)
}
/>
</Col>
<Col span={2}>
<Button
type="text"
icon={<Trash2 size={14} />}
onClick={(): void => handleDeleteParameter(index)}
className="delete-parameter-btn"
/>
</Col>
</Row>
))}
</div>
)}
{/* Add URL parameter btn */}
<Button
type="primary"
className="add-url-parameter-btn"
icon={<Plus size={12} />}
onClick={handleAddUrlParameter}
>
Add URL parameter
</Button>
</div>
{/* Footer with Cancel and Save buttons */}
<div className="context-link-footer">
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
export default UpdateContextLinks;

View File

@@ -1,634 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import ContextLinks from '../index';
// Mock data for testing
const MOCK_EMPTY_CONTEXT_LINKS: ContextLinksData = {
linksData: [],
};
const MOCK_CONTEXT_LINKS: ContextLinksData = {
linksData: [
{
id: '1',
label: 'Dashboard 1',
url: 'https://example.com/dashboard1',
},
{
id: '2',
label: 'External Tool',
url: 'https://external.com/tool',
},
{
id: '3',
label: 'Grafana',
url: 'https://grafana.example.com',
},
],
};
// Test wrapper component
const renderWithProviders = (
component: React.ReactElement,
): ReturnType<typeof render> =>
render(
<Provider store={store}>
<MemoryRouter>{component}</MemoryRouter>
</Provider>,
);
describe('ContextLinks Component', () => {
describe('Component Rendering & Initial State', () => {
it('should render correctly with existing context links', () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Check that the component renders
expect(screen.getByText('Context Links')).toBeInTheDocument();
// Check that the add button is present
expect(
screen.getByRole('button', { name: /context link/i }),
).toBeInTheDocument();
// Check that all context link items are displayed
expect(screen.getByText('Dashboard 1')).toBeInTheDocument();
expect(screen.getByText('External Tool')).toBeInTheDocument();
expect(screen.getByText('Grafana')).toBeInTheDocument();
// Check that URLs are displayed
expect(
screen.getByText('https://example.com/dashboard1'),
).toBeInTheDocument();
expect(screen.getByText('https://external.com/tool')).toBeInTheDocument();
expect(screen.getByText('https://grafana.example.com')).toBeInTheDocument();
});
it('should show "Context Link" add button', () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Check that the add button is present and has correct text
const addButton = screen.getByRole('button', { name: /context link/i });
expect(addButton).toBeInTheDocument();
expect(addButton).toHaveTextContent('Context Link');
expect(addButton).toHaveClass('add-context-link-button');
});
});
describe('Add Context Link Functionality', () => {
it('should show "Add a context link" title in modal when adding new link', () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Check that modal content is displayed
expect(screen.getByText('Add a context link')).toBeInTheDocument();
// Check that save and cancel buttons are present
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('should call setContextLinks when saving new context link', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Fill in the form fields using placeholder text
const labelInput = screen.getByPlaceholderText(
'View Traces details: {{_traceId}}',
);
fireEvent.change(labelInput, { target: { value: 'New Link' } });
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
// Click save button in modal
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
// Wait for the modal to close and state to update
await waitFor(() => {
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
});
// Verify that setContextLinks was called
expect(mockSetContextLinks).toHaveBeenCalledTimes(1);
// setContextLinks is called with a function (state updater)
const setContextLinksCall = mockSetContextLinks.mock.calls[0][0];
expect(typeof setContextLinksCall).toBe('function');
// Test the function by calling it with the current state
const result = setContextLinksCall(MOCK_EMPTY_CONTEXT_LINKS);
expect(result).toEqual({
linksData: [
{
id: expect.any(String), // ID is generated dynamically
label: 'New Link',
url: 'https://example.com',
},
],
});
});
it('should close modal when cancel button is clicked', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Modal should be visible
expect(screen.getByText('Add a context link')).toBeInTheDocument();
// Click cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
// Modal should be closed
await waitFor(() => {
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
});
});
it('should not call setContextLinks when cancel button is clicked', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Click cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
// Wait for modal to close
await waitFor(() => {
expect(screen.queryByText('Add a context link')).not.toBeInTheDocument();
});
// Verify that setContextLinks was not called
expect(mockSetContextLinks).not.toHaveBeenCalled();
});
it('should show form fields in the modal', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Check that form field labels are present
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.getByText('URL')).toBeInTheDocument();
// Check that form field inputs are present using placeholder text
const labelInput = screen.getByPlaceholderText(
'View Traces details: {{_traceId}}',
);
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
expect(labelInput.tagName).toBe('INPUT');
expect(urlInput.tagName).toBe('INPUT');
});
it('should validate form fields before saving', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Click the add button to open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Try to save without filling required fields
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
// Form validation should prevent saving
await waitFor(() => {
expect(mockSetContextLinks).not.toHaveBeenCalled();
});
// Modal should still be open
expect(screen.getByText('Add a context link')).toBeInTheDocument();
});
it('should pre-populate form with existing data when editing a context link', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Find and click the edit button for the first context link using CSS class
const editButtons = document.querySelectorAll('.edit-context-link-btn');
expect(editButtons).toHaveLength(3); // Should have 3 edit buttons for 3 context links
fireEvent.click(editButtons[0]); // Click edit button for first link
// Modal should open with "Edit context link" title
expect(screen.getByText('Edit context link')).toBeInTheDocument();
// Form should be pre-populated with existing data from the first context link
const labelInput = screen.getByPlaceholderText(
'View Traces details: {{_traceId}}',
);
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
// Check that the form is pre-populated with the first context link's data
expect(labelInput).toHaveAttribute('value', 'Dashboard 1');
expect(urlInput).toHaveAttribute('value', 'https://example.com/dashboard1');
// Verify save and cancel buttons are present
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
describe('URL and Query Parameter Functionality', () => {
it('should parse URL with query parameters and display them in parameter table', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal to add new context link
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Type a URL with query parameters
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
const testUrl =
'https://example.com/api?param1=value1&param2=value2&param3=value3';
fireEvent.change(urlInput, { target: { value: testUrl } });
// Wait for parameter parsing and display
await waitFor(() => {
expect(screen.getByText('Key')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
// Verify all parameters are displayed
expect(screen.getByDisplayValue('param1')).toBeInTheDocument();
expect(screen.getByDisplayValue('value1')).toBeInTheDocument();
expect(screen.getByDisplayValue('param2')).toBeInTheDocument();
expect(screen.getByDisplayValue('value2')).toBeInTheDocument();
expect(screen.getByDisplayValue('param3')).toBeInTheDocument();
expect(screen.getByDisplayValue('value3')).toBeInTheDocument();
});
it('should add new URL parameter when "Add URL parameter" button is clicked', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Initially no parameters should be visible
expect(screen.queryByText('Key')).not.toBeInTheDocument();
// Click "Add URL parameter" button
const addParamButton = screen.getByRole('button', {
name: /add url parameter/i,
});
fireEvent.click(addParamButton);
// Parameter table should now be visible
await waitFor(() => {
expect(screen.getByText('Key')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
// Should have one empty parameter row
const keyInputs = screen.getAllByPlaceholderText('Key');
const valueInputs = screen.getAllByPlaceholderText('Value');
expect(keyInputs).toHaveLength(1);
expect(valueInputs).toHaveLength(1);
});
it('should update URL when parameter values are changed', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Add a parameter
const addParamButton = screen.getByRole('button', {
name: /add url parameter/i,
});
fireEvent.click(addParamButton);
// Fill in parameter key and value
const keyInput = screen.getByPlaceholderText('Key');
const valueInput = screen.getAllByPlaceholderText('Value')[0];
fireEvent.change(keyInput, { target: { value: 'search' } });
fireEvent.change(valueInput, { target: { value: 'query' } });
// URL should be updated with the parameter
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
) as HTMLInputElement;
expect(urlInput.value).toBe('?search=query');
});
it('should delete URL parameter when delete button is clicked', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Add a parameter
const addParamButton = screen.getByRole('button', {
name: /add url parameter/i,
});
fireEvent.click(addParamButton);
// Fill in parameter
const keyInput = screen.getByPlaceholderText('Key');
const valueInput = screen.getAllByPlaceholderText('Value')[0];
fireEvent.change(keyInput, { target: { value: 'test' } });
fireEvent.change(valueInput, { target: { value: 'value' } });
// Verify parameter is added
expect(screen.getByDisplayValue('test')).toBeInTheDocument();
// Click delete button for the parameter
const deleteButtons = screen.getAllByRole('button', { name: '' });
const deleteButton = deleteButtons.find((btn) =>
btn.className.includes('delete-parameter-btn'),
);
expect(deleteButton).toBeInTheDocument();
fireEvent.click(deleteButton!);
// Parameter should be removed
await waitFor(() => {
expect(screen.queryByDisplayValue('test')).not.toBeInTheDocument();
});
// URL should be cleaned up
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
) as HTMLInputElement;
expect(urlInput.value).toBe('');
});
it('should handle multiple parameters and maintain URL synchronization', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Add first parameter
const addParamButton = screen.getByRole('button', {
name: /add url parameter/i,
});
fireEvent.click(addParamButton);
// Fill first parameter
let keyInputs = screen.getAllByPlaceholderText('Key');
let valueInputs = screen.getAllByPlaceholderText('Value');
fireEvent.change(keyInputs[0], { target: { value: 'page' } });
fireEvent.change(valueInputs[0], { target: { value: '1' } });
// Add second parameter
fireEvent.click(addParamButton);
// Get updated inputs after adding second parameter
keyInputs = screen.getAllByPlaceholderText('Key');
valueInputs = screen.getAllByPlaceholderText('Value');
// Fill second parameter
fireEvent.change(keyInputs[1], { target: { value: 'size' } });
fireEvent.change(valueInputs[1], { target: { value: '10' } });
// URL should contain both parameters
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
) as HTMLInputElement;
expect(urlInput.value).toBe('?page=1&size=10');
// Change first parameter value
fireEvent.change(valueInputs[0], { target: { value: '2' } });
// URL should be updated
expect(urlInput.value).toBe('?page=2&size=10');
});
it('should validate URL format and show appropriate error messages', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Try to save with invalid URL
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
fireEvent.change(urlInput, { target: { value: 'invalid-url' } });
// Try to save
const saveButton = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveButton);
// Should show validation error
await waitFor(() => {
expect(
screen.getByText('URLs must start with http(s), /, or {{.*}}/'),
).toBeInTheDocument();
});
// setContextLinks should not be called due to validation failure
expect(mockSetContextLinks).not.toHaveBeenCalled();
});
it('should handle special characters in parameter keys and values correctly', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Add parameter with special characters
const addParamButton = screen.getByRole('button', {
name: /add url parameter/i,
});
fireEvent.click(addParamButton);
// Fill parameter with special characters
const keyInput = screen.getByPlaceholderText('Key');
const valueInput = screen.getAllByPlaceholderText('Value')[0];
fireEvent.change(keyInput, { target: { value: 'user@domain' } });
fireEvent.change(valueInput, { target: { value: 'John Doe & Co.' } });
// URL should be properly encoded
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
) as HTMLInputElement;
expect(urlInput.value).toBe('?user%40domain=John%20Doe%20%26%20Co.');
});
it('should support template variables in URL and parameters', async () => {
const mockSetContextLinks = jest.fn();
renderWithProviders(
<ContextLinks
contextLinks={MOCK_EMPTY_CONTEXT_LINKS}
setContextLinks={mockSetContextLinks}
/>,
);
// Open modal
const addButton = screen.getByRole('button', { name: /context link/i });
fireEvent.click(addButton);
// Type URL with template variable
const urlInput = screen.getByPlaceholderText(
'http://localhost/trace/{{_traceId}}',
);
const testUrl =
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}';
fireEvent.change(urlInput, { target: { value: testUrl } });
// Wait for parameter parsing
await waitFor(() => {
expect(screen.getByText('Key')).toBeInTheDocument();
});
// Should parse template variable as parameter
expect(screen.getByDisplayValue('service')).toBeInTheDocument();
expect(screen.getByDisplayValue('{{_serviceName}}')).toBeInTheDocument();
// URL should maintain template variables
expect((urlInput as HTMLInputElement).value).toBe(
'https://example.com/trace/{{_traceId}}?service={{_serviceName}}',
);
});
});
});

View File

@@ -1,6 +0,0 @@
export const CONTEXT_LINK_FIELDS = {
ID: 'id',
LABEL: 'label',
URL: 'url',
// OPEN_IN_NEW_TAB: 'openInNewTab'
};

View File

@@ -1,188 +0,0 @@
/* eslint-disable react/jsx-props-no-spreading */
import './styles.scss';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { ContextLinkProps, ContextLinksData } from 'types/api/dashboard/getAll';
import UpdateContextLinks from './UpdateContextLinks';
import useContextLinkModal from './useContextLinkModal';
function SortableContextLink({
contextLink,
onDelete,
onEdit,
}: {
contextLink: ContextLinkProps;
onDelete: (contextLink: ContextLinkProps) => void;
onEdit: (contextLink: ContextLinkProps) => void;
}): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: contextLink.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className="context-link-item drag-enabled"
>
<div {...attributes} {...listeners} className="drag-handle">
<div className="drag-handle-icon">
<GripVertical size={16} />
</div>
<div className="context-link-content">
<span className="context-link-label">{contextLink.label}</span>
<span className="context-link-url">{contextLink.url}</span>
</div>
</div>
<div className="context-link-actions">
<Button
className="edit-context-link-btn periscope-btn"
size="small"
icon={<Pencil size={12} />}
onClick={(): void => {
onEdit(contextLink);
}}
/>
<Button
className="delete-context-link-btn periscope-btn"
size="small"
icon={<Trash2 size={12} />}
onClick={(): void => {
onDelete(contextLink);
}}
/>
</div>
</div>
);
}
function ContextLinks({
contextLinks,
setContextLinks,
}: {
contextLinks: ContextLinksData;
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
}): JSX.Element {
// Use the custom hook for modal functionality
const {
isModalOpen,
selectedContextLink,
handleEditContextLink,
handleAddContextLink,
handleCancelModal,
handleSaveContextLink,
} = useContextLinkModal({ setContextLinks });
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
setContextLinks((prev) => {
const items = [...prev.linksData];
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return {
...prev,
linksData: arrayMove(items, oldIndex, newIndex),
};
});
}
};
const handleDeleteContextLink = (contextLink: ContextLinkProps): void => {
setContextLinks((prev) => ({
...prev,
linksData: prev.linksData.filter((link) => link.id !== contextLink.id),
}));
};
return (
<div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={contextLinks.linksData.map((link) => link.id)}
strategy={verticalListSortingStrategy}
>
{contextLinks.linksData.map((contextLink) => (
<SortableContextLink
key={contextLink.id}
contextLink={contextLink}
onDelete={handleDeleteContextLink}
onEdit={handleEditContextLink}
/>
))}
</SortableContext>
</DndContext>
</OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div>
<Modal
title={selectedContextLink ? 'Edit context link' : 'Add a context link'}
open={isModalOpen}
onCancel={handleCancelModal}
destroyOnClose
width={672}
footer={null}
>
<UpdateContextLinks
selectedContextLink={selectedContextLink}
onSave={handleSaveContextLink}
onCancel={handleCancelModal}
/>
</Modal>
</div>
);
}
export default ContextLinks;

View File

@@ -1,149 +0,0 @@
.context-links-container {
display: flex;
flex-direction: column;
gap: 16px;
margin: 12px;
}
.context-links-text {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.52px;
text-transform: uppercase;
}
.context-links-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
}
.context-link-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 4px;
user-select: none;
transition: background-color 0.2s ease-in-out;
width: 100%;
min-width: 0;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
cursor: grab;
min-width: 0;
&:active {
cursor: grabbing;
}
}
.drag-handle-icon {
flex-shrink: 0;
color: var(--bg-vanilla-400);
}
.context-link-content {
display: flex;
flex-direction: column;
gap: 2px;
flex-grow: 1;
min-width: 0;
overflow: hidden;
}
.context-link-label {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-link-url {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-link-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
flex-shrink: 0;
}
.edit-context-link-btn,
.delete-context-link-btn {
padding: 6px 12px;
flex-shrink: 0;
}
.delete-context-link-btn {
&:hover {
color: var(--bg-cherry-400) !important;
border-color: var(--bg-cherry-400) !important;
}
}
&:hover {
background-color: var(--bg-slate-400);
.context-link-actions {
opacity: 1;
}
}
}
.add-context-link-button {
display: flex;
align-items: center;
margin: auto;
width: fit-content;
}
.lightMode {
.context-links-text {
color: var(--bg-ink-400);
}
.context-link-item {
&:hover {
background-color: var(--bg-vanilla-200);
}
.context-link-label {
color: var(--bg-slate-500);
}
.context-link-url {
color: var(--bg-slate-400);
}
.drag-handle-icon {
color: var(--bg-slate-400);
}
.delete-context-link-btn {
&:hover {
color: var(--bg-cherry-500);
border-color: var(--bg-cherry-500);
background-color: var(--bg-cherry-100);
}
}
}
}

View File

@@ -1,65 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { ContextLinkProps, ContextLinksData } from 'types/api/dashboard/getAll';
interface ContextLinkModalProps {
isModalOpen: boolean;
selectedContextLink: ContextLinkProps | null;
handleEditContextLink: (contextLink: ContextLinkProps) => void;
handleAddContextLink: () => void;
handleCancelModal: () => void;
handleSaveContextLink: (newContextLink: ContextLinkProps) => void;
}
const useContextLinkModal = ({
setContextLinks,
}: {
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
}): ContextLinkModalProps => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [
selectedContextLink,
setSelectedContextLink,
] = useState<ContextLinkProps | null>(null);
const handleEditContextLink = (contextLink: ContextLinkProps): void => {
setSelectedContextLink(contextLink);
setIsModalOpen(true);
};
const handleAddContextLink = (): void => {
setSelectedContextLink(null);
setIsModalOpen(true);
};
const handleCancelModal = (): void => {
setIsModalOpen(false);
setSelectedContextLink(null);
};
const handleSaveContextLink = (newContextLink: ContextLinkProps): void => {
setContextLinks((prev) => {
const links = [...prev.linksData];
const existing = links.filter((link) => link.id === newContextLink.id)[0];
if (existing) {
const idx = links.findIndex((link) => link.id === newContextLink.id);
links[idx] = { ...existing, ...newContextLink };
return { ...prev, linksData: links };
}
links.push(newContextLink);
return { ...prev, linksData: links };
});
setIsModalOpen(false);
setSelectedContextLink(null);
};
return {
isModalOpen,
selectedContextLink,
handleEditContextLink,
handleAddContextLink,
handleCancelModal,
handleSaveContextLink,
};
};
export default useContextLinkModal;

View File

@@ -1,181 +0,0 @@
import { CONTEXT_LINK_FIELDS } from 'container/NewWidget/RightContainer/ContextLinks/constants';
import { resolveTexts } from 'hooks/dashboard/useContextVariables';
import { ContextLinkProps } from 'types/api/dashboard/getAll';
import { v4 as uuid } from 'uuid';
interface UrlParam {
key: string;
value: string;
}
interface ProcessedContextLink {
id: string;
label: string;
url: string;
}
const getInitialValues = (
contextLink: ContextLinkProps | null,
): Record<string, string> => ({
[CONTEXT_LINK_FIELDS.ID]: contextLink?.id || uuid(),
[CONTEXT_LINK_FIELDS.LABEL]: contextLink?.label || '',
[CONTEXT_LINK_FIELDS.URL]: contextLink?.url || '',
});
const getUrlParams = (url: string): UrlParam[] => {
try {
const [, queryString] = url.split('?');
if (!queryString) {
return [];
}
const paramPairs = queryString.split('&');
const params: UrlParam[] = [];
paramPairs.forEach((pair) => {
try {
const [key, value] = pair.split('=');
if (key) {
const decodedKey = decodeURIComponent(key);
const decodedValue = decodeURIComponent(value || '');
// Double decode the value for display
let displayValue = decodedValue;
try {
// Try to double decode if it looks like it was double encoded
const doubleDecoded = decodeURIComponent(decodedValue);
// Check if double decoding produced a different result
if (doubleDecoded !== decodedValue) {
displayValue = doubleDecoded;
}
} catch {
// If double decoding fails, use single decoded value
displayValue = decodedValue;
}
params.push({
key: decodedKey,
value: displayValue,
});
}
} catch (paramError) {
// Skip malformed parameters and continue processing
console.warn('Failed to parse URL parameter:', pair, paramError);
}
});
return params;
} catch (error) {
console.warn('Failed to parse URL parameters, returning empty array:', error);
return [];
}
};
const updateUrlWithParams = (url: string, params: UrlParam[]): string => {
// Get base URL without query parameters
const [baseUrl] = url.split('?');
// Create query parameter string from current parameters
const validParams = params.filter((param) => param.key.trim() !== '');
const queryString = validParams
.map(
(param) =>
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
param.value,
)}`,
)
.join('&');
// Construct final URL
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
};
// Utility function to process context links with variable resolution and URL encoding
const processContextLinks = (
contextLinks: ContextLinkProps[],
processedVariables: Record<string, string>,
maxLength?: number,
): ProcessedContextLink[] => {
// Extract all labels and URLs for batch processing
const labels = contextLinks.map(({ label }) => label);
const urls = contextLinks.map(({ url }) => url);
// Resolve variables in labels
const resolvedLabels = resolveTexts({
texts: labels,
processedVariables,
maxLength,
});
// Process URLs with proper encoding/decoding
const finalUrls = urls.map((url) => {
if (typeof url !== 'string') return url;
try {
// 1. Get the URL and extract base URL and query string
const [baseUrl, queryString] = url.split('?');
// Resolve variables in base URL.
const resolvedBaseUrlResult = resolveTexts({
texts: [baseUrl],
processedVariables,
});
const resolvedBaseUrl = resolvedBaseUrlResult.fullTexts[0];
if (!queryString) return resolvedBaseUrl;
// 2. Extract all query params using URLSearchParams
const searchParams = new URLSearchParams(queryString);
const processedParams: Record<string, string> = {};
// 3. Process each parameter
Array.from(searchParams.entries()).forEach(([key, value]) => {
// 4. Decode twice to handle double encoding
let decodedValue = decodeURIComponent(value);
try {
const doubleDecoded = decodeURIComponent(decodedValue);
// Check if double decoding produced a different result
if (doubleDecoded !== decodedValue) {
decodedValue = doubleDecoded;
}
} catch {
// If double decoding fails, use single decoded value
}
// 5. Pass through resolve text for variable resolution
const resolvedTextsResult = resolveTexts({
texts: [decodedValue],
processedVariables,
});
const resolvedValue = resolvedTextsResult.fullTexts[0];
// 6. Encode the resolved value
processedParams[key] = encodeURIComponent(resolvedValue);
});
// 7. Create new URL with processed parameters
const newQueryString = Object.entries(processedParams)
.map(([key, value]) => `${encodeURIComponent(key)}=${value}`)
.join('&');
return `${resolvedBaseUrl}?${newQueryString}`;
} catch (error) {
console.warn('Failed to process URL, using original URL:', error);
return url;
}
});
// Return processed context links
return contextLinks.map((link, index) => ({
id: link.id,
label: resolvedLabels.fullTexts[index],
url: finalUrls[index],
}));
};
export {
getInitialValues,
getUrlParams,
processContextLinks,
updateUrlWithParams,
};

View File

@@ -335,10 +335,6 @@
}
}
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts {
display: flex;
padding: 12px;
@@ -516,10 +512,6 @@
color: var(--bg-ink-300);
}
}
.context-links {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}
.select-option {

View File

@@ -178,17 +178,3 @@ export const panelTypeVsLegendColors: {
[PANEL_TYPES.HISTOGRAM]: true,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsContextLinks: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: true,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: true,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.HISTOGRAM]: true,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -34,7 +34,6 @@ import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
ContextLinksData,
LegendPosition,
Widgets,
} from 'types/api/dashboard/getAll';
@@ -46,7 +45,6 @@ import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import {
panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences,
panelTypeVsContextLinks,
panelTypeVsCreateAlert,
panelTypeVsFillSpan,
panelTypeVsLegendColors,
@@ -58,7 +56,6 @@ import {
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
} from './constants';
import ContextLinks from './ContextLinks';
import LegendColors from './LegendColors/LegendColors';
import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types';
@@ -116,9 +113,6 @@ function RightContainer({
customLegendColors,
setCustomLegendColors,
queryResponse,
contextLinks,
setContextLinks,
enableDrillDown = false,
}: RightContainerProps): JSX.Element {
const { selectedDashboard } = useDashboard();
const [inputValue, setInputValue] = useState(title);
@@ -158,8 +152,6 @@ function RightContainer({
const allowPanelColumnPreference =
panelTypeVsColumnUnitPreferences[selectedGraph];
const allowContextLinks =
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
const { currentQuery } = useQueryBuilder();
@@ -506,15 +498,6 @@ function RightContainer({
</section>
)}
{allowContextLinks && (
<section className="context-links">
<ContextLinks
contextLinks={contextLinks}
setContextLinks={setContextLinks}
/>
</section>
)}
{allowThreshold && (
<section>
<ThresholdSelector
@@ -576,15 +559,11 @@ interface RightContainerProps {
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
contextLinks: ContextLinksData;
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
enableDrillDown?: boolean;
}
RightContainer.defaultProps = {
selectedWidget: undefined,
queryResponse: null,
enableDrillDown: false,
};
export default RightContainer;

View File

@@ -21,7 +21,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
@@ -42,7 +41,6 @@ import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
ContextLinksData,
LegendPosition,
Widgets,
} from 'types/api/dashboard/getAll';
@@ -74,10 +72,7 @@ import {
placeWidgetBetweenRows,
} from './utils';
function NewWidget({
selectedGraph,
enableDrillDown = false,
}: NewWidgetProps): JSX.Element {
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const {
selectedDashboard,
@@ -244,10 +239,6 @@ function NewWidget({
selectedWidget?.columnUnits || {},
);
const [contextLinks, setContextLinks] = useState<ContextLinksData>(
selectedWidget?.contextLinks || { linksData: [] },
);
useEffect(() => {
setSelectedWidget((prev) => {
if (!prev) {
@@ -277,7 +268,6 @@ function NewWidget({
legendPosition,
customLegendColors,
columnWidths: columnWidths?.[selectedWidget?.id],
contextLinks,
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -304,7 +294,6 @@ function NewWidget({
legendPosition,
customLegendColors,
columnWidths,
contextLinks,
]);
const closeModal = (): void => {
@@ -515,7 +504,6 @@ function NewWidget({
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
},
]
: [
@@ -545,7 +533,6 @@ function NewWidget({
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
contextLinks: selectedWidget?.contextLinks || { linksData: [] },
},
...afterWidgets,
],
@@ -703,26 +690,6 @@ function NewWidget({
}
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
const showSwitchToViewModeButton =
enableDrillDown && !isNewDashboard && !!query.get('widgetId');
const handleSwitchToViewMode = useCallback(() => {
if (!query.get('widgetId')) return;
const widgetId = query.get('widgetId') || '';
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
};
const updatedSearch = createQueryParams(queryParams);
safeNavigate({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
search: updatedSearch,
});
}, [query, safeNavigate, dashboardId, currentQuery]);
return (
<Container>
<div className="edit-header">
@@ -739,42 +706,31 @@ function NewWidget({
</Typography.Text>
</Flex>
</div>
<div className="right-header">
{showSwitchToViewModeButton && (
<Button
data-testid="switch-to-view-mode"
disabled={isSaveDisabled || !currentQuery}
onClick={handleSwitchToViewMode}
>
Switch to View Mode
</Button>
)}
{isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
className="save-btn"
>
Save Changes
</Button>
)}
{!isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
icon={<Check size={14} />}
className="save-btn"
>
Save Changes
</Button>
)}
</div>
{isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
className="save-btn"
>
Save Changes
</Button>
)}
{!isSaveDisabled && (
<Button
type="primary"
data-testid="new-widget-save"
loading={updateDashboardMutation.isLoading}
disabled={isSaveDisabled}
onClick={onSaveDashboard}
icon={<Check size={14} />}
className="save-btn"
>
Save Changes
</Button>
)}
</div>
<PanelContainer>
@@ -793,7 +749,6 @@ function NewWidget({
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
enableDrillDown={enableDrillDown}
/>
)}
</OverlayScrollbar>
@@ -844,9 +799,6 @@ function NewWidget({
setSoftMin={setSoftMin}
softMax={softMax}
setSoftMax={setSoftMax}
contextLinks={contextLinks}
setContextLinks={setContextLinks}
enableDrillDown={enableDrillDown}
/>
</OverlayScrollbar>
</RightContainerWrapper>

View File

@@ -12,7 +12,6 @@ export interface NewWidgetProps {
selectedGraph: PANEL_TYPES;
yAxisUnit: Widgets['yAxisUnit'];
fillSpans: Widgets['fillSpans'];
enableDrillDown?: boolean;
}
export interface WidgetGraphProps {
@@ -33,7 +32,6 @@ export interface WidgetGraphProps {
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
>;
enableDrillDown?: boolean;
}
export type WidgetGraphContainerProps = {
@@ -47,5 +45,4 @@ export type WidgetGraphContainerProps = {
selectedGraph: PANEL_TYPES;
selectedWidget: Widgets;
isLoadingPanelData: boolean;
enableDrillDown?: boolean;
};

View File

@@ -554,7 +554,6 @@ export const getDefaultWidgetData = (
dataType: field.fieldDataType ?? '',
})),
selectedTracesFields: defaultTraceSelectedColumns,
// contextLinks: { linksData: [] },
});
export const PANEL_TYPE_TO_QUERY_TYPES: Record<PANEL_TYPES, EQueryType[]> = {

View File

@@ -2,15 +2,12 @@ import { ToggleGraphProps } from 'components/Graph/types';
import Uplot from 'components/Uplot';
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
import _noop from 'lodash-es/noop';
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { buildHistogramData } from './histogram';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -23,60 +20,11 @@ function HistogramPanelWrapper({
isFullViewMode,
onToggleModelHandler,
onClickHandler,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useGraphContextMenu({
widgetId: widget.id || '',
query: widget.query,
graphData: clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks: widget.contextLinks,
panelType: widget.panelTypes,
});
const clickHandlerWithContextMenu = useCallback(
(...args: any[]) => {
const [
,
,
,
,
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
,
focusedSeries,
] = args;
const data = getUplotClickData({
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
});
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label });
}
},
[onClick],
);
const histogramData = buildHistogramData(
queryResponse.data?.payload.data.result,
@@ -125,9 +73,7 @@ function HistogramPanelWrapper({
setGraphsVisibilityStates: setGraphVisibility,
graphsVisibilityStates: graphVisibility,
mergeAllQueries: widget.mergeAllActiveQueries,
onClickHandler: enableDrillDown
? clickHandlerWithContextMenu
: onClickHandler ?? _noop,
onClickHandler: onClickHandler || _noop,
}),
[
containerDimensions,
@@ -139,8 +85,6 @@ function HistogramPanelWrapper({
widget.id,
widget.mergeAllActiveQueries,
widget.panelTypes,
clickHandlerWithContextMenu,
enableDrillDown,
onClickHandler,
],
);
@@ -148,13 +92,6 @@ function HistogramPanelWrapper({
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
<GraphManager
data={histogramData}

View File

@@ -21,7 +21,6 @@ function PanelWrapper({
onOpenTraceBtnClick,
customSeries,
customOnRowClick,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const Component = PanelTypeVsPanelWrapper[
selectedGraph || widget.panelTypes
@@ -50,7 +49,6 @@ function PanelWrapper({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customOnRowClick={customOnRowClick}
customSeries={customSeries}
enableDrillDown={enableDrillDown}
/>
);
}

View File

@@ -6,13 +6,10 @@ import { Pie } from '@visx/shape';
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { themeColors } from 'constants/theme';
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { useRef, useState } from 'react';
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
@@ -22,7 +19,6 @@ import { lightenColor, tooltipStyles } from './utils';
function PiePanelWrapper({
queryResponse,
widget,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const [active, setActive] = useState<{
label: string;
@@ -52,7 +48,6 @@ function PiePanelWrapper({
label: string;
value: string;
color: string;
record: any;
}[] = [].concat(
...(panelData
.map((d) => {
@@ -60,7 +55,6 @@ function PiePanelWrapper({
return {
label,
value: d?.values?.[0]?.[1],
record: d,
color:
widget?.customLegendColors?.[label] ||
generateColor(
@@ -148,28 +142,6 @@ function PiePanelWrapper({
return active.color === color ? color : lightenedColor;
};
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useGraphContextMenu({
widgetId: widget.id || '',
query: widget.query,
graphData: clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks: widget.contextLinks,
panelType: widget.panelTypes,
});
return (
<div className="piechart-wrapper">
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
@@ -193,7 +165,7 @@ function PiePanelWrapper({
height={size}
>
{
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, sonarjs/cognitive-complexity
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
(pie) =>
pie.arcs.map((arc) => {
const { label } = arc.data;
@@ -254,17 +226,6 @@ function PiePanelWrapper({
hideTooltip();
setActive(null);
}}
onClick={(e): void => {
if (enableDrillDown) {
const data = getPieChartClickData(arc);
if (data && data?.queryName) {
onClick(
{ x: e.clientX, y: e.clientY },
{ ...data, label: data.label },
);
}
}
}}
>
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
@@ -323,13 +284,6 @@ function PiePanelWrapper({
})
}
</Pie>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
{/* Add total value in the center */}
<text

View File

@@ -12,7 +12,6 @@ function TablePanelWrapper({
openTracesButton,
onOpenTraceBtnClick,
customOnRowClick,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const panelData =
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
@@ -32,9 +31,6 @@ function TablePanelWrapper({
widgetId={widget.id}
renderColumnCell={widget.renderColumnCell}
customColTitles={widget.customColTitles}
contextLinks={widget.contextLinks}
enableDrillDown={enableDrillDown}
panelType={widget.panelTypes}
// eslint-disable-next-line react/jsx-props-no-spreading
{...GRID_TABLE_CONFIG}
/>

View File

@@ -6,8 +6,6 @@ import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -15,16 +13,14 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
import _noop from 'lodash-es/noop';
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import uPlot from 'uplot';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { PanelWrapperProps } from './panelWrapper.types';
import { getTimeRangeFromUplotAxis } from './utils';
function UplotPanelWrapper({
queryResponse,
@@ -38,7 +34,6 @@ function UplotPanelWrapper({
selectedGraph,
customTooltipElement,
customSeries,
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const isDarkMode = useIsDarkMode();
@@ -70,27 +65,6 @@ function UplotPanelWrapper({
const containerDimensions = useResizeObserver(graphRef);
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useGraphContextMenu({
widgetId: widget.id || '',
query: widget.query,
graphData: clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks: widget.contextLinks,
panelType: widget.panelTypes,
});
useEffect(() => {
const {
graphVisibilityStates: localStoredVisibilityState,
@@ -140,42 +114,6 @@ function UplotPanelWrapper({
const { timezone } = useTimezone();
const clickHandlerWithContextMenu = useCallback(
(...args: any[]) => {
const [
xValue,
,
,
,
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
] = args;
const data = getUplotClickData({
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
});
console.log('onClickData: ', data);
// Compute time range if needed and if axes data is available
let timeRange;
if (axesData) {
const { xAxis } = axesData;
timeRange = getTimeRangeFromUplotAxis(xAxis, xValue);
}
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick],
);
const options = useMemo(
() =>
getUPlotChartOptions({
@@ -185,9 +123,7 @@ function UplotPanelWrapper({
isDarkMode,
onDragSelect,
yAxisUnit: widget?.yAxisUnit,
onClickHandler: enableDrillDown
? clickHandlerWithContextMenu
: onClickHandler ?? _noop,
onClickHandler: onClickHandler || _noop,
thresholds: widget.thresholds,
minTimeScale,
maxTimeScale,
@@ -216,7 +152,7 @@ function UplotPanelWrapper({
containerDimensions,
isDarkMode,
onDragSelect,
clickHandlerWithContextMenu,
onClickHandler,
minTimeScale,
maxTimeScale,
graphVisibility,
@@ -227,8 +163,6 @@ function UplotPanelWrapper({
customTooltipElement,
timezone.value,
customSeries,
enableDrillDown,
onClickHandler,
widget,
],
);
@@ -236,13 +170,6 @@ function UplotPanelWrapper({
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot options={options} data={chartData} ref={lineChartRef} />
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
{widget?.stackedBarChart && isFullViewMode && (
<Alert
message="Selecting multiple legends is currently not supported in case of stacked bar charts"

View File

@@ -266,34 +266,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
demo-app
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
demo-app
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
4.35 s
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
4.35 s
</div>
</div>
</td>
@@ -304,34 +292,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
customer
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
customer
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
</div>
</td>
@@ -342,34 +318,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
mysql
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
mysql
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
431 ms
</div>
</div>
</td>
@@ -380,34 +344,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
frontend
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
frontend
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
287 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
287 ms
</div>
</div>
</td>
@@ -418,34 +370,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
driver
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
driver
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
230 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
230 ms
</div>
</div>
</td>
@@ -456,34 +396,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
route
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
route
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
66.4 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
66.4 ms
</div>
</div>
</td>
@@ -494,34 +422,22 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
redis
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
redis
</div>
</div>
</td>
<td
class="ant-table-cell"
>
<div
class=""
role="button"
tabindex="0"
>
<div>
<div
class="line-clamped-wrapper__text"
>
31.3 ms
</div>
<div>
<div
class="line-clamped-wrapper__text"
>
31.3 ms
</div>
</div>
</td>

View File

@@ -30,7 +30,6 @@ export type PanelWrapperProps = {
onOpenTraceBtnClick?: (record: RowData) => void;
customOnRowClick?: (record: RowData) => void;
customSeries?: (data: QueryData[]) => uPlot.Series[];
enableDrillDown?: boolean;
};
export type TooltipData = {

View File

@@ -71,20 +71,3 @@ export const lightenColor = (color: string, opacity: number): string => {
// Create a new RGBA color string with the specified opacity
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
export const getTimeRangeFromUplotAxis = (
axis: any,
xValue: number,
): { startTime: number; endTime: number } => {
let gap =
(axis as any)._splits && (axis as any)._splits.length > 1
? (axis as any)._splits[1] - (axis as any)._splits[0]
: 600; // 10 minutes in seconds
gap = Math.max(gap, 600); // Minimum gap of 10 minutes in seconds
const startTime = xValue - gap;
const endTime = xValue + gap;
return { startTime, endTime };
};

View File

@@ -30,5 +30,14 @@ export type QueryBuilderProps = {
isListViewPanel?: boolean;
showFunctions?: boolean;
showOnlyWhereClause?: boolean;
showOnlyTraceOperator?: boolean;
showTraceViewSelector?: boolean;
showTraceOperator?: boolean;
version: string;
onChangeTraceView?: (view: TraceView) => void;
};
export enum TraceView {
SPANS = 'spans',
TRACES = 'traces',
}

View File

@@ -39,6 +39,7 @@ interface QBEntityOptionsProps {
showCloneOption?: boolean;
isListViewPanel?: boolean;
index?: number;
hasTraceOperator?: boolean;
queryVariant?: 'dropdown' | 'static';
onChangeDataSource?: (value: DataSource) => void;
}
@@ -61,6 +62,7 @@ export default function QBEntityOptions({
onCloneQuery,
index,
queryVariant,
hasTraceOperator = false,
onChangeDataSource,
}: QBEntityOptionsProps): JSX.Element {
const handleCloneEntity = (): void => {
@@ -97,7 +99,7 @@ export default function QBEntityOptions({
value="query-builder"
className="periscope-btn visibility-toggle"
onClick={onToggleVisibility}
disabled={isListViewPanel}
disabled={isListViewPanel && query?.dataSource !== DataSource.TRACES}
>
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
@@ -115,6 +117,7 @@ export default function QBEntityOptions({
className={cx(
'periscope-btn',
entityType === 'query' ? 'query-name' : 'formula-name',
hasTraceOperator && 'has-trace-operator',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
)}
>

View File

@@ -11,5 +11,7 @@ export type QueryProps = {
version: string;
showSpanScopeSelector?: boolean;
showOnlyWhereClause?: boolean;
showTraceOperator?: boolean;
signalSource?: string;
isMultiQueryAllowed?: boolean;
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;

View File

@@ -1,137 +0,0 @@
import './Breakoutoptions.styles.scss';
import { Input, Skeleton } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import useDebounce from 'hooks/useDebounce';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { BreakoutOptionsProps } from './contextConfig';
function OptionsSkeleton(): JSX.Element {
return (
<div className="breakout-options-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton.Input
active
size="small"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>
))}
</div>
);
}
function BreakoutOptions({
queryData,
onColumnClick,
}: BreakoutOptionsProps): JSX.Element {
const { groupBy = [] } = queryData;
const [searchText, setSearchText] = useState<string>('');
const debouncedSearchText = useDebounce(searchText, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setSearchText(value);
},
[],
);
// Using getKeySuggestions directly like in QuerySearch
const { data, isFetching } = useQuery(
[
'keySuggestions',
queryData.dataSource,
debouncedSearchText,
queryData.aggregateAttribute?.key,
],
() =>
getKeySuggestions({
signal: queryData.dataSource,
searchText: debouncedSearchText,
metricName:
(queryData.aggregations?.[0] as MetricAggregation)?.metricName ||
queryData.aggregateAttribute?.key,
}),
{
enabled: !!queryData,
},
);
const breakoutOptions = useMemo(() => {
if (!data?.data?.data?.keys) {
return [];
}
const { keys } = data.data.data;
const transformedOptions: BaseAutocompleteData[] = [];
// Transform the response to match BaseAutocompleteData format
Object.values(keys).forEach((keyArray) => {
keyArray.forEach((keyData) => {
transformedOptions.push({
key: keyData.name,
dataType: DataTypes.EMPTY,
type: '',
isColumn: true,
});
});
});
// Filter out already selected groupBy keys
const groupByKeys = groupBy.map((item: BaseAutocompleteData) => item.key);
return transformedOptions.filter(
(item: BaseAutocompleteData) => !groupByKeys.includes(item.key),
);
}, [data, groupBy]);
return (
<div>
<section className="search" style={{ padding: '8px 0' }}>
<Input
type="text"
value={searchText}
placeholder="Search breakout options..."
onChange={handleInputChange}
/>
</section>
<div>
<OverlayScrollbar
style={{ maxHeight: '200px' }}
options={{
overflow: {
x: 'hidden',
},
}}
>
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<>
{isFetching ? (
<OptionsSkeleton />
) : (
breakoutOptions?.map((item: BaseAutocompleteData) => (
<ContextMenu.Item
key={item.key}
onClick={(): void => onColumnClick(item)}
>
{item.key}
</ContextMenu.Item>
))
)}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default BreakoutOptions;

View File

@@ -1,7 +0,0 @@
.breakout-options-skeleton {
.ant-skeleton-input {
width: 100% !important;
height: 20px !important;
margin: 8px 5px;
}
}

View File

@@ -1,259 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useTableContextMenu from '../useTableContextMenu';
import {
MOCK_AGGREGATE_DATA,
MOCK_COORDINATES,
MOCK_FILTER_DATA,
MOCK_KEY_SUGGESTIONS_RESPONSE,
// MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE,
MOCK_QUERY,
} from './mockTableData';
// Mock the necessary hooks and dependencies
const mockSafeNavigate = jest.fn();
const mockRedirectWithQueryBuilderData = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
__esModule: true,
default: (): any => ({
getUpdatedQuery: jest.fn().mockResolvedValue({}),
isLoading: false,
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({
globalTime: {
selectedTime: {
startTime: 1713734400000,
endTime: 1713738000000,
},
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
function MockTableDrilldown(): JSX.Element {
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useTableContextMenu({
widgetId: 'test-widget',
query: MOCK_QUERY as Query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
});
const handleClick = (type: 'aggregate' | 'filter'): void => {
// Simulate the same flow as handleColumnClick in QueryTable
onClick(
MOCK_COORDINATES,
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
);
};
return (
<div style={{ padding: '20px' }}>
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
Aggregate
</Button>
<Button type="primary" onClick={(): void => handleClick('filter')}>
Filter
</Button>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
onClose={onClose}
items={menuItemsConfig.items}
title={
typeof menuItemsConfig.header === 'string'
? menuItemsConfig.header
: undefined
}
/>
</div>
);
}
const renderWithProviders = (
component: React.ReactElement,
): ReturnType<typeof render> =>
render(
<MockQueryClientProvider>
<MemoryRouter>
<Provider store={store}>{component}</Provider>
</MemoryRouter>
</MockQueryClientProvider>,
);
describe('TableDrilldown Breakout Functionality', () => {
beforeEach((): void => {
jest.clearAllMocks();
// Mock the substitute_vars API that's causing network errors
server.use(
rest.post('*/api/v5/substitute_vars', (req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
});
it('should show breakout options when "Breakout by" is clicked', async (): Promise<void> => {
// Mock the MSW server to intercept the keySuggestions API call
server.use(
rest.get('*/fields/keys', (req, res, ctx) =>
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
),
);
renderWithProviders(<MockTableDrilldown />);
// Find and click the aggregate button to show context menu
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
fireEvent.click(aggregateButton);
// Find and click "Breakout by" option
const breakoutOption = screen.getByText(/Breakout by/);
fireEvent.click(breakoutOption);
// Wait for the breakout options to load and verify they are displayed
await screen.findByText('Breakout by');
// Check that the search input is displayed
expect(
screen.getByPlaceholderText('Search breakout options...'),
).toBeInTheDocument();
// Wait for the API call to complete and options to load
// Check what's actually being rendered instead of waiting for specific text
await screen.findByText('deployment.environment');
// Check that the breakout options are loaded and displayed
// Based on the test output, these are the actual options being rendered
expect(screen.getByText('deployment.environment')).toBeInTheDocument();
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.getByText('http.status_code')).toBeInTheDocument();
// Verify that the breakout header is displayed
expect(screen.getByText('Breakout by')).toBeInTheDocument();
});
it('should add selected breakout option to groupBy and redirect with correct query', async (): Promise<void> => {
// Mock the MSW server to intercept the keySuggestions API call
server.use(
rest.get('*/fields/keys', (req, res, ctx) =>
res(ctx.status(200), ctx.json(MOCK_KEY_SUGGESTIONS_RESPONSE)),
),
);
renderWithProviders(<MockTableDrilldown />);
// Navigate to breakout options
const aggregateButton = screen.getByRole('button', { name: /aggregate/i });
fireEvent.click(aggregateButton);
const breakoutOption = screen.getByText(/Breakout by/);
fireEvent.click(breakoutOption);
// Wait for breakout options to load
await screen.findByText('deployment.environment');
// Click on a breakout option (e.g., deployment.environment)
const breakoutOptionItem = screen.getByText('deployment.environment');
fireEvent.click(breakoutOptionItem);
// Verify redirectWithQueryBuilderData was called
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [
query,
queryParams,
,
newTab,
] = mockRedirectWithQueryBuilderData.mock.calls[0];
// Check that the query contains the correct structure
expect(query.builder).toBeDefined();
expect(query.builder.queryData).toBeDefined();
// Find the query data for the aggregate query (queryName: 'A')
const aggregateQueryData = query.builder.queryData.find(
(item: any) => item.queryName === 'A',
);
expect(aggregateQueryData).toBeDefined();
// Verify that the groupBy has been updated to only contain the selected breakout option
expect(aggregateQueryData.groupBy).toHaveLength(1);
expect(aggregateQueryData.groupBy[0].key).toEqual('deployment.environment');
// Verify that orderBy has been cleared (as per getBreakoutQuery logic)
expect(aggregateQueryData.orderBy).toEqual([]);
// Verify that the legend has been updated (check the actual value being returned)
// The legend logic in getBreakoutQuery: legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : ''
// Since the original legend might be empty, the result could be empty string
expect(aggregateQueryData.legend).toBeDefined();
// Check that the queryParams contain the expandedWidgetId
expect(queryParams).toEqual({ expandedWidgetId: 'test-widget' });
// Check that newTab is true
expect(newTab).toBe(true);
// Verify that the original filters are preserved and new filters are added
expect(aggregateQueryData.filter.expression).toContain(
"service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'",
);
// The new filter from the clicked data should also be present
expect(aggregateQueryData.filter.expression).toContain(
"service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'",
);
});
});

View File

@@ -1,291 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useTableContextMenu from '../useTableContextMenu';
import {
MOCK_AGGREGATE_DATA,
MOCK_COORDINATES,
MOCK_FILTER_DATA,
MOCK_QUERY,
MOCK_QUERY_WITH_FILTER,
} from './mockTableData';
// Mock the necessary hooks and dependencies
const mockSafeNavigate = jest.fn();
const mockRedirectWithQueryBuilderData = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.DASHBOARD}/`,
}),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({
globalTime: {
selectedTime: {
startTime: 1713734400000,
endTime: 1713738000000,
},
maxTime: 1713738000000,
minTime: 1713734400000,
},
}),
}));
function MockTableDrilldown(): JSX.Element {
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useTableContextMenu({
widgetId: 'test-widget',
query: MOCK_QUERY as Query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
});
const handleClick = (type: 'aggregate' | 'filter'): void => {
// Simulate the same flow as handleColumnClick in QueryTable
onClick(
MOCK_COORDINATES,
type === 'aggregate' ? MOCK_AGGREGATE_DATA : MOCK_FILTER_DATA,
);
};
return (
<div style={{ padding: '20px' }}>
<Button type="primary" onClick={(): void => handleClick('aggregate')}>
Aggregate
</Button>
<Button type="primary" onClick={(): void => handleClick('filter')}>
Filter
</Button>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
onClose={onClose}
items={menuItemsConfig.items}
title={
typeof menuItemsConfig.header === 'string'
? menuItemsConfig.header
: undefined
}
/>
</div>
);
}
const renderWithProviders = (
component: React.ReactElement,
): ReturnType<typeof render> =>
render(
<MockQueryClientProvider>
<MemoryRouter>
<Provider store={store}>{component}</Provider>
</MemoryRouter>
</MockQueryClientProvider>,
);
describe('TableDrilldown', () => {
beforeEach((): void => {
jest.clearAllMocks();
});
it('should show context menu filter options when button is clicked', (): void => {
renderWithProviders(<MockTableDrilldown />);
// Find and click the button
const button = screen.getByRole('button', { name: /filter/i });
fireEvent.click(button);
// Check that the context menu options are displayed
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
});
it('should show context menu aggregate options when button is clicked', (): void => {
renderWithProviders(<MockTableDrilldown />);
// Find and click the button
const button = screen.getByRole('button', { name: /aggregate/i });
fireEvent.click(button);
// Check that the context menu options are displayed
expect(screen.getByText('logs')).toBeInTheDocument();
expect(screen.getByText('count()')).toBeInTheDocument();
expect(screen.getByText('View in Logs')).toBeInTheDocument();
expect(screen.getByText('View in Traces')).toBeInTheDocument();
expect(screen.getByText(/Breakout by/)).toBeInTheDocument();
});
it('should navigate to logs explorer with correct query when "View in Logs" is clicked', (): void => {
renderWithProviders(<MockTableDrilldown />);
// Find and click the button to show context menu
const button = screen.getByRole('button', { name: /aggregate/i });
fireEvent.click(button);
// Find and click "View in Logs" option
const viewInLogsOption = screen.getByText('View in Logs');
fireEvent.click(viewInLogsOption);
// Verify safeNavigate was called with the correct URL
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url, options] = mockSafeNavigate.mock.calls[0];
// Check the URL structure
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('?');
// Parse the URL to check query parameters
const urlObj = new URL(url, 'http://localhost');
// Check that compositeQuery parameter exists and contains the query with filters
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
urlObj.searchParams.get('compositeQuery') || '{}',
);
// Verify the query structure includes the filters from clicked data
expect(compositeQuery.builder).toBeDefined();
expect(compositeQuery.builder.queryData).toBeDefined();
// Check that the query contains the correct filter expression
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filters).toBeDefined();
// Check that newTab option is set to true
expect(options).toEqual({ newTab: true });
});
it('should navigate to traces explorer with correct query when "View in Traces" is clicked', (): void => {
renderWithProviders(<MockTableDrilldown />);
// Find and click the button to show context menu
const button = screen.getByRole('button', { name: /aggregate/i });
fireEvent.click(button);
// Find and click "View in Traces" option
const viewInTracesOption = screen.getByText('View in Traces');
fireEvent.click(viewInTracesOption);
// Verify safeNavigate was called with the correct URL
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url, options] = mockSafeNavigate.mock.calls[0];
// Check the URL structure
expect(url).toContain(ROUTES.TRACES_EXPLORER);
expect(url).toContain('?');
// Parse the URL to check query parameters
const urlObj = new URL(url, 'http://localhost');
// Check that compositeQuery parameter exists and contains the query with filters
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
const compositeQuery = JSON.parse(
urlObj.searchParams.get('compositeQuery') || '{}',
);
// Verify the query structure includes the filters from clicked data
expect(compositeQuery.builder).toBeDefined();
expect(compositeQuery.builder.queryData).toBeDefined();
// Check that the query contains the correct filter expression
// The filter should include the clicked data filters (service.name = 'adservice', trace_id = 'df2cfb0e57bb8736207689851478cd50')
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toEqual(MOCK_QUERY_WITH_FILTER);
// Check that newTab option is set to true
expect(options).toEqual({ newTab: true });
});
it('should show filter options and navigate with correct query when filter option is clicked', (): void => {
renderWithProviders(<MockTableDrilldown />);
// Find and click the Filter button to show filter context menu
const filterButton = screen.getByRole('button', { name: /filter/i });
fireEvent.click(filterButton);
// Check that the filter context menu is displayed
expect(screen.getByText('Filter by trace_id')).toBeInTheDocument();
// Check that the filter operators are displayed
expect(screen.getByText('Is this')).toBeInTheDocument(); // = operator
expect(screen.getByText('Is not this')).toBeInTheDocument(); // != operator
// Click on "Is this" (equals operator)
const equalsOption = screen.getByText('Is this');
fireEvent.click(equalsOption);
// Verify redirectWithQueryBuilderData was called instead of safeNavigate
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [
query,
queryParams,
,
newTab,
] = mockRedirectWithQueryBuilderData.mock.calls[0];
// Check that the query contains the filter that was added
expect(query.builder).toBeDefined();
expect(query.builder.queryData).toBeDefined();
const firstQueryData = query.builder.queryData[0];
// The filter should include the original filter plus the new one from clicked data
// Original: "service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'"
// New: trace_id = 'df2cfb0e57bb8736207689851478cd50'
expect(firstQueryData.filter.expression).toContain(
"service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'",
);
expect(firstQueryData.filter.expression).toContain(
"trace_id = 'df2cfb0e57bb8736207689851478cd50'",
);
// Check that the queryParams contain the expandedWidgetId
expect(queryParams).toEqual({ expandedWidgetId: 'test-widget' });
// Check that newTab is true
expect(newTab).toBe(true);
});
});
export default MockTableDrilldown;

View File

@@ -1,290 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export const MOCK_COORDINATES = {
x: 996,
y: 421,
};
export const MOCK_AGGREGATE_DATA = {
record: {
'service.name': 'adservice',
trace_id: 'df2cfb0e57bb8736207689851478cd50',
A: 3,
},
column: {
dataIndex: 'A',
title: 'count()',
width: 145,
isValueColumn: true,
queryName: 'A',
},
tableColumns: [
{
dataIndex: 'service.name',
title: 'service.name',
width: 145,
isValueColumn: false,
queryName: 'A',
},
{
dataIndex: 'trace_id',
title: 'trace_id',
width: 145,
isValueColumn: false,
queryName: 'A',
},
{
dataIndex: 'A',
title: 'count()',
width: 145,
isValueColumn: true,
queryName: 'A',
},
],
};
export const MOCK_QUERY_WITH_FILTER =
"service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env' service.name = 'adservice' AND trace_id = 'df2cfb0e57bb8736207689851478cd50'";
export const MOCK_FILTER_DATA = {
record: {
'service.name': 'adservice',
trace_id: 'df2cfb0e57bb8736207689851478cd50',
A: 3,
},
column: {
dataIndex: 'trace_id',
title: 'trace_id',
width: 145,
isValueColumn: false,
queryName: 'A',
},
tableColumns: [
{
dataIndex: 'service.name',
title: 'service.name',
width: 145,
isValueColumn: false,
queryName: 'A',
},
{
dataIndex: 'trace_id',
title: 'trace_id',
width: 145,
isValueColumn: false,
queryName: 'A',
},
{
dataIndex: 'A',
title: 'count()',
width: 145,
isValueColumn: true,
queryName: 'A',
},
],
};
export const MOCK_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
aggregations: [
{
expression: 'count()',
},
],
dataSource: DataSource.LOGS,
disabled: false,
expression: 'A',
filter: {
expression:
"service.name = '$service.name' AND trace_id EXISTS AND deployment.environment = '$env'",
},
filters: {
items: [],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: 'string',
id: 'service.name--string--resource--false',
isColumn: false,
isJSON: false,
key: 'service.name',
type: 'resource',
},
{
dataType: 'string',
id: 'trace_id--string----true',
isColumn: true,
isJSON: false,
key: 'trace_id',
},
],
having: {
expression: '',
},
havingExpression: {
expression: '',
},
legend: '',
limit: null,
orderBy: [],
queryName: 'A',
stepInterval: 60,
},
{
aggregations: [
{
expression: 'count()',
},
],
dataSource: 'logs',
disabled: true,
expression: 'B',
filter: {
expression: '',
},
filters: {
items: [],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: 'string',
id: 'service.name--string--resource--false',
isColumn: false,
isJSON: false,
key: 'service.name',
type: 'resource',
},
],
having: {
expression: '',
},
havingExpression: {
expression: '',
},
legend: '',
limit: null,
orderBy: [],
queryName: 'B',
stepInterval: 60,
},
],
queryFormulas: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '6092c3fd-6877-4cb8-836a-7f30db4e4bfe',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
};
export const MOCK_KEY_SUGGESTIONS_RESPONSE = {
status: 'success',
data: {
complete: true,
keys: {
resource: [
{
name: 'service.name',
label: 'Service Name',
type: 'resource',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'deployment.environment',
label: 'Environment',
type: 'resource',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
},
],
attribute: [
{
name: 'http.method',
label: 'HTTP Method',
type: 'attribute',
signal: 'logs',
fieldContext: 'attribute',
fieldDataType: 'string',
},
{
name: 'http.status_code',
label: 'HTTP Status Code',
type: 'attribute',
signal: 'logs',
fieldContext: 'attribute',
fieldDataType: 'number',
},
],
},
},
};
export const MOCK_KEY_SUGGESTIONS_SEARCH_RESPONSE = {
status: 'success',
data: {
complete: true,
keys: {
resource: [
{
name: 'service.name',
label: 'Service Name',
type: 'resource',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'deployment.environment',
label: 'Environment',
type: 'resource',
signal: 'logs',
fieldContext: 'attribute',
fieldDataType: 'string',
},
],
},
},
};
export const MOCK_KEY_SUGGESTIONS_SINGLE_RESPONSE = {
status: 'success',
data: {
complete: true,
keys: {
resource: [
{
name: 'deployment.environment',
label: 'Environment',
type: 'resource',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
},
],
},
},
};

View File

@@ -1,153 +0,0 @@
import {
PANEL_TYPES,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import BreakoutOptions from './BreakoutOptions';
import {
getAggregateColumnHeader,
getBaseMeta,
getQueryData,
} from './drilldownUtils';
import { AGGREGATE_OPTIONS, SUPPORTED_OPERATORS } from './menuOptions';
import { getBreakoutQuery } from './tableDrilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
export type ContextMenuItem = ReactNode;
export enum ConfigType {
GROUP = 'group',
AGGREGATE = 'aggregate',
}
export interface ContextMenuConfigParams {
configType: ConfigType;
query: Query;
clickedData: ClickedData;
panelType?: string;
onColumnClick: (key: string, query?: Query) => void;
subMenu?: string;
}
export interface GroupContextMenuConfig {
header?: string;
items?: ContextMenuItem;
}
export interface AggregateContextMenuConfig {
header?: string | ReactNode;
items?: ContextMenuItem;
}
export interface BreakoutOptionsProps {
queryData: IBuilderQuery;
onColumnClick: (groupBy: BaseAutocompleteData) => void;
}
export function getGroupContextMenuConfig({
query,
clickedData,
panelType,
onColumnClick,
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
const filterKey = clickedData?.column?.dataIndex;
const filterDataType =
getBaseMeta(query, filterKey as string)?.dataType || 'string';
const operators =
QUERY_BUILDER_OPERATORS_BY_TYPES[
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
];
const filterOperators = operators.filter(
(operator) => SUPPORTED_OPERATORS[operator],
);
if (panelType === PANEL_TYPES.TABLE && clickedData?.column) {
return {
items: (
<>
<ContextMenu.Header>
<div>Filter by {filterKey}</div>
</ContextMenu.Header>
{filterOperators.map((operator) => (
<ContextMenu.Item
key={operator}
icon={SUPPORTED_OPERATORS[operator].icon}
onClick={(): void => onColumnClick(SUPPORTED_OPERATORS[operator].value)}
>
{SUPPORTED_OPERATORS[operator].label}
</ContextMenu.Item>
))}
</>
),
};
}
return {};
}
export function getAggregateContextMenuConfig({
subMenu,
query,
onColumnClick,
aggregateData,
}: {
subMenu?: string;
query: Query;
onColumnClick: (key: string, query?: Query) => void;
aggregateData: AggregateData | null;
}): AggregateContextMenuConfig {
if (subMenu === 'breakout') {
const queryData = getQueryData(query, aggregateData?.queryName || '');
return {
header: 'Breakout by',
items: (
<BreakoutOptions
queryData={queryData}
onColumnClick={(groupBy: BaseAutocompleteData): void => {
// Use aggregateData.filters
const filtersToAdd = aggregateData?.filters || [];
const breakoutQuery = getBreakoutQuery(
query,
aggregateData,
groupBy,
filtersToAdd,
);
onColumnClick('breakout', breakoutQuery);
}}
/>
),
};
}
// Use aggregateData.queryName
const queryName = aggregateData?.queryName;
const { dataSource, aggregations } = getAggregateColumnHeader(
query,
queryName as string,
);
return {
header: (
<div>
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
<div>{aggregations}</div>
</div>
),
items: AGGREGATE_OPTIONS.map(({ key, label, icon }) => (
<ContextMenu.Item
key={key}
icon={icon}
onClick={(): void => onColumnClick(key)}
>
{label}
</ContextMenu.Item>
)),
};
}

View File

@@ -1,336 +0,0 @@
import { PieArcDatum } from '@visx/shape/lib/shapes/Pie';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
initialQueryBuilderFormValuesMap,
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import cloneDeep from 'lodash-es/cloneDeep';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
export function getBaseMeta(
query: Query,
filterKey: string,
): BaseAutocompleteData | null {
const steps = query.builder.queryData;
for (let i = 0; i < steps.length; i++) {
const { groupBy } = steps[i];
for (let j = 0; j < groupBy.length; j++) {
if (groupBy[j].key === filterKey) {
return groupBy[j];
}
}
}
return null;
}
export const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
if (!dataType) return false;
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
};
export interface FilterData {
filterKey: string;
filterValue: string | number;
operator: string;
}
// Helper function to avoid code duplication
function addFiltersToQuerySteps(
query: Query,
filters: FilterData[],
queryName?: string,
): Query {
// 1) clone so we don't mutate the original
const q = cloneDeep(query);
// 2) map over builder.queryData to return a new modified version
q.builder.queryData = q.builder.queryData.map((step) => {
// Only modify the step that matches the queryName (if provided)
if (queryName && step.queryName !== queryName) {
return step;
}
// 3) build the new filters array
const newFilters = {
...step.filters,
op: step?.filters?.op || 'AND',
items: [...(step?.filters?.items || [])],
};
// Add each filter to the items array
filters.forEach(({ filterKey, filterValue, operator }) => {
// skip if this step doesn't group by our key
const baseMeta = step.groupBy.find((g) => g.key === filterKey);
if (!baseMeta) return;
newFilters.items.push({
id: uuid(),
key: baseMeta,
op: operator,
value: filterValue,
});
});
const resolvedFilters = convertFiltersToExpressionWithExistingQuery(
newFilters,
step.filter?.expression,
);
// 4) return a new step object with updated filters
return {
...step,
...resolvedFilters,
};
});
return q;
}
export function addFilterToQuery(query: Query, filters: FilterData[]): Query {
return addFiltersToQuerySteps(query, filters);
}
export const addFilterToSelectedQuery = (
query: Query,
filters: FilterData[],
queryName: string,
): Query => addFiltersToQuerySteps(query, filters, queryName);
export const getAggregateColumnHeader = (
query: Query,
queryName: string,
): { dataSource: string; aggregations: string } => {
// Find the query step with the matching queryName
const queryStep = query.builder.queryData.find(
(step) => step.queryName === queryName,
);
if (!queryStep) {
return { dataSource: '', aggregations: '' };
}
const { dataSource, aggregations } = queryStep; // TODO: check if this is correct
// Extract aggregation expressions based on data source type
let aggregationExpressions: string[] = [];
if (aggregations && aggregations.length > 0) {
if (dataSource === 'metrics') {
// For metrics, construct expression from spaceAggregation(metricName)
aggregationExpressions = aggregations.map((agg: any) => {
const { spaceAggregation, metricName } = agg;
return `${spaceAggregation}(${metricName})`;
});
} else {
// For traces and logs, use the expression field directly
aggregationExpressions = aggregations.map((agg: any) => agg.expression);
}
}
return {
dataSource,
aggregations: aggregationExpressions.join(', '),
};
};
const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],
operator: OPERATORS['='],
}));
export const getUplotClickData = ({
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
}: {
metric?: { [key: string]: string };
queryData?: { queryName: string; inFocusOrNot: boolean };
absoluteMouseX: number;
absoluteMouseY: number;
focusedSeries?: {
seriesIndex: number;
seriesName: string;
value: number;
color: string;
show: boolean;
isFocused: boolean;
} | null;
}): {
coord: { x: number; y: number };
record: { queryName: string; filters: FilterData[] };
label: string | React.ReactNode;
} | null => {
if (!queryData?.queryName || !metric) {
return null;
}
const record = {
queryName: queryData.queryName,
filters: getFiltersFromMetric(metric),
};
// Generate label from focusedSeries data
let label: string | React.ReactNode = '';
if (focusedSeries && focusedSeries.seriesName) {
label = (
<span style={{ color: focusedSeries.color }}>
{focusedSeries.seriesName}
</span>
);
}
return {
coord: {
x: absoluteMouseX,
y: absoluteMouseY,
},
record,
label,
};
};
export const getPieChartClickData = (
arc: PieArcDatum<{
label: string;
value: string;
color: string;
record: any;
}>,
): {
queryName: string;
filters: FilterData[];
label: string | React.ReactNode;
} | null => {
const { metric, queryName } = arc.data.record;
if (!queryName || !metric) return null;
const label = <span style={{ color: arc.data.color }}>{arc.data.label}</span>;
return {
queryName,
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
label,
};
};
/**
* Gets the query data that matches the aggregate data's queryName
*/
export const getQueryData = (
query: Query,
queryName: string,
): IBuilderQuery => {
const queryData = query?.builder?.queryData?.filter(
(item: IBuilderQuery) => item.queryName === queryName,
);
return queryData[0];
};
/**
* Checks if a query name is valid for drilldown operations
* Returns false if queryName is empty or starts with 'F'
* Note: Checking if queryName starts with 'F' is a hack to know if it's a Formulae based query
*/
export const isValidQueryName = (queryName: string): boolean => {
if (!queryName || queryName.trim() === '') {
return false;
}
return !queryName.startsWith('F');
};
const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
view_logs: initialQueryBuilderFormValuesMap.logs,
view_metrics: initialQueryBuilderFormValuesMap.metrics,
view_traces: initialQueryBuilderFormValuesMap.traces,
};
export const getViewQuery = (
query: Query,
filtersToAdd: FilterData[],
key: string,
queryName: string,
): Query | null => {
const newQuery = cloneDeep(query);
const queryBuilderData = VIEW_QUERY_MAP[key];
if (!queryBuilderData) return null;
let existingFilters: TagFilterItem[] = [];
let existingFilterExpression: string | undefined;
if (queryName) {
const queryData = getQueryData(query, queryName);
existingFilters = queryData?.filters?.items || [];
existingFilterExpression = queryData?.filter?.expression;
}
newQuery.builder.queryData = [queryBuilderData];
const filters = filtersToAdd.reduce((acc: any[], filter) => {
// use existing query to get baseMeta
const baseMeta = getBaseMeta(query, filter.filterKey);
if (!baseMeta) return acc;
acc.push({
id: uuid(),
key: baseMeta,
op: filter.operator,
value: filter.filterValue,
});
return acc;
}, []);
const allFilters = [...existingFilters, ...filters];
const {
// filters: newFilters,
filter: newFilterExpression,
} = convertFiltersToExpressionWithExistingQuery(
{
items: allFilters,
op: 'AND',
},
existingFilterExpression,
);
// newQuery.builder.queryData[0].filters = newFilters;
newQuery.builder.queryData[0].filter = newFilterExpression;
return newQuery;
};
export function isDrilldownEnabled(): boolean {
return true;
// temp code
// if (typeof window === 'undefined') return false;
// const drilldownValue = window.localStorage.getItem('drilldown');
// return drilldownValue === 'true';
}

View File

@@ -1,99 +0,0 @@
import { OPERATORS } from 'constants/queryBuilder';
import { ChartBar, DraftingCompass, ScrollText } from 'lucide-react';
/**
* Supported operators for filtering with their display properties
*/
export const SUPPORTED_OPERATORS = {
[OPERATORS['=']]: {
label: 'Is this',
icon: '=',
value: '=',
},
[OPERATORS['!=']]: {
label: 'Is not this',
icon: '!=',
value: '!=',
},
[OPERATORS['>=']]: {
label: 'Is greater than or equal to',
icon: '>=',
value: '>=',
},
[OPERATORS['<=']]: {
label: 'Is less than or equal to',
icon: '<=',
value: '<=',
},
[OPERATORS['<']]: {
label: 'Is less than',
icon: '<',
value: '<',
},
};
/**
* Aggregate menu options for different views
*/
// TO REMOVE
export const AGGREGATE_OPTIONS = [
{
key: 'view_logs',
icon: <ScrollText size={16} />,
label: 'View in Logs',
},
// {
// key: 'view_metrics',
// icon: <BarChart2 size={16} />,
// label: 'View in Metrics',
// },
{
key: 'view_traces',
icon: <DraftingCompass size={16} />,
label: 'View in Traces',
},
{
key: 'breakout',
icon: <ChartBar size={16} />,
label: 'Breakout by ..',
},
];
/**
* Aggregate menu options for different views
*/
export const getBaseContextConfig = ({
handleBaseDrilldown,
}: {
handleBaseDrilldown: (key: string) => void;
}): {
key: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
}[] => [
{
key: 'view_logs',
icon: <ScrollText size={16} />,
label: 'View in Logs',
onClick: (): void => handleBaseDrilldown('view_logs'),
},
// {
// key: 'view_metrics',
// icon: <BarChart2 size={16} />,
// label: 'View in Metrics',
// onClick: () => handleBaseDrilldown('view_metrics'),
// },
{
key: 'view_traces',
icon: <DraftingCompass size={16} />,
label: 'View in Traces',
onClick: (): void => handleBaseDrilldown('view_traces'),
},
{
key: 'breakout',
icon: <ChartBar size={16} />,
label: 'Breakout by ..',
onClick: (): void => handleBaseDrilldown('breakout'),
},
];

View File

@@ -1,81 +0,0 @@
import { OPERATORS } from 'constants/queryBuilder';
import cloneDeep from 'lodash-es/cloneDeep';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
export const isEmptyFilterValue = (value: any): boolean =>
value === '' || value === null || value === undefined || value === 'n/a';
/**
* Creates filters to add to the query from table columns for view mode navigation
*/
export const getFiltersToAddToView = (clickedData: any): FilterData[] => {
if (!clickedData) {
console.warn('clickedData is null in getFiltersToAddToView');
return [];
}
return (
clickedData?.tableColumns
?.filter((col: any) => !col.isValueColumn)
.reduce((acc: FilterData[], col: any) => {
// only add table col which have isValueColumn false. and the filter value suffices the isEmptyFilterValue condition.
const { dataIndex } = col;
if (!dataIndex || typeof dataIndex !== 'string') return acc;
if (
clickedData?.column?.isValueColumn &&
isEmptyFilterValue(clickedData?.record?.[dataIndex])
)
return acc;
return [
...acc,
{
filterKey: dataIndex,
filterValue: clickedData?.record?.[dataIndex] || '',
operator: OPERATORS['='],
},
];
}, []) || []
);
};
/**
* Creates a breakout query by adding filters and updating the groupBy
*/
export const getBreakoutQuery = (
query: Query,
aggregateData: AggregateData | null,
groupBy: BaseAutocompleteData,
filtersToAdd: FilterData[],
): Query => {
if (!aggregateData) {
console.warn('aggregateData is null in getBreakoutQuery');
return query;
}
const queryWithFilters = addFilterToSelectedQuery(
query,
filtersToAdd,
aggregateData.queryName,
);
const newQuery = cloneDeep(queryWithFilters);
newQuery.builder.queryData = newQuery.builder.queryData.map(
(item: IBuilderQuery) => {
if (item.queryName === aggregateData.queryName) {
return {
...item,
groupBy: [groupBy],
orderBy: [],
legend: item.legend && groupBy.key ? `{{${groupBy.key}}}` : '',
};
}
return item;
},
);
return newQuery;
};

View File

@@ -1,34 +0,0 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type ContextMenuItem = ReactNode;
export enum ConfigType {
GROUP = 'group',
AGGREGATE = 'aggregate',
}
export interface ContextMenuConfigParams {
configType: ConfigType;
query: any; // Query type
clickedData: any;
panelType?: string;
onColumnClick: (operator: string | any) => void; // Query type
subMenu?: string;
}
export interface GroupContextMenuConfig {
header?: string;
items?: ContextMenuItem;
}
export interface AggregateContextMenuConfig {
header?: string;
items?: ContextMenuItem;
}
export interface BreakoutOptionsProps {
queryData: IBuilderQuery;
onColumnClick: (groupBy: BaseAutocompleteData) => void;
}

View File

@@ -1,80 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useMemo } from 'react';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ContextMenuItem } from './contextConfig';
import { FilterData } from './drilldownUtils';
import useBaseAggregateOptions from './useBaseAggregateOptions';
import useBreakout from './useBreakout';
// Type for aggregate data
export interface AggregateData {
queryName: string;
filters: FilterData[];
timeRange?: {
startTime: number;
endTime: number;
};
label?: string | React.ReactNode;
}
const useAggregateDrilldown = ({
query,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData,
contextLinks,
panelType,
}: {
query: Query;
widgetId: string;
onClose: () => void;
subMenu: string;
setSubMenu: (subMenu: string) => void;
aggregateData: AggregateData | null;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
}): {
aggregateDrilldownConfig: {
header?: string | React.ReactNode;
items?: ContextMenuItem;
};
} => {
const { breakoutConfig } = useBreakout({
query,
widgetId,
onClose,
aggregateData,
setSubMenu,
});
const { baseAggregateOptionsConfig } = useBaseAggregateOptions({
query,
onClose,
aggregateData,
subMenu,
setSubMenu,
contextLinks,
panelType,
});
const aggregateDrilldownConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in aggregateDrilldownConfig');
return {};
}
if (subMenu === 'breakout') {
return breakoutConfig;
}
return baseAggregateOptionsConfig;
}, [subMenu, aggregateData, breakoutConfig, baseAggregateOptionsConfig]);
return { aggregateDrilldownConfig };
};
export default useAggregateDrilldown;

View File

@@ -1,263 +0,0 @@
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ContextMenuItem } from './contextConfig';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
interface UseBaseAggregateOptionsProps {
query: Query;
onClose: () => void;
subMenu: string;
setSubMenu: (subMenu: string) => void;
aggregateData: AggregateData | null;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
}
interface BaseAggregateOptionsConfig {
header?: string | React.ReactNode;
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
subMenu,
setSubMenu,
aggregateData,
contextLinks,
panelType,
}: UseBaseAggregateOptionsProps): {
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
} => {
const [resolvedQuery, setResolvedQuery] = useState<Query>(query);
const {
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { selectedDashboard } = useDashboard();
useEffect(() => {
if (!aggregateData) return;
const resolveQuery = async (): Promise<void> => {
const updatedQuery = await getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
selectedDashboard,
});
setResolvedQuery(updatedQuery);
};
resolveQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
const fieldVariables = useMemo(() => {
if (!aggregateData?.filters) return {};
// Extract field variables from aggregation data filters
const fieldVars: Record<string, string | number | boolean> = {};
aggregateData.filters.forEach((filter) => {
if (filter.filterKey && filter.filterValue !== undefined) {
fieldVars[filter.filterKey] = filter.filterValue;
}
});
return fieldVars;
}, [aggregateData?.filters]);
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
customVariables: fieldVariables,
});
const getContextLinksItems = useCallback(() => {
if (!contextLinks?.linksData) return [];
try {
const processedLinks = processContextLinks(
contextLinks.linksData,
processedVariables,
50, // maxLength for labels
);
return processedLinks.map(({ id, label, url }) => (
<ContextMenu.Item
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
window.open(url, '_blank');
}}
>
{label}
</ContextMenu.Item>
));
} catch (error) {
return [];
}
}, [contextLinks, processedVariables]);
const handleBaseDrilldown = useCallback(
(key: string): void => {
if (key === 'breakout') {
// if (!drilldownQuery) {
setSubMenu(key);
return;
// }
}
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
// if (viewQuery) {
// viewQuery = resolveQueryVariables(viewQuery);
// }
let queryParams = {
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, setSubMenu, aggregateData],
);
const baseAggregateOptionsConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in baseAggregateOptionsConfig');
return {};
}
// Skip breakout logic as it's handled by useBreakout
if (subMenu === 'breakout') {
return {};
}
// Extract the non-breakout logic from getAggregateContextMenuConfig
const { queryName } = aggregateData;
const { dataSource, aggregations } = getAggregateColumnHeader(
resolvedQuery,
queryName as string,
);
return {
items: (
<>
<ContextMenu.Header>
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
<div
style={{
fontWeight: 'normal',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{aggregateData?.label || aggregations}
</div>
</ContextMenu.Header>
<div>
<OverlayScrollbar
style={{ maxHeight: '200px' }}
options={{
overflow: {
x: 'hidden',
},
}}
>
<>
{getBaseContextConfig({ handleBaseDrilldown }).map(
({ key, label, icon, onClick }) => {
const isLoading = isResolveQueryLoading && key !== 'breakout';
return (
<ContextMenu.Item
key={key}
icon={isLoading ? <LoadingOutlined spin /> : icon}
onClick={(): void => onClick()}
disabled={isLoading}
>
{label}
</ContextMenu.Item>
);
},
)}
{getContextLinksItems()}
</>
</OverlayScrollbar>
</div>
</>
),
};
}, [
subMenu,
handleBaseDrilldown,
aggregateData,
getContextLinksItems,
isResolveQueryLoading,
resolvedQuery,
]);
return { baseAggregateOptionsConfig };
};
export default useBaseAggregateOptions;

View File

@@ -1,110 +0,0 @@
import { QueryParams } from 'constants/query';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ArrowLeft } from 'lucide-react';
import ContextMenu from 'periscope/components/ContextMenu';
import { useCallback, useMemo } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import BreakoutOptions from './BreakoutOptions';
import { getQueryData } from './drilldownUtils';
import { getBreakoutQuery } from './tableDrilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
interface UseBreakoutProps {
query: Query;
widgetId: string;
onClose: () => void;
aggregateData: AggregateData | null;
setSubMenu: (subMenu: string) => void;
}
interface BreakoutConfig {
header?: string | React.ReactNode;
items?: React.ReactNode;
}
const useBreakout = ({
query,
widgetId,
onClose,
aggregateData,
setSubMenu,
}: UseBreakoutProps): {
breakoutConfig: BreakoutConfig;
handleBreakoutClick: (groupBy: BaseAutocompleteData) => void;
} => {
const { redirectWithQueryBuilderData } = useQueryBuilder();
const redirectToViewMode = useCallback(
(query: Query): void => {
redirectWithQueryBuilderData(
query,
{ [QueryParams.expandedWidgetId]: widgetId },
undefined,
true,
);
},
[widgetId, redirectWithQueryBuilderData],
);
const handleBreakoutClick = useCallback(
(groupBy: BaseAutocompleteData): void => {
if (!aggregateData) {
console.warn('aggregateData is null in handleBreakoutClick');
return;
}
const filtersToAdd = aggregateData.filters || [];
const breakoutQuery = getBreakoutQuery(
query,
aggregateData,
groupBy,
filtersToAdd,
);
redirectToViewMode(breakoutQuery);
onClose();
},
[query, aggregateData, redirectToViewMode, onClose],
);
const handleBackClick = useCallback(() => {
setSubMenu('');
}, [setSubMenu]);
const breakoutConfig = useMemo(() => {
if (!aggregateData) {
console.warn('aggregateData is null in breakoutConfig');
return {};
}
const queryData = getQueryData(query, aggregateData.queryName || '');
return {
// header: 'Breakout by',
items: (
<>
<ContextMenu.Header>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ArrowLeft
size={14}
style={{ cursor: 'pointer' }}
onClick={handleBackClick}
/>
<span>Breakout by</span>
</div>
</ContextMenu.Header>
<BreakoutOptions
queryData={queryData}
onColumnClick={handleBreakoutClick}
/>
</>
),
};
}, [query, aggregateData, handleBreakoutClick, handleBackClick]);
return { breakoutConfig, handleBreakoutClick };
};
export default useBreakout;

View File

@@ -1,86 +0,0 @@
import { QueryParams } from 'constants/query';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ClickedData } from 'periscope/components/ContextMenu/types';
import { useCallback, useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getGroupContextMenuConfig } from './contextConfig';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from './drilldownUtils';
const useFilterDrilldown = ({
query,
widgetId,
clickedData,
onClose,
}: {
query: Query;
widgetId: string;
clickedData: ClickedData | null;
onClose: () => void;
}): {
filterDrilldownConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
} => {
const { redirectWithQueryBuilderData } = useQueryBuilder();
const redirectToViewMode = useCallback(
(query: Query): void => {
redirectWithQueryBuilderData(
query,
{ [QueryParams.expandedWidgetId]: widgetId },
undefined,
true,
);
},
[widgetId, redirectWithQueryBuilderData],
);
const handleFilterDrilldown = useCallback(
(operator: string): void => {
const filterKey = clickedData?.column?.title as string;
let filterValue = clickedData?.record?.[filterKey] || '';
// Check if the filterKey is of number type and convert filterValue accordingly
const baseMeta = getBaseMeta(query, filterKey);
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
filterValue = Number(filterValue);
}
const newQuery = addFilterToQuery(query, [
{
filterKey,
filterValue,
operator,
},
]);
redirectToViewMode(newQuery);
onClose();
},
[onClose, clickedData, query, redirectToViewMode],
);
const filterDrilldownConfig = useMemo(() => {
if (!clickedData) {
console.warn('clickedData is null in filterDrilldownConfig');
return {};
}
return getGroupContextMenuConfig({
query,
clickedData,
panelType: 'table',
onColumnClick: handleFilterDrilldown,
});
}, [handleFilterDrilldown, clickedData, query]);
return {
filterDrilldownConfig,
};
};
export default useFilterDrilldown;

View File

@@ -1,68 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useMemo } from 'react';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { isValidQueryName } from './drilldownUtils';
import useAggregateDrilldown, { AggregateData } from './useAggregateDrilldown';
interface UseGraphContextMenuProps {
widgetId?: string;
query: Query;
graphData: AggregateData | null;
onClose: () => void;
coordinates: { x: number; y: number } | null;
subMenu: string;
setSubMenu: (subMenu: string) => void;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
}
export function useGraphContextMenu({
widgetId = '',
query,
graphData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks,
panelType,
}: UseGraphContextMenuProps): {
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const isQueryTypeBuilder = drilldownQuery?.queryType === 'builder';
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData: graphData,
contextLinks,
panelType,
});
const menuItemsConfig = useMemo(() => {
if (!coordinates || !graphData || !isQueryTypeBuilder) {
return {};
}
// Check if queryName is valid for drilldown
if (!isValidQueryName(graphData.queryName)) {
return {};
}
return aggregateDrilldownConfig;
}, [coordinates, aggregateDrilldownConfig, graphData, isQueryTypeBuilder]);
return { menuItemsConfig };
}
export default useGraphContextMenu;

View File

@@ -1,109 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { ClickedData } from 'periscope/components/ContextMenu/types';
import { useMemo } from 'react';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ConfigType } from './contextConfig';
import { isValidQueryName } from './drilldownUtils';
import { getFiltersToAddToView } from './tableDrilldownUtils';
import useAggregateDrilldown from './useAggregateDrilldown';
import useFilterDrilldown from './useFilterDrilldown';
interface UseTableContextMenuProps {
widgetId?: string;
query: Query;
clickedData: ClickedData | null;
onClose: () => void;
coordinates: { x: number; y: number } | null;
subMenu: string;
setSubMenu: (subMenu: string) => void;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
}
export function useTableContextMenu({
widgetId = '',
query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks,
panelType,
}: UseTableContextMenuProps): {
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const { filterDrilldownConfig } = useFilterDrilldown({
query: drilldownQuery,
widgetId,
clickedData,
onClose,
});
const aggregateData = useMemo(() => {
if (!clickedData?.column?.isValueColumn) return null;
return {
queryName: String(clickedData.column.queryName || ''),
filters: getFiltersToAddToView(clickedData) || [],
};
}, [clickedData]);
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,
widgetId,
onClose,
subMenu,
setSubMenu,
aggregateData,
contextLinks,
panelType,
});
const menuItemsConfig = useMemo(() => {
if (!coordinates || (!clickedData && !aggregateData)) {
if (!clickedData) {
console.warn('clickedData is null in menuItemsConfig');
}
return {};
}
const columnType = clickedData?.column?.isValueColumn
? ConfigType.AGGREGATE
: ConfigType.GROUP;
// Check if queryName is valid for drilldown
if (
columnType === ConfigType.AGGREGATE &&
!isValidQueryName(aggregateData?.queryName || '')
) {
return {};
}
switch (columnType) {
case ConfigType.AGGREGATE:
return aggregateDrilldownConfig;
case ConfigType.GROUP:
return filterDrilldownConfig;
default:
return {};
}
}, [
clickedData,
filterDrilldownConfig,
coordinates,
aggregateDrilldownConfig,
aggregateData,
]);
return { menuItemsConfig };
}
export default useTableContextMenu;

View File

@@ -1,10 +1,8 @@
import { TableProps } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DownloadOptions } from 'container/Download/Download.types';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ReactNode } from 'react';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -23,7 +21,4 @@ export type QueryTableProps = Omit<
sticky?: TableProps<RowData>['sticky'];
searchTerm?: string;
widgetId?: string;
enableDrillDown?: boolean;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
};

View File

@@ -13,13 +13,4 @@
width: 0.1rem;
}
}
.clickable-cell {
cursor: pointer;
max-width: fit-content;
&:hover {
color: var(--bg-robin-500);
}
}
}

View File

@@ -1,6 +1,5 @@
import './QueryTable.styles.scss';
import cx from 'classnames';
import { ResizeTable } from 'components/ResizeTable';
import Download from 'container/Download/Download';
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
@@ -8,11 +7,9 @@ import {
createTableColumnsFromQuery,
RowData,
} from 'lib/query/createTableColumnsFromQuery';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import useTableContextMenu from './Drilldown/useTableContextMenu';
import { QueryTableProps } from './QueryTable.intefaces';
import { createDownloadableData } from './utils';
@@ -28,37 +25,12 @@ export function QueryTable({
sticky,
searchTerm,
widgetId,
panelType,
...props
}: QueryTableProps): JSX.Element {
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
const isQueryTypeBuilder = query.queryType === 'builder';
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { loading, enableDrillDown = false, contextLinks } = props;
const {
coordinates,
popoverPosition,
clickedData,
onClose,
onClick,
subMenu,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useTableContextMenu({
widgetId: widgetId || '',
query,
clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks,
panelType,
});
const { loading } = props;
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
if (columns && dataSource) {
return { columns, dataSource };
@@ -82,52 +54,6 @@ export function QueryTable({
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
const handleColumnClick = useCallback(
(
e: React.MouseEvent,
record: RowData,
column: any,
tableColumns: any,
): void => {
e.stopPropagation();
if (isQueryTypeBuilder && enableDrillDown) {
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
}
},
[isQueryTypeBuilder, enableDrillDown, onClick],
);
// Click handler to columns to capture clicked data
const columnsWithClickHandlers = useMemo(
() =>
tableColumns.map((column: any): any => ({
...column,
render: (text: any, record: RowData, index: number): JSX.Element => {
const originalRender = column.render;
const renderedContent = originalRender
? originalRender(text, record, index)
: text;
return (
<div
role="button"
className={cx({
'clickable-cell': isQueryTypeBuilder && enableDrillDown,
})}
tabIndex={0}
onClick={(e): void => {
handleColumnClick(e, record, column, tableColumns);
}}
onKeyDown={(): void => {}}
>
{renderedContent}
</div>
);
},
})),
[tableColumns, isQueryTypeBuilder, enableDrillDown, handleColumnClick],
);
const paginationConfig = {
pageSize: 10,
showSizeChanger: false,
@@ -156,37 +82,28 @@ export function QueryTable({
}, [newDataSource, onTableSearch, searchTerm]);
return (
<>
<div className="query-table">
{isDownloadEnabled && (
<div className="query-table--download">
<Download
data={downloadableData}
fileName={`${fileName}-${servicename}`}
isLoading={loading as boolean}
/>
</div>
)}
<ResizeTable
columns={columnsWithClickHandlers}
tableLayout="fixed"
dataSource={filterTable === null ? newDataSource : filterTable}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
widgetId={widgetId}
shouldPersistColumnWidths
sticky={sticky}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
</div>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
<div className="query-table">
{isDownloadEnabled && (
<div className="query-table--download">
<Download
data={downloadableData}
fileName={`${fileName}-${servicename}`}
isLoading={loading as boolean}
/>
</div>
)}
<ResizeTable
columns={tableColumns}
tableLayout="fixed"
dataSource={filterTable === null ? newDataSource : filterTable}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
widgetId={widgetId}
shouldPersistColumnWidths
sticky={sticky}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
</>
</div>
);
}

View File

@@ -34,7 +34,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useQueryClient } from 'react-query';
import { connect, useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
import { useNavigationType } from 'react-router-dom-v5-compat';
import { useCopyToClipboard } from 'react-use';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
@@ -117,8 +117,6 @@ function DateTimeSelection({
);
const [modalEndTime, setModalEndTime] = useState<number>(initialModalEndTime);
const [searchParams] = useSearchParams();
// Effect to update modal time state when props change
useEffect(() => {
if (modalInitialStartTime !== undefined) {
@@ -412,10 +410,8 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (searchParams.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
safeNavigate(generatedUrl);
@@ -432,7 +428,6 @@ function DateTimeSelection({
updateLocalStorageForRoutes,
updateTimeInterval,
urlQuery,
searchParams,
],
);
@@ -493,10 +488,8 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (searchParams.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
}
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
safeNavigate(generatedUrl);

View File

@@ -37,11 +37,15 @@ function QuerySection(): JSX.Element {
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isListViewPanel={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}

View File

@@ -1,286 +0,0 @@
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
interface ContextVariable {
name: string;
value: string | number | boolean;
source: 'dashboard' | 'global' | 'custom';
isArray?: boolean;
originalValue?: any;
}
interface UseContextVariablesProps {
maxValues?: number;
customVariables?: Record<string, string | number | boolean>;
}
interface UseContextVariablesResult {
variables: ContextVariable[];
processedVariables: Record<string, string>;
getVariableByName: (name: string) => ContextVariable | undefined;
}
// Utility interfaces for text resolution
interface ResolveTextUtilsProps {
texts: string[];
processedVariables: Record<string, string>;
maxLength?: number;
matcher?: string;
}
interface ResolvedTextUtilsResult {
fullTexts: string[];
truncatedTexts: string[];
}
function useContextVariables({
maxValues = 2,
customVariables,
}: UseContextVariablesProps): UseContextVariablesResult {
const { selectedDashboard } = useDashboard();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Extract dashboard variables
const dashboardVariables = useMemo(() => {
if (!selectedDashboard?.data?.variables) return [];
return Object.entries(selectedDashboard.data.variables)
.filter(([, value]) => value.name)
.map(([, value]) => {
let processedValue: string | number | boolean;
let isArray = false;
if (Array.isArray(value.selectedValue)) {
processedValue = value.selectedValue.join(', ');
isArray = true;
} else if (value.selectedValue != null) {
processedValue = value.selectedValue;
} else {
processedValue = '';
}
return {
name: value.name || '',
value: processedValue,
source: 'dashboard' as const,
isArray,
originalValue: value.selectedValue,
};
});
}, [selectedDashboard]);
// Extract global variables
const globalVariables = useMemo(
() => [
{
name: 'timestamp_start',
value: Math.floor(globalTime.minTime / 1000000), // Convert from nanoseconds to milliseconds
source: 'global' as const,
originalValue: globalTime.minTime,
},
{
name: 'timestamp_end',
value: Math.floor(globalTime.maxTime / 1000000), // Convert from nanoseconds to milliseconds
source: 'global' as const,
originalValue: globalTime.maxTime,
},
],
[globalTime.minTime, globalTime.maxTime],
);
// Extract custom variables with '_' prefix to avoid conflicts
const customVariablesList = useMemo(() => {
if (!customVariables) return [];
return Object.entries(customVariables).map(([name, value]) => ({
name: `_${name}`, // Add '_' prefix to avoid conflicts
value,
source: 'custom' as const,
originalValue: value,
}));
}, [customVariables]);
// Combine all variables
const allVariables = useMemo(
() => [...dashboardVariables, ...globalVariables, ...customVariablesList],
[dashboardVariables, globalVariables, customVariablesList],
);
// Create processed variables with truncation logic
const processedVariables = useMemo(() => {
const result: Record<string, string> = {};
allVariables.forEach((variable) => {
const { name, value } = variable;
const isArray = 'isArray' in variable ? variable.isArray : false;
// If the value contains array data (comma-separated string), format it with +n more
if (
typeof value === 'string' &&
!value.includes('-|-') &&
value.includes(',') &&
isArray
) {
const values = value.split(',').map((v) => v.trim());
if (values.length > maxValues) {
const visibleValues = values.slice(0, maxValues);
const remainingCount = values.length - maxValues;
result[name] = `${visibleValues.join(
', ',
)} +${remainingCount}-|-${values.join(', ')}`;
} else {
result[name] = `${values.join(', ')}-|-${values.join(', ')}`;
}
} else {
// For values already formatted with -|- or non-array values
result[name] = String(value);
}
});
return result;
}, [allVariables, maxValues]);
// Helper function to get variable by name
const getVariableByName = useMemo(
(): ((name: string) => ContextVariable | undefined) => (
name: string,
): ContextVariable | undefined => allVariables.find((v) => v.name === name),
[allVariables],
);
return {
variables: allVariables,
processedVariables,
getVariableByName,
};
}
// Utility function to create combined pattern for variable matching
const createCombinedPattern = (matcher: string): RegExp => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
const variablePatterns = [
`\\{\\{\\s*?\\.(${varNamePattern})\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`, // {{var}}
`${escapedMatcher}(${varNamePattern})`, // matcher + var.name
`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
};
// Utility function to extract variable name from different formats
const extractVarName = (
match: string,
matcher: string,
processedVariables: Record<string, string>,
): string => {
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
if (match.startsWith('{{')) {
const dotMatch = match.match(
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
);
if (dotMatch) return dotMatch[1].trim();
const normalMatch = match.match(
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
);
if (normalMatch) return normalMatch[1].trim();
} else if (match.startsWith('[[')) {
const bracketMatch = match.match(
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
);
if (bracketMatch) return bracketMatch[1].trim();
} else if (match.startsWith(matcher)) {
// For $ variables, we always want to strip the prefix
// unless the full match exists in processedVariables
const withoutPrefix = match.substring(matcher.length).trim();
const fullMatch = match.trim();
// If the full match (with prefix) exists, use it
if (processedVariables[fullMatch] !== undefined) {
return fullMatch;
}
// Otherwise return without prefix
return withoutPrefix;
}
return match;
};
// Utility function to resolve text with processed variables
const resolveText = (
text: string,
processedVariables: Record<string, string>,
matcher = '$',
): string => {
const combinedPattern = createCombinedPattern(matcher);
return text.replace(combinedPattern, (match) => {
const varName = extractVarName(match, matcher, processedVariables);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts.length > 1 ? parts[1] : value;
}
return match;
});
};
// Utility function to resolve text with truncation
const resolveTextWithTruncation = (
text: string,
processedVariables: Record<string, string>,
maxLength?: number,
matcher = '$',
): string => {
const combinedPattern = createCombinedPattern(matcher);
const result = text.replace(combinedPattern, (match) => {
const varName = extractVarName(match, matcher, processedVariables);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts[0] || value;
}
return match;
});
if (maxLength && result.length > maxLength) {
// For the specific test case
if (maxLength === 20 && result.startsWith('Logs count in')) {
return 'Logs count in test, a...';
}
// General case
return `${result.substring(0, maxLength - 3)}...`;
}
return result;
};
// Main utility function to resolve multiple texts
export const resolveTexts = ({
texts,
processedVariables,
maxLength,
matcher = '$',
}: ResolveTextUtilsProps): ResolvedTextUtilsResult => {
const fullTexts = texts.map((text) =>
resolveText(text, processedVariables, matcher),
);
const truncatedTexts = texts.map((text) =>
resolveTextWithTruncation(text, processedVariables, maxLength, matcher),
);
return {
fullTexts,
truncatedTexts,
};
};
export default useContextVariables;

View File

@@ -54,9 +54,11 @@ export const useQueryOperations: UseQueryOperations = ({
formula,
isListViewPanel = false,
entityVersion,
isForTraceOperator = false,
}) => {
const {
handleSetQueryData,
handleSetTraceOperatorData,
handleSetFormulaData,
removeQueryBuilderEntityByIndex,
panelType,
@@ -400,9 +402,19 @@ export const useQueryOperations: UseQueryOperations = ({
: value,
};
handleSetQueryData(index, newQuery);
if (isForTraceOperator) {
handleSetTraceOperatorData(index, newQuery);
} else {
handleSetQueryData(index, newQuery);
}
},
[query, index, handleSetQueryData],
[
query,
index,
handleSetQueryData,
handleSetTraceOperatorData,
isForTraceOperator,
],
);
const handleChangeFormulaData: HandleChangeFormulaData = useCallback(

View File

@@ -5,7 +5,6 @@ import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
interface NavigateOptions {
replace?: boolean;
state?: any;
newTab?: boolean;
}
interface SafeNavigateParams {
@@ -114,16 +113,6 @@ export const useSafeNavigate = (
);
}
// If newTab is true, open in new tab and return early
if (options?.newTab) {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
return;
}
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);

View File

@@ -110,7 +110,6 @@ export const stepIntervalUnchanged = {
dataSource: 'metrics',
disabled: false,
expression: 'A',
filter: undefined,
filters: {
items: [],
op: 'AND',
@@ -363,7 +362,6 @@ export const replaceVariables = {
dataSource: 'metrics',
disabled: false,
expression: 'A',
filter: undefined,
filters: {
items: [
{
@@ -700,7 +698,6 @@ export const outputWithFunctions = {
expression: 'A',
disabled: false,
stepInterval: 120,
filter: undefined,
having: [],
limit: null,
orderBy: [],
@@ -730,7 +727,6 @@ export const outputWithFunctions = {
expression: 'B',
disabled: false,
stepInterval: 120,
filter: undefined,
having: [],
limit: null,
orderBy: [],

View File

@@ -45,7 +45,6 @@ export const transformQueryBuilderDataModel = (
queryData.push({
...baseQuery,
filters: queryFromData.filters,
filter: queryFromData.filter,
});
} else {
queryData.push({

View File

@@ -1,90 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
// Helper function to get the focused/highlighted series at a specific position
export const getFocusedSeriesAtPosition = (
e: MouseEvent,
u: uPlot,
): {
seriesIndex: number;
seriesName: string;
value: number;
color: string;
show: boolean;
isFocused: boolean;
} | null => {
const bbox = u.over.getBoundingClientRect();
const left = e.clientX - bbox.left;
const top = e.clientY - bbox.top;
const timestampIndex = u.posToIdx(left);
let focusedSeriesIndex = -1;
let closestPixelDiff = Infinity;
// Check all series (skip index 0 which is the x-axis)
for (let i = 1; i < u.data.length; i++) {
const series = u.data[i];
const seriesValue = series[timestampIndex];
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
const seriesYPx = u.valToPos(seriesValue, 'y');
const pixelDiff = Math.abs(seriesYPx - top);
if (pixelDiff < closestPixelDiff) {
closestPixelDiff = pixelDiff;
focusedSeriesIndex = i;
}
}
}
// If we found a focused series, return its data
if (focusedSeriesIndex > 0) {
const series = u.series[focusedSeriesIndex];
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
// Ensure we have a valid value
if (
seriesValue !== undefined &&
seriesValue !== null &&
!Number.isNaN(seriesValue)
) {
// Get color - try series stroke first, then generate based on label
let color = '#000000';
if (typeof series.stroke === 'string') {
color = series.stroke;
} else if (typeof series.fill === 'string') {
color = series.fill;
} else {
// Generate color based on series label (like the tooltip plugin does)
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
// Detect theme mode by checking body class
const isDarkMode = !document.body.classList.contains('lightMode');
color = generateColor(
seriesLabel,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
}
return {
seriesIndex: focusedSeriesIndex,
seriesName: series.label || `Series ${focusedSeriesIndex}`,
value: seriesValue as number,
color,
show: series.show !== false,
isFocused: true, // This indicates it's the highlighted/bold one
};
}
}
return null;
};
export interface OnClickPluginOpts {
onClick: (
xValue: number,
@@ -98,20 +13,6 @@ export interface OnClickPluginOpts {
queryName: string;
inFocusOrNot: boolean;
},
absoluteMouseX?: number,
absoluteMouseY?: number,
axesData?: {
xAxis: any;
yAxis: any;
},
focusedSeries?: {
seriesIndex: number;
seriesName: string;
value: number;
color: string;
show: boolean;
isFocused: boolean;
} | null,
) => void;
apiResponse?: MetricRangePayloadProps;
}
@@ -123,22 +24,14 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
init: (u: uPlot) => {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
handleClick = function (event: MouseEvent) {
// relative coordinates
const mouseX = event.offsetX + 40;
const mouseY = event.offsetY + 40;
// absolute coordinates
const absoluteMouseX = event.clientX;
const absoluteMouseY = event.clientY;
// Convert pixel positions to data values
// do not use mouseX and mouseY here as it offsets the timestamp as well
const xValue = u.posToVal(event.offsetX, 'x');
const yValue = u.posToVal(event.offsetY, 'y');
// Get the focused/highlighted series (the one that would be bold in hover)
const focusedSeries = getFocusedSeriesAtPosition(event, u);
let metric = {};
const { series } = u;
const apiResult = opts.apiResponse?.data?.result || [];
@@ -153,8 +46,6 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (item?.show && item?._focus) {
console.log('>> outputMetric', apiResult[index - 1]);
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
@@ -163,57 +54,7 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
});
}
if (!outputMetric.queryName) {
// Get the focused series data
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
// If we found a valid focused series, get its data
if (
focusedSeriesData &&
focusedSeriesData.seriesIndex <= apiResult.length
) {
console.log(
'>> outputMetric',
apiResult[focusedSeriesData.seriesIndex - 1],
);
const { metric: focusedMetric, queryName } =
apiResult[focusedSeriesData.seriesIndex - 1] || [];
metric = focusedMetric;
outputMetric.queryName = queryName;
outputMetric.inFocusOrNot = true;
}
}
const axesData = {
xAxis: u.axes[0],
yAxis: u.axes[1],
};
console.log('>> graph click', {
xValue,
yValue,
mouseX,
mouseY,
metric,
outputMetric,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
});
opts.onClick(
xValue,
yValue,
mouseX,
mouseY,
metric,
outputMetric,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
);
opts.onClick(xValue, yValue, mouseX, mouseY, metric, outputMetric);
};
u.over.addEventListener('click', handleClick);
},

View File

@@ -14,7 +14,7 @@ export type AlertHeaderProps = {
state: string;
alert: string;
id: string;
labels: Record<string, string | undefined> | undefined;
labels: Record<string, string>;
disabled: boolean;
};
};
@@ -23,14 +23,13 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { alertRuleState } = useAlertRule();
const [updatedName, setUpdatedName] = useState(alertName);
const labelsWithoutSeverity = useMemo(() => {
if (labels) {
return Object.fromEntries(
const labelsWithoutSeverity = useMemo(
() =>
Object.fromEntries(
Object.entries(labels).filter(([key]) => key !== 'severity'),
);
}
return {};
}, [labels]);
),
[labels],
);
return (
<div className="alert-info">
@@ -44,7 +43,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
</div>
</div>
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{labels.severity && <AlertSeverity severity={labels.severity} />}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus

View File

@@ -4,7 +4,6 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import NewWidget from 'container/NewWidget';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { useDashboard } from 'providers/Dashboard/Dashboard';
@@ -59,7 +58,6 @@ function DashboardWidget(): JSX.Element | null {
yAxisUnit={selectedWidget?.yAxisUnit}
selectedGraph={selectedGraph}
fillSpans={selectedWidget?.fillSpans}
enableDrillDown={isDrilldownEnabled()}
/>
</PreferenceContextProvider>
);

View File

@@ -53,7 +53,6 @@ function TracesExplorer(): JSX.Element {
handleRunQuery,
stagedQuery,
handleSetConfig,
updateQueriesData,
} = useQueryBuilder();
const { options } = useOptionsMenu({
@@ -112,48 +111,14 @@ function TracesExplorer(): JSX.Element {
handleSetConfig(PANEL_TYPES.LIST, DataSource.TRACES);
}
if (view === ExplorerViews.LIST) {
if (
selectedView !== ExplorerViews.LIST &&
currentQuery?.builder?.queryData?.[0]
) {
const filterToRetain = currentQuery.builder.queryData[0].filter;
const newDefaultQuery = updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
);
const newListQuery = updateQueriesData(
newDefaultQuery,
'queryData',
(item, index) => {
if (index === 0) {
return { ...item, filter: filterToRetain };
}
return item;
},
);
setDefaultQuery(newListQuery);
}
setShouldReset(true);
}
// TODO: remove formula when switching to List view
setSelectedView(view);
handleExplorerTabChange(
view === ExplorerViews.TIMESERIES ? PANEL_TYPES.TIME_SERIES : view,
);
},
[
handleSetConfig,
handleExplorerTabChange,
selectedView,
currentQuery,
updateAllQueriesOperators,
updateQueriesData,
setSelectedView,
],
[handleSetConfig, handleExplorerTabChange, selectedView, setSelectedView],
);
const listQuery = useMemo(() => {

View File

@@ -198,3 +198,4 @@ export default class FilterQueryListener extends ParseTreeListener {
*/
exitKey?: (ctx: KeyContext) => void;
}

View File

@@ -133,3 +133,4 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
*/
visitKey?: (ctx: KeyContext) => Result;
}

View File

@@ -1,160 +0,0 @@
import './styles.scss';
import { Popover } from 'antd';
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Coordinates, PopoverPosition } from './types';
import { useCoordinates } from './useCoordinates';
export { useCoordinates };
export type { ClickedData, Coordinates, PopoverPosition } from './types';
interface ContextMenuProps {
coordinates: Coordinates | null;
popoverPosition?: PopoverPosition | null;
title?: string;
items?: ReactNode;
onClose: () => void;
children?: ReactNode;
}
interface ContextMenuItemProps {
children: ReactNode;
onClick?: () => void;
icon?: ReactNode;
disabled?: boolean;
danger?: boolean;
}
function ContextMenuItem({
children,
onClick,
icon,
disabled = false,
danger = false,
}: ContextMenuItemProps): JSX.Element {
const className = `context-menu-item${disabled ? ' disabled' : ''}${
danger ? ' danger' : ''
}`;
return (
<button
className={className}
onClick={disabled ? undefined : onClick}
disabled={disabled}
type="button"
>
{icon && <span className="icon">{icon}</span>}
<span className="text">{children}</span>
</button>
);
}
interface ContextMenuHeaderProps {
children: ReactNode;
}
function ContextMenuHeader({ children }: ContextMenuHeaderProps): JSX.Element {
return <div className="context-menu-header">{children}</div>;
}
export function ContextMenu({
coordinates,
popoverPosition,
title,
items,
onClose,
children,
}: ContextMenuProps): JSX.Element | null {
if (!coordinates || !items) {
return null;
}
const position: PopoverPosition = popoverPosition ?? {
left: coordinates.x + 10,
top: coordinates.y - 10,
placement: 'right',
};
// Render backdrop using portal to ensure it covers the entire viewport
const backdrop = createPortal(
<div
className="context-menu-backdrop"
onClick={onClose}
onKeyDown={(e): void => {
if (e.key === 'Escape') {
onClose();
}
}}
role="button"
tabIndex={0}
aria-label="Close context menu"
/>,
document.body,
);
return (
<>
{backdrop}
<Popover
content={items}
title={title}
open={Boolean(coordinates)}
onOpenChange={(open: boolean): void => {
if (!open) {
onClose();
}
}}
trigger="click"
overlayStyle={{
position: 'fixed',
left: position.left,
top: position.top,
width: 210,
maxHeight: 254,
}}
arrow={false}
placement={position.placement}
rootClassName="context-menu"
zIndex={10000}
>
{children}
{/* phantom span to force Popover to position relative to viewport */}
<span
style={{
position: 'fixed',
left: position.left,
top: position.top,
width: 0,
height: 0,
}}
/>
</Popover>
</>
);
}
// Attach Item component to ContextMenu
ContextMenu.Item = ContextMenuItem;
ContextMenu.Header = ContextMenuHeader;
// default props for ContextMenuItem
ContextMenuItem.defaultProps = {
onClick: undefined,
icon: undefined,
disabled: false,
danger: false,
};
// default props
ContextMenu.defaultProps = {
popoverPosition: null,
title: '',
items: null,
children: null,
};
export default ContextMenu;
// ENHANCEMENT:
// 1. Adjust postion based on variable height of items. Currently hardcoded to 254px. Same for width.

Some files were not shown because too many files have changed in this diff Show More