fixes: includes fixes required in the new QB (#8675)

* fix: removed unused code for querycontext (#8674)

* Update frontend/src/utils/queryValidationUtils.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* feat: added tooltips in metric aggregations

* feat: enabled legend enhancement for explorer pages and alert page

* feat: updated the error state in explorer pages with new APIError

* fix: cloned panel query shows previous query (#8681)

* fix: cloned panel query shows previous query

* chore: removed comments

* chore: added null check

* fix: added fix for auto run query in dashboard panel + trace view issue

---------

Co-authored-by: Abhi Kumar <abhikumar@Mac.lan>

* feat: added new SubstituteVars api and enable v5 for creating new alerts (#8683)

* feat: added new SubstituteVars api and enable v5 for creating new alerts

* feat: add warning notification for query response

* feat: fixed failing test case

* fix: metric histogram UI config state in edit mode

* fix: fixed table columns getting duplicate data (#8685)

* fix: added fix for conversion of QB function to filter expression. (#8684)

* fix: added fix for QB filters for functions

* chore: minor fix

---------

Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>

* feat: query builder fixes and enhancement (#8692)

* feat: legend format fixes around single and multiple aggregation

* feat: fixed table unit and metric units

* feat: add fallbacks to columnWidth and columnUnits for old-dashboards

* feat: fixed metric edit issue and having filter suggestion duplications

* feat: fix and cleanup functions across product for v5

* chore: add tooltips with links to documentation (#8676)

* fix: added fix for query validation and empty query error (#8694)

* fix: added fix for selected columns being empty in logs explorer (#8709)

* feat: added columnUnit changes for old dashboard migrations (#8706)

* fix: fixed keyfetching logic (#8712)

* chore: lint fix

* fix: fixed logs explorer test

* feat: fix type checks

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: SagarRajput-7 <sagar@signoz.io>
Co-authored-by: Abhi Kumar <abhikumar@Mac.lan>
Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
This commit is contained in:
Abhi kumar 2025-08-06 00:16:20 +05:30 committed by GitHub
parent 01202b5800
commit 3a2eab2019
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1650 additions and 1324 deletions

View File

@ -0,0 +1,34 @@
import { ApiV5Instance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { QueryRangePayloadV5 } from 'api/v5/v5';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
interface ISubstituteVars {
compositeQuery: ICompositeMetricQuery;
}
export const getSubstituteVars = async (
props?: Partial<QueryRangePayloadV5>,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<ISubstituteVars>> => {
try {
const response = await ApiV5Instance.post<{ data: ISubstituteVars }>(
'/substitute_vars',
props,
{
signal,
headers,
},
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@ -28,14 +28,18 @@ function getColName(
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0; const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
const isSingleAggregation = aggregationsCount === 1; const isSingleAggregation = aggregationsCount === 1;
if (aggregationsCount > 0) {
// Single aggregation: Priority is alias > legend > expression // Single aggregation: Priority is alias > legend > expression
if (isSingleAggregation) { if (isSingleAggregation) {
return alias || legend || expression; return alias || legend || expression || col.queryName;
} }
// Multiple aggregations: Each follows single rules BUT never shows legend // Multiple aggregations: Each follows single rules BUT never shows legend
// Priority: alias > expression (legend is ignored for multiple aggregations) // Priority: alias > expression (legend is ignored for multiple aggregations)
return alias || expression; return alias || expression || col.queryName;
}
return legend || col.queryName;
} }
function getColId( function getColId(
@ -48,9 +52,16 @@ function getColId(
const aggregation = const aggregation =
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex]; aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
const expression = aggregation?.expression || ''; const expression = aggregation?.expression || '';
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
const isMultipleAggregations = aggregationsCount > 1;
if (isMultipleAggregations && expression) {
return `${col.queryName}.${expression}`; return `${col.queryName}.${expression}`;
} }
return col.queryName;
}
/** /**
* Converts V5 TimeSeriesData to legacy format * Converts V5 TimeSeriesData to legacy format
*/ */
@ -374,6 +385,7 @@ export function convertV5ResponseToLegacy(
data: { data: {
resultType: 'scalar', resultType: 'scalar',
result: webTables, result: webTables,
warnings: v5Data?.data?.warnings || [],
}, },
warning: v5Data?.warning || undefined, warning: v5Data?.warning || undefined,
}, },

View File

@ -5,10 +5,7 @@ import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery,
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData';
import { import {
BaseBuilderQuery, BaseBuilderQuery,
FieldContext, FieldContext,
@ -30,6 +27,7 @@ import {
} from 'types/api/v5/queryRange'; } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { normalizeFunctionName } from 'utils/functionNameNormalizer';
type PrepareQueryRangePayloadV5Result = { type PrepareQueryRangePayloadV5Result = {
queryPayload: QueryRangePayloadV5; queryPayload: QueryRangePayloadV5;
@ -123,17 +121,21 @@ function createBaseSpec(
functions: isEmpty(queryData.functions) functions: isEmpty(queryData.functions)
? undefined ? undefined
: queryData.functions.map( : queryData.functions.map(
(func: QueryFunctionProps): QueryFunction => ({ (func: QueryFunction): QueryFunction => {
name: func.name as FunctionName, // Normalize function name to handle case sensitivity
const normalizedName = normalizeFunctionName(func?.name);
return {
name: normalizedName as FunctionName,
args: isEmpty(func.namedArgs) args: isEmpty(func.namedArgs)
? func.args.map((arg) => ({ ? func.args?.map((arg) => ({
value: arg, value: arg?.value,
})) }))
: Object.entries(func.namedArgs).map(([name, value]) => ({ : Object.entries(func?.namedArgs || {}).map(([name, value]) => ({
name, name,
value, value,
})), })),
}), };
},
), ),
selectFields: isEmpty(nonEmptySelectColumns) selectFields: isEmpty(nonEmptySelectColumns)
? undefined ? undefined

View File

@ -19,6 +19,7 @@ export interface NavigateToExplorerProps {
endTime?: number; endTime?: number;
sameTab?: boolean; sameTab?: boolean;
shouldResolveQuery?: boolean; shouldResolveQuery?: boolean;
widgetQuery?: Query;
} }
export function useNavigateToExplorer(): ( export function useNavigateToExplorer(): (
@ -30,11 +31,17 @@ export function useNavigateToExplorer(): (
); );
const prepareQuery = useCallback( const prepareQuery = useCallback(
(selectedFilters: TagFilterItem[], dataSource: DataSource): Query => ({ (
...currentQuery, selectedFilters: TagFilterItem[],
dataSource: DataSource,
query?: Query,
): Query => {
const widgetQuery = query || currentQuery;
return {
...widgetQuery,
builder: { builder: {
...currentQuery.builder, ...widgetQuery.builder,
queryData: currentQuery.builder.queryData queryData: widgetQuery.builder.queryData
.map((item) => ({ .map((item) => ({
...item, ...item,
dataSource, dataSource,
@ -50,7 +57,8 @@ export function useNavigateToExplorer(): (
.slice(0, 1), .slice(0, 1),
queryFormulas: [], queryFormulas: [],
}, },
}), };
},
[currentQuery], [currentQuery],
); );
@ -67,6 +75,7 @@ export function useNavigateToExplorer(): (
endTime, endTime,
sameTab, sameTab,
shouldResolveQuery, shouldResolveQuery,
widgetQuery,
} = props; } = props;
const urlParams = new URLSearchParams(); const urlParams = new URLSearchParams();
if (startTime && endTime) { if (startTime && endTime) {
@ -77,7 +86,7 @@ export function useNavigateToExplorer(): (
urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString()); urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString());
} }
let preparedQuery = prepareQuery(filters, dataSource); let preparedQuery = prepareQuery(filters, dataSource, widgetQuery);
if (shouldResolveQuery) { if (shouldResolveQuery) {
await getUpdatedQuery({ await getUpdatedQuery({

View File

@ -0,0 +1,33 @@
.error-state-container {
height: 240px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
border-radius: 3px;
.error-state-container-content {
display: flex;
flex-direction: column;
gap: 8px;
.error-state-text {
font-size: 14px;
font-weight: 500;
}
.error-state-additional-messages {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
.error-state-additional-text {
font-size: 12px;
font-weight: 400;
margin-left: 8px;
}
}
}
}

View File

@ -0,0 +1,59 @@
import './Common.styles.scss';
import { Typography } from 'antd';
import APIError from '../../types/api/error';
interface ErrorStateComponentProps {
message?: string;
error?: APIError;
}
const defaultProps: Partial<ErrorStateComponentProps> = {
message: undefined,
error: undefined,
};
function ErrorStateComponent({
message,
error,
}: ErrorStateComponentProps): JSX.Element {
// Handle API Error object
if (error) {
const mainMessage = error.getErrorMessage();
const additionalErrors = error.getErrorDetails().error.errors || [];
return (
<div className="error-state-container">
<div className="error-state-container-content">
<Typography className="error-state-text">{mainMessage}</Typography>
{additionalErrors.length > 0 && (
<div className="error-state-additional-messages">
{additionalErrors.map((additionalError) => (
<Typography
key={`error-${additionalError.message}`}
className="error-state-additional-text"
>
{additionalError.message}
</Typography>
))}
</div>
)}
</div>
</div>
);
}
// Handle simple string message (backwards compatibility)
return (
<div className="error-state-container">
<div className="error-state-container-content">
<Typography className="error-state-text">{message}</Typography>
</div>
</div>
);
}
ErrorStateComponent.defaultProps = defaultProps;
export default ErrorStateComponent;

View File

@ -1,4 +1,11 @@
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'; import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
// Types for the context state // Types for the context state
export type AggregationOption = { func: string; arg: string }; export type AggregationOption = { func: string; arg: string };
@ -6,8 +13,12 @@ export type AggregationOption = { func: string; arg: string };
interface QueryBuilderV2ContextType { interface QueryBuilderV2ContextType {
searchText: string; searchText: string;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
aggregationOptions: AggregationOption[]; aggregationOptionsMap: Record<string, AggregationOption[]>;
setAggregationOptions: (options: AggregationOption[]) => void; setAggregationOptions: (
queryName: string,
options: AggregationOption[],
) => void;
getAggregationOptions: (queryName: string) => AggregationOption[];
aggregationInterval: string; aggregationInterval: string;
setAggregationInterval: (interval: string) => void; setAggregationInterval: (interval: string) => void;
queryAddValues: any; // Replace 'any' with a more specific type if available queryAddValues: any; // Replace 'any' with a more specific type if available
@ -24,26 +35,50 @@ export function QueryBuilderV2Provider({
children: ReactNode; children: ReactNode;
}): JSX.Element { }): JSX.Element {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [aggregationOptions, setAggregationOptions] = useState< const [aggregationOptionsMap, setAggregationOptionsMap] = useState<
AggregationOption[] Record<string, AggregationOption[]>
>([]); >({});
const [aggregationInterval, setAggregationInterval] = useState(''); const [aggregationInterval, setAggregationInterval] = useState('');
const [queryAddValues, setQueryAddValues] = useState<any>(null); // Replace 'any' if you have a type const [queryAddValues, setQueryAddValues] = useState<any>(null); // Replace 'any' if you have a type
const setAggregationOptions = useCallback(
(queryName: string, options: AggregationOption[]): void => {
setAggregationOptionsMap((prev) => ({
...prev,
[queryName]: options,
}));
},
[],
);
const getAggregationOptions = useCallback(
(queryName: string): AggregationOption[] =>
aggregationOptionsMap[queryName] || [],
[aggregationOptionsMap],
);
return ( return (
<QueryBuilderV2Context.Provider <QueryBuilderV2Context.Provider
value={useMemo( value={useMemo(
() => ({ () => ({
searchText, searchText,
setSearchText, setSearchText,
aggregationOptions, aggregationOptionsMap,
setAggregationOptions, setAggregationOptions,
getAggregationOptions,
aggregationInterval, aggregationInterval,
setAggregationInterval, setAggregationInterval,
queryAddValues, queryAddValues,
setQueryAddValues, setQueryAddValues,
}), }),
[searchText, aggregationOptions, aggregationInterval, queryAddValues], [
searchText,
aggregationOptionsMap,
aggregationInterval,
queryAddValues,
getAggregationOptions,
setAggregationOptions,
],
)} )}
> >
{children} {children}

View File

@ -7,7 +7,6 @@ import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import SpaceAggregationOptions from 'container/QueryBuilder/components/SpaceAggregationOptions/SpaceAggregationOptions'; import SpaceAggregationOptions from 'container/QueryBuilder/components/SpaceAggregationOptions/SpaceAggregationOptions';
import { GroupByFilter, OperatorsSelect } from 'container/QueryBuilder/filters'; import { GroupByFilter, OperatorsSelect } from 'container/QueryBuilder/filters';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Info } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo } from 'react'; import { memo, useCallback, useEffect, useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange'; import { MetricAggregation } from 'types/api/v5/queryRange';
@ -50,17 +49,17 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
); );
useEffect(() => { useEffect(() => {
setAggregationOptions([ setAggregationOptions(query.queryName, [
{ {
func: queryAggregation.spaceAggregation || 'count', func: queryAggregation.spaceAggregation || 'count',
arg: queryAggregation.metricName || '', arg: queryAggregation.metricName || '',
}, },
]); ]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
queryAggregation.spaceAggregation, queryAggregation.spaceAggregation,
queryAggregation.metricName, queryAggregation.metricName,
setAggregationOptions, query.queryName,
query,
]); ]);
const handleChangeGroupByKeys = useCallback( const handleChangeGroupByKeys = useCallback(
@ -100,12 +99,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<div className="metrics-time-aggregation-section"> <div className="metrics-time-aggregation-section">
<div className="metrics-aggregation-section-content"> <div className="metrics-aggregation-section-content">
<div className="metrics-aggregation-section-content-item"> <div className="metrics-aggregation-section-content-item">
<Tooltip
title={
<a
href="https://signoz.io/docs/metrics-management/types-and-aggregation/#aggregation"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn more about temporal aggregation
</a>
}
>
<div className="metrics-aggregation-section-content-item-label main-label"> <div className="metrics-aggregation-section-content-item-label main-label">
AGGREGATE BY TIME{' '} AGGREGATE WITHIN TIME SERIES{' '}
<Tooltip title="AGGREGATE BY TIME">
<Info size={12} />
</Tooltip>
</div> </div>
</Tooltip>
<div className="metrics-aggregation-section-content-item-value"> <div className="metrics-aggregation-section-content-item-value">
<OperatorsSelect <OperatorsSelect
value={queryAggregation.timeAggregation || ''} value={queryAggregation.timeAggregation || ''}
@ -118,9 +127,30 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
{showAggregationInterval && ( {showAggregationInterval && (
<div className="metrics-aggregation-section-content-item"> <div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-label"> <Tooltip
title={
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
}
placement="top"
>
<div
className="metrics-aggregation-section-content-item-label"
style={{ cursor: 'help' }}
>
every every
</div> </div>
</Tooltip>
<div className="metrics-aggregation-section-content-item-value"> <div className="metrics-aggregation-section-content-item-value">
<InputWithLabel <InputWithLabel
@ -138,12 +168,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<div className="metrics-space-aggregation-section"> <div className="metrics-space-aggregation-section">
<div className="metrics-aggregation-section-content"> <div className="metrics-aggregation-section-content">
<div className="metrics-aggregation-section-content-item"> <div className="metrics-aggregation-section-content-item">
<Tooltip
title={
<a
href="https://signoz.io/docs/metrics-management/types-and-aggregation/#aggregation"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn more about spatial aggregation
</a>
}
>
<div className="metrics-aggregation-section-content-item-label main-label"> <div className="metrics-aggregation-section-content-item-label main-label">
AGGREGATE LABELS AGGREGATE ACROSS TIME SERIES
<Tooltip title="AGGREGATE LABELS">
<Info size={12} />
</Tooltip>
</div> </div>
</Tooltip>
<div className="metrics-aggregation-section-content-item-value"> <div className="metrics-aggregation-section-content-item-value">
<SpaceAggregationOptions <SpaceAggregationOptions
panelType={panelType} panelType={panelType}
@ -208,9 +248,30 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
</div> </div>
</div> </div>
<div className="metrics-aggregation-section-content-item"> <div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-label"> <Tooltip
title={
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
}
placement="top"
>
<div
className="metrics-aggregation-section-content-item-label"
style={{ cursor: 'help' }}
>
every every
</div> </div>
</Tooltip>
<div className="metrics-aggregation-section-content-item-value"> <div className="metrics-aggregation-section-content-item-value">
<InputWithLabel <InputWithLabel

View File

@ -22,7 +22,11 @@ export const MetricsSelect = memo(function MetricsSelect({
return ( return (
<div className="metrics-select-container"> <div className="metrics-select-container">
<AggregatorFilter onChange={handleChangeAggregatorAttribute} query={query} /> <AggregatorFilter
onChange={handleChangeAggregatorAttribute}
query={query}
index={index}
/>
</div> </div>
); );
}); });

View File

@ -95,7 +95,8 @@ function HavingFilter({
queryData: IBuilderQuery; queryData: IBuilderQuery;
}): JSX.Element { }): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { aggregationOptions } = useQueryBuilderV2Context(); const { getAggregationOptions } = useQueryBuilderV2Context();
const aggregationOptions = getAggregationOptions(queryData.queryName);
const having = queryData?.having as Having; const having = queryData?.having as Having;
const [input, setInput] = useState(having?.expression || ''); const [input, setInput] = useState(having?.expression || '');

View File

@ -1,6 +1,7 @@
/* eslint-disable react/require-default-props */
import './QueryAddOns.styles.scss'; import './QueryAddOns.styles.scss';
import { Button, Radio, RadioChangeEvent } from 'antd'; import { Button, Radio, RadioChangeEvent, Tooltip } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel'; import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter'; import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
@ -9,7 +10,7 @@ import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/Re
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ScrollText } from 'lucide-react'; import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange'; import { MetricAggregation } from 'types/api/v5/queryRange';
@ -21,6 +22,8 @@ interface AddOn {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
key: string; key: string;
description?: string;
docLink?: string;
} }
const ADD_ONS_KEYS = { const ADD_ONS_KEYS = {
@ -36,26 +39,45 @@ const ADD_ONS = [
icon: <BarChart2 size={14} />, icon: <BarChart2 size={14} />,
label: 'Group By', label: 'Group By',
key: 'group_by', key: 'group_by',
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
}, },
{ {
icon: <ScrollText size={14} />, icon: <ScrollText size={14} />,
label: 'Having', label: 'Having',
key: 'having', key: 'having',
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
}, },
{ {
icon: <ScrollText size={14} />, icon: <ScrollText size={14} />,
label: 'Order By', label: 'Order By',
key: 'order_by', key: 'order_by',
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
}, },
{ {
icon: <ScrollText size={14} />, icon: <ScrollText size={14} />,
label: 'Limit', label: 'Limit',
key: 'limit', key: 'limit',
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
}, },
{ {
icon: <ScrollText size={14} />, icon: <ScrollText size={14} />,
label: 'Legend format', label: 'Legend format',
key: 'legend_format', key: 'legend_format',
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
}, },
]; ];
@ -63,8 +85,58 @@ const REDUCE_TO = {
icon: <ScrollText size={14} />, icon: <ScrollText size={14} />,
label: 'Reduce to', label: 'Reduce to',
key: 'reduce_to', key: 'reduce_to',
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
}; };
// Custom tooltip content component
function TooltipContent({
label,
description,
docLink,
}: {
label: string;
description?: string;
docLink?: string;
}): JSX.Element {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
maxWidth: '300px',
}}
>
<strong style={{ fontSize: '14px' }}>{label}</strong>
{description && (
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
)}
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: '#4096ff',
fontSize: '12px',
marginTop: '4px',
}}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
}
function QueryAddOns({ function QueryAddOns({
query, query,
version, version,
@ -212,7 +284,21 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'group_by') && ( {selectedViews.find((view) => view.key === 'group_by') && (
<div className="add-on-content"> <div className="add-on-content">
<div className="periscope-input-with-label"> <div className="periscope-input-with-label">
<div className="label">Group By</div> <Tooltip
title={
<TooltipContent
label="Group By"
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Group By
</div>
</Tooltip>
<div className="input"> <div className="input">
<GroupByFilter <GroupByFilter
disabled={ disabled={
@ -234,7 +320,21 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'having') && ( {selectedViews.find((view) => view.key === 'having') && (
<div className="add-on-content"> <div className="add-on-content">
<div className="periscope-input-with-label"> <div className="periscope-input-with-label">
<div className="label">Having</div> <Tooltip
title={
<TooltipContent
label="Having"
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Having
</div>
</Tooltip>
<div className="input"> <div className="input">
<HavingFilter <HavingFilter
onClose={(): void => { onClose={(): void => {
@ -266,7 +366,21 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'order_by') && ( {selectedViews.find((view) => view.key === 'order_by') && (
<div className="add-on-content"> <div className="add-on-content">
<div className="periscope-input-with-label"> <div className="periscope-input-with-label">
<div className="label">Order By</div> <Tooltip
title={
<TooltipContent
label="Order By"
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Order By
</div>
</Tooltip>
<div className="input"> <div className="input">
<OrderByFilter <OrderByFilter
entityVersion={version} entityVersion={version}
@ -290,7 +404,21 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && ( {selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
<div className="add-on-content"> <div className="add-on-content">
<div className="periscope-input-with-label"> <div className="periscope-input-with-label">
<div className="label">Reduce to</div> <Tooltip
title={
<TooltipContent
label="Reduce to"
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<div className="label" style={{ cursor: 'help' }}>
Reduce to
</div>
</Tooltip>
<div className="input"> <div className="input">
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} /> <ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div> </div>
@ -330,8 +458,19 @@ function QueryAddOns({
value={selectedViews} value={selectedViews}
> >
{addOns.map((addOn) => ( {addOns.map((addOn) => (
<Tooltip
key={addOn.key}
title={
<TooltipContent
label={addOn.label}
description={addOn.description}
docLink={addOn.docLink}
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<Radio.Button <Radio.Button
key={addOn.label}
className={ className={
selectedViews.find((view) => view.key === addOn.key) selectedViews.find((view) => view.key === addOn.key)
? 'selected-view tab' ? 'selected-view tab'
@ -344,6 +483,7 @@ function QueryAddOns({
{addOn.label} {addOn.label}
</div> </div>
</Radio.Button> </Radio.Button>
</Tooltip>
))} ))}
</Radio.Group> </Radio.Group>
</div> </div>

View File

@ -1,5 +1,6 @@
import './QueryAggregation.styles.scss'; import './QueryAggregation.styles.scss';
import { Tooltip } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel'; import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -53,7 +54,31 @@ function QueryAggregationOptions({
{showAggregationInterval && ( {showAggregationInterval && (
<div className="query-aggregation-interval"> <div className="query-aggregation-interval">
<div className="query-aggregation-interval-label">every</div> <Tooltip
title={
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
}
placement="top"
>
<div
className="metrics-aggregation-section-content-item-label"
style={{ cursor: 'help' }}
>
every
</div>
</Tooltip>
<div className="query-aggregation-interval-input-container"> <div className="query-aggregation-interval-input-container">
<InputWithLabel <InputWithLabel
initialValue={ initialValue={

View File

@ -27,13 +27,13 @@ import CodeMirror, {
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
} from '@uiw/react-codemirror'; } from '@uiw/react-codemirror';
import { Button, Popover } from 'antd'; import { Button, Popover, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions'; import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants'; import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryBuilderKeys } from 'constants/queryBuilder'; import { QueryBuilderKeys } from 'constants/queryBuilder';
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators'; import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { TriangleAlert } from 'lucide-react'; import { Info, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@ -263,7 +263,7 @@ function QueryAggregationSelect({
setValidationError(validateAggregations()); setValidationError(validateAggregations());
setFunctionArgPairs(pairs); setFunctionArgPairs(pairs);
setAggregationOptions(pairs); setAggregationOptions(queryData.queryName, pairs);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [input, maxAggregations, validFunctions]); }, [input, maxAggregations, validFunctions]);
@ -639,6 +639,50 @@ function QueryAggregationSelect({
} }
}} }}
/> />
<Tooltip
title={
<div>
Aggregation functions:
<br />
<span style={{ fontSize: '12px', lineHeight: '1.4' }}>
<strong>count</strong> - number of occurrences
<br /> <strong>sum/avg</strong> - sum/average of values
<br /> <strong>min/max</strong> - minimum/maximum value
<br /> <strong>p50/p90/p99</strong> - percentiles
<br /> <strong>count_distinct</strong> - unique values
<br /> <strong>rate</strong> - per-interval rate
</span>
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
View documentation
</a>
</div>
}
placement="left"
>
<div
style={{
position: 'absolute',
top: '8px', // Match the error icon's top position
right: validationError ? '40px' : '8px', // Move left when error icon is shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
}}
>
<Info
size={14}
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
/>
</div>
</Tooltip>
{validationError && ( {validationError && (
<div className="query-aggregation-error-container"> <div className="query-aggregation-error-container">
<Popover <Popover

View File

@ -12,22 +12,7 @@ export default function QueryFooter({
<div className="qb-footer"> <div className="qb-footer">
<div className="qb-footer-container"> <div className="qb-footer-container">
<div className="qb-add-new-query"> <div className="qb-add-new-query">
<Tooltip <Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
title={
<div style={{ textAlign: 'center' }}>
Add New Query
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#multiple-queries-and-functions"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button <Button
className="add-new-query-button periscope-btn secondary" className="add-new-query-button periscope-btn secondary"
type="text" type="text"
@ -43,7 +28,7 @@ export default function QueryFooter({
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
Add New Formula Add New Formula
<Typography.Link <Typography.Link
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#multiple-queries-and-functions" href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
target="_blank" target="_blank"
style={{ textDecoration: 'underline' }} style={{ textDecoration: 'underline' }}
> >

View File

@ -16,12 +16,13 @@ import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot'; import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github'; import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror'; import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tag } from 'antd'; import { Button, Card, Collapse, Popover, Tag, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions'; import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion'; import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames'; import cx from 'classnames';
import { import {
negationQueryOperatorSuggestions, negationQueryOperatorSuggestions,
OPERATORS,
QUERY_BUILDER_KEY_TYPES, QUERY_BUILDER_KEY_TYPES,
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE, QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions, queryOperatorSuggestions,
@ -30,7 +31,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce'; import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es'; import { debounce, isNull } from 'lodash-es';
import { TriangleAlert } from 'lucide-react'; import { Info, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
IDetailedError, IDetailedError,
@ -40,11 +41,11 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types'; import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/antlrQueryUtils';
import { import {
getCurrentValueIndexAtCursor, getCurrentValueIndexAtCursor,
getQueryContextAtCursor, getQueryContextAtCursor,
} from 'utils/queryContextUtils'; } from 'utils/queryContextUtils';
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils'; import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants'; import { queryExamples } from './constants';
@ -88,10 +89,7 @@ function QuerySearch({
}): JSX.Element { }): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(queryData.filter?.expression || ''); const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([ const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
{ label: 'error', type: 'value' },
{ label: 'frontend', type: 'value' },
]);
const [activeKey, setActiveKey] = useState<string>(''); const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [queryContext, setQueryContext] = useState<IQueryContext | null>(null); const [queryContext, setQueryContext] = useState<IQueryContext | null>(null);
@ -114,9 +112,27 @@ function QuerySearch({
} }
}; };
// Track if the query was changed externally (from queryData) vs internally (user input)
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
useEffect(() => { useEffect(() => {
setQuery(queryData.filter?.expression || ''); const newQuery = queryData.filter?.expression || '';
}, [queryData.filter?.expression]); // Only mark as external change if the query actually changed from external source
if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
}, [queryData.filter?.expression, lastExternalQuery]);
// Validate query when it changes externally (from queryData)
useEffect(() => {
if (isExternalQueryChange && query) {
handleQueryValidation(query);
setIsExternalQueryChange(false);
}
}, [isExternalQueryChange, query]);
const [keySuggestions, setKeySuggestions] = useState< const [keySuggestions, setKeySuggestions] = useState<
QueryKeyDataSuggestionsProps[] | null QueryKeyDataSuggestionsProps[] | null
@ -127,7 +143,6 @@ function QuerySearch({
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 }); const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isCompleteKeysList, setIsCompleteKeysList] = useState(false);
const [ const [
isFetchingCompleteValuesList, isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList, setIsFetchingCompleteValuesList,
@ -170,8 +185,28 @@ function QuerySearch({
500, 500,
); );
const fetchKeySuggestions = async (searchText?: string): Promise<void> => { const toggleSuggestions = useCallback(
if (dataSource === DataSource.METRICS && !queryData.aggregateAttribute?.key) { (timeout?: number) => {
const timeoutId = setTimeout(() => {
if (!editorRef.current) return;
if (isFocused) {
startCompletion(editorRef.current);
} else {
closeCompletion(editorRef.current);
}
}, timeout);
return (): void => clearTimeout(timeoutId);
},
[isFocused],
);
const fetchKeySuggestions = useCallback(
async (searchText?: string): Promise<void> => {
if (
dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key
) {
setKeySuggestions([]); setKeySuggestions([]);
return; return;
} }
@ -182,7 +217,7 @@ function QuerySearch({
}); });
if (response.data.data) { if (response.data.data) {
const { complete, keys } = response.data.data; const { keys } = response.data.data;
const options = generateOptions(keys); const options = generateOptions(keys);
// Use a Map to deduplicate by label and preserve order: new options take precedence // Use a Map to deduplicate by label and preserve order: new options take precedence
const merged = new Map<string, QueryKeyDataSuggestionsProps>(); const merged = new Map<string, QueryKeyDataSuggestionsProps>();
@ -193,13 +228,30 @@ function QuerySearch({
}); });
} }
setKeySuggestions(Array.from(merged.values())); setKeySuggestions(Array.from(merged.values()));
setIsCompleteKeysList(complete);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
} }
}; }
},
[
dataSource,
debouncedMetricName,
keySuggestions,
toggleSuggestions,
queryData.aggregateAttribute?.key,
],
);
const debouncedFetchKeySuggestions = useMemo(
() => debounce(fetchKeySuggestions, 300),
[fetchKeySuggestions],
);
useEffect(() => { useEffect(() => {
setKeySuggestions([]); setKeySuggestions([]);
fetchKeySuggestions(); debouncedFetchKeySuggestions();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSource, debouncedMetricName]); }, [dataSource, debouncedMetricName]);
@ -310,6 +362,11 @@ function QuerySearch({
}, },
]); ]);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
}
const sanitizedSearchText = searchText ? searchText?.trim() : ''; const sanitizedSearchText = searchText ? searchText?.trim() : '';
try { try {
@ -382,13 +439,9 @@ function QuerySearch({
]); ]);
} }
// Force reopen the completion if editor is available // Force reopen the completion if editor is available and focused
if (editorRef.current) { if (editorRef.current) {
setTimeout(() => { toggleSuggestions(10);
if (isMountedRef.current && editorRef.current) {
startCompletion(editorRef.current);
}
}, 10);
} }
} }
} catch (error) { } catch (error) {
@ -408,7 +461,8 @@ function QuerySearch({
setIsFetchingCompleteValuesList(false); setIsFetchingCompleteValuesList(false);
} }
}, },
[activeKey, dataSource, isLoadingSuggestions], // eslint-disable-next-line react-hooks/exhaustive-deps
[activeKey, dataSource, isFocused],
); );
const debouncedFetchValueSuggestions = useMemo( const debouncedFetchValueSuggestions = useMemo(
@ -468,14 +522,13 @@ function QuerySearch({
} }
}, []); }, []);
const handleQueryChange = useCallback(async (newQuery: string) => {
setQuery(newQuery);
}, []);
const handleChange = (value: string): void => { const handleChange = (value: string): void => {
setQuery(value); setQuery(value);
handleQueryChange(value);
onChange(value); onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(value);
}; };
const handleBlur = (): void => { const handleBlur = (): void => {
@ -483,24 +536,27 @@ function QuerySearch({
setIsFocused(false); setIsFocused(false);
}; };
useEffect(() => { useEffect(
if (query) { () => (): void => {
handleQueryValidation(query);
}
return (): void => {
if (debouncedFetchValueSuggestions) { if (debouncedFetchValueSuggestions) {
debouncedFetchValueSuggestions.cancel(); debouncedFetchValueSuggestions.cancel();
} }
}; if (debouncedFetchKeySuggestions) {
debouncedFetchKeySuggestions.cancel();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); [],
);
const handleExampleClick = (exampleQuery: string): void => { const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND // If there's an existing query, append the example with AND
const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery; const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
setQuery(newQuery); setQuery(newQuery);
handleQueryChange(newQuery); // Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(newQuery);
}; };
// Helper function to render a badge for the current context mode // Helper function to render a badge for the current context mode
@ -743,16 +799,14 @@ function QuerySearch({
} }
if (queryContext.isInKey) { if (queryContext.isInKey) {
const searchText = word?.text.toLowerCase() ?? ''; const searchText = word?.text.toLowerCase().trim() ?? '';
options = (keySuggestions || []).filter((option) => options = (keySuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText), option.label.toLowerCase().includes(searchText),
); );
if (!isCompleteKeysList && options.length === 0) { if (options.length === 0 && lastKeyRef.current !== searchText) {
setTimeout(() => { debouncedFetchKeySuggestions(searchText);
fetchKeySuggestions(searchText);
}, 300);
} }
// If we have previous pairs, we can prioritize keys that haven't been used yet // If we have previous pairs, we can prioritize keys that haven't been used yet
@ -827,12 +881,32 @@ function QuerySearch({
QUERY_BUILDER_KEY_TYPES.STRING QUERY_BUILDER_KEY_TYPES.STRING
].includes(op.label), ].includes(op.label),
) )
.map((op) => ({ .map((op) => {
if (op.label === OPERATORS['=']) {
return {
...op, ...op,
boost: ['=', '!=', 'LIKE', 'ILIKE', 'CONTAINS', 'IN'].includes(op.label) boost: 200,
? 100 };
: 0, }
})); if (
[
OPERATORS['!='],
OPERATORS.LIKE,
OPERATORS.ILIKE,
OPERATORS.CONTAINS,
OPERATORS.IN,
].includes(op.label)
) {
return {
...op,
boost: 100,
};
}
return {
...op,
boost: 0,
};
});
} else if (keyType === QUERY_BUILDER_KEY_TYPES.BOOLEAN) { } else if (keyType === QUERY_BUILDER_KEY_TYPES.BOOLEAN) {
// Prioritize boolean operators // Prioritize boolean operators
options = options options = options
@ -841,10 +915,24 @@ function QuerySearch({
QUERY_BUILDER_KEY_TYPES.BOOLEAN QUERY_BUILDER_KEY_TYPES.BOOLEAN
].includes(op.label), ].includes(op.label),
) )
.map((op) => ({ .map((op) => {
if (op.label === OPERATORS['=']) {
return {
...op, ...op,
boost: ['=', '!='].includes(op.label) ? 100 : 0, boost: 200,
})); };
}
if (op.label === OPERATORS['!=']) {
return {
...op,
boost: 100,
};
}
return {
...op,
boost: 0,
};
});
} }
} }
} }
@ -1034,26 +1122,15 @@ function QuerySearch({
// Effect to handle focus state and trigger suggestions // Effect to handle focus state and trigger suggestions
useEffect(() => { useEffect(() => {
if (editorRef.current) { const clearTimeout = toggleSuggestions(10);
if (!isFocused) { return (): void => clearTimeout();
closeCompletion(editorRef.current); }, [isFocused, toggleSuggestions]);
} else {
startCompletion(editorRef.current);
}
}
}, [isFocused]);
useEffect(() => { useEffect(() => {
if (!queryContext) return; if (!queryContext) return;
// Trigger suggestions based on context // Trigger suggestions based on context
if (editorRef.current) { if (editorRef.current) {
// Small delay to ensure the context is fully updated toggleSuggestions(10);
setTimeout(() => {
if (editorRef.current) {
startCompletion(editorRef.current);
}
}, 50);
} }
// Handle value suggestions for value context // Handle value suggestions for value context
@ -1066,7 +1143,28 @@ function QuerySearch({
fetchValueSuggestions({ key }); fetchValueSuggestions({ key });
} }
} }
}, [queryContext, activeKey, isLoadingSuggestions, fetchValueSuggestions]); }, [
queryContext,
toggleSuggestions,
isLoadingSuggestions,
activeKey,
fetchValueSuggestions,
]);
const getTooltipContent = (): JSX.Element => (
<div>
Need help with search syntax?
<br />
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
View documentation
</a>
</div>
);
return ( return (
<div className="code-mirror-where-clause"> <div className="code-mirror-where-clause">
@ -1109,6 +1207,31 @@ function QuerySearch({
)} )}
<div className="query-where-clause-editor-container"> <div className="query-where-clause-editor-container">
<Tooltip title={getTooltipContent()} placement="left">
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
/>
</a>
</Tooltip>
<CodeMirror <CodeMirror
value={query} value={query}
theme={isDarkMode ? copilot : githubLight} theme={isDarkMode ? copilot : githubLight}
@ -1167,7 +1290,7 @@ function QuerySearch({
]), ]),
), ),
]} ]}
placeholder="Enter your filter query (e.g., status = 'error' AND service = 'frontend')" placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
basicSetup={{ basicSetup={{
lineNumbers: false, lineNumbers: false,
}} }}

View File

@ -21,6 +21,7 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils'; import { extractQueryPairs } from 'utils/queryContextUtils';
import { unquote } from 'utils/stringUtils'; import { unquote } from 'utils/stringUtils';
import { isFunctionOperator } from 'utils/tokenUtils';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
/** /**
@ -86,6 +87,10 @@ export const convertFiltersToExpression = (
return ''; return '';
} }
if (isFunctionOperator(op)) {
return `${op}(${key.key}, ${value})`;
}
const formattedValue = formatValueForExpression(value, op); const formattedValue = formatValueForExpression(value, op);
return `${key.key} ${op} ${formattedValue}`; return `${key.key} ${op} ${formattedValue}`;
}) })
@ -539,45 +544,18 @@ export const convertAggregationToExpression = (
]; ];
}; };
export const getQueryTitles = (currentQuery: Query): string[] => {
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
const queryTitles: string[] = [];
// Handle builder queries with multiple aggregations
currentQuery.builder.queryData.forEach((q) => {
const aggregationCount = q.aggregations?.length || 1;
if (aggregationCount > 1) {
// If multiple aggregations, create titles like A.0, A.1, A.2
for (let i = 0; i < aggregationCount; i++) {
queryTitles.push(`${q.queryName}.${i}`);
}
} else {
// Single aggregation, just use query name
queryTitles.push(q.queryName);
}
});
// Handle formulas (they don't have aggregations, so just use query name)
const formulas = currentQuery.builder.queryFormulas.map((q) => q.queryName);
return [...queryTitles, ...formulas];
}
if (currentQuery.queryType === EQueryType.CLICKHOUSE) {
return currentQuery.clickhouse_sql.map((q) => q.name);
}
return currentQuery.promql.map((q) => q.name);
};
function getColId( function getColId(
queryName: string, queryName: string,
aggregation: { alias?: string; expression?: string }, aggregation: { alias?: string; expression?: string },
isMultipleAggregations: boolean,
): string { ): string {
if (isMultipleAggregations && aggregation.expression) {
return `${queryName}.${aggregation.expression}`; return `${queryName}.${aggregation.expression}`;
} }
return queryName;
}
// function to give you label value for query name taking multiaggregation into account // function to give you label value for query name taking multiaggregation into account
export function getQueryLabelWithAggregation( export function getQueryLabelWithAggregation(
queryData: IBuilderQuery[], queryData: IBuilderQuery[],
@ -599,7 +577,7 @@ export function getQueryLabelWithAggregation(
const isMultipleAggregations = aggregations.length > 1; const isMultipleAggregations = aggregations.length > 1;
aggregations.forEach((agg: any, index: number) => { aggregations.forEach((agg: any, index: number) => {
const columnId = getColId(queryName, agg); const columnId = getColId(queryName, agg, isMultipleAggregations);
// For display purposes, show the aggregation index for multiple aggregations // For display purposes, show the aggregation index for multiple aggregations
const displayLabel = isMultipleAggregations const displayLabel = isMultipleAggregations

View File

@ -4,7 +4,7 @@ import { Table } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import cx from 'classnames'; import cx from 'classnames';
import { dragColumnParams } from 'hooks/useDragColumns/configs'; import { dragColumnParams } from 'hooks/useDragColumns/configs';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
import { debounce, set } from 'lodash-es'; import { debounce, set } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { import {
@ -110,12 +110,15 @@ function ResizeTable({
// Apply stored column widths from widget configuration // Apply stored column widths from widget configuration
const columnsWithStoredWidths = columns.map((col) => { const columnsWithStoredWidths = columns.map((col) => {
const dataIndex = (col as RowData).dataIndex as string; const dataIndex = (col as RowData).dataIndex as string;
if (dataIndex && columnWidths && columnWidths[dataIndex]) { if (dataIndex && columnWidths) {
const width = getColumnWidth(dataIndex, columnWidths);
if (width) {
return { return {
...col, ...col,
width: columnWidths[dataIndex], // Apply stored width width, // Apply stored width
}; };
} }
}
return col; return col;
}); });

View File

@ -15,6 +15,12 @@ export const OPERATORS = {
'<': '<', '<': '<',
}; };
export const QUERY_BUILDER_FUNCTIONS = {
HAS: 'has',
HASANY: 'hasAny',
HASALL: 'hasAll',
};
export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS]; export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS];
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -76,3 +82,15 @@ export const queryOperatorSuggestions = [
{ label: OPERATORS.NOT, type: 'operator', info: 'Not' }, { label: OPERATORS.NOT, type: 'operator', info: 'Not' },
...negationQueryOperatorSuggestions, ...negationQueryOperatorSuggestions,
]; ];
export function negateOperator(operatorOrFunction: string): string {
// Special cases for equals/not equals
if (operatorOrFunction === OPERATORS['=']) {
return OPERATORS['!='];
}
if (operatorOrFunction === OPERATORS['!=']) {
return OPERATORS['='];
}
// For all other operators and functions, add NOT in front
return `${OPERATORS.NOT} ${operatorOrFunction}`;
}

View File

@ -1,4 +1,4 @@
import { ENTITY_VERSION_V4 } from 'constants/app'; import { ENTITY_VERSION_V5 } from 'constants/app';
import { import {
initialQueryBuilderFormValuesMap, initialQueryBuilderFormValuesMap,
initialQueryPromQLData, initialQueryPromQLData,
@ -28,7 +28,7 @@ const defaultAnnotations = {
export const alertDefaults: AlertDef = { export const alertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT, alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V4, version: ENTITY_VERSION_V5,
condition: { condition: {
compositeQuery: { compositeQuery: {
builderQueries: { builderQueries: {
@ -62,7 +62,7 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = { export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT, alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V4, version: ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
condition: { condition: {
compositeQuery: { compositeQuery: {
@ -107,7 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
export const logAlertDefaults: AlertDef = { export const logAlertDefaults: AlertDef = {
alertType: AlertTypes.LOGS_BASED_ALERT, alertType: AlertTypes.LOGS_BASED_ALERT,
version: ENTITY_VERSION_V4, version: ENTITY_VERSION_V5,
condition: { condition: {
compositeQuery: { compositeQuery: {
builderQueries: { builderQueries: {
@ -139,7 +139,7 @@ export const logAlertDefaults: AlertDef = {
export const traceAlertDefaults: AlertDef = { export const traceAlertDefaults: AlertDef = {
alertType: AlertTypes.TRACES_BASED_ALERT, alertType: AlertTypes.TRACES_BASED_ALERT,
version: ENTITY_VERSION_V4, version: ENTITY_VERSION_V5,
condition: { condition: {
compositeQuery: { compositeQuery: {
builderQueries: { builderQueries: {
@ -171,7 +171,7 @@ export const traceAlertDefaults: AlertDef = {
export const exceptionAlertDefaults: AlertDef = { export const exceptionAlertDefaults: AlertDef = {
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT, alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
version: ENTITY_VERSION_V4, version: ENTITY_VERSION_V5,
condition: { condition: {
compositeQuery: { compositeQuery: {
builderQueries: { builderQueries: {

View File

@ -1,6 +1,6 @@
import { Form, Row } from 'antd'; import { Form, Row } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V4 } from 'constants/app'; import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules'; import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
@ -71,14 +71,14 @@ function CreateRules(): JSX.Element {
case AlertTypes.ANOMALY_BASED_ALERT: case AlertTypes.ANOMALY_BASED_ALERT:
setInitValues({ setInitValues({
...anamolyAlertDefaults, ...anamolyAlertDefaults,
version: version || ENTITY_VERSION_V4, version: version || ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT, ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
}); });
break; break;
default: default:
setInitValues({ setInitValues({
...alertDefaults, ...alertDefaults,
version: version || ENTITY_VERSION_V4, version: version || ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.THRESHOLD_ALERT, ruleType: AlertDetectionTypes.THRESHOLD_ALERT,
}); });
} }

View File

@ -8,6 +8,7 @@ import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView'; import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch'; import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util'; import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories'; import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
@ -37,6 +38,7 @@ import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { Warning } from 'types/api'; import { Warning } from 'types/api';
import { AlertDef } from 'types/api/alerts/def'; import { AlertDef } from 'types/api/alerts/def';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
@ -83,6 +85,7 @@ function ChartPreview({
const threshold = alertDef?.condition.target || 0; const threshold = alertDef?.condition.target || 0;
const [minTimeScale, setMinTimeScale] = useState<number>(); const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>(); const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
@ -174,6 +177,19 @@ function ChartPreview({
setMaxTimeScale(endTime); setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]); }, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]);
// Initialize graph visibility from localStorage
useEffect(() => {
if (queryResponse?.data?.payload?.data?.result) {
const {
graphVisibilityStates: localStoredVisibilityState,
} = getLocalStorageGraphVisibilityState({
apiResponse: queryResponse.data.payload.data.result,
name: 'alert-chart-preview',
});
setGraphVisibility(localStoredVisibilityState);
}
}, [queryResponse?.data?.payload?.data?.result]);
if (queryResponse.data && graphType === PANEL_TYPES.BAR) { if (queryResponse.data && graphType === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData( const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result, queryResponse.data?.payload.data.result,
@ -260,6 +276,10 @@ function ChartPreview({
timezone: timezone.value, timezone: timezone.value,
currentQuery, currentQuery,
query: query || currentQuery, query: query || currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
}), }),
[ [
yAxisUnit, yAxisUnit,
@ -277,6 +297,7 @@ function ChartPreview({
timezone.value, timezone.value,
currentQuery, currentQuery,
query, query,
graphVisibility,
], ],
); );

View File

@ -37,11 +37,8 @@ import {
defaultEvalWindow, defaultEvalWindow,
defaultMatchType, defaultMatchType,
} from 'types/api/alerts/def'; } from 'types/api/alerts/def';
import { import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery, import { QueryFunction } from 'types/api/v5/queryRange';
Query,
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
@ -182,12 +179,17 @@ function FormAlertRules({
setDetectionMethod(value); setDetectionMethod(value);
}; };
const updateFunctions = (data: IBuilderQuery): QueryFunctionProps[] => { const updateFunctions = (data: IBuilderQuery): QueryFunction[] => {
const anomalyFunction = { const anomalyFunction: QueryFunction = {
name: 'anomaly', name: 'anomaly' as any,
args: [], args: [
namedArgs: { z_score_threshold: alertDef.condition.target || 3 }, {
name: 'z_score_threshold',
value: alertDef.condition.target || 3,
},
],
}; };
const functions = data.functions || []; const functions = data.functions || [];
if (alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) { if (alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {

View File

@ -30,6 +30,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Widgets } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update'; import { Props } from 'types/api/dashboard/update';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
@ -183,9 +184,19 @@ function WidgetGraphComponent({
notifications.success({ notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.', message: 'Panel cloned successfully, redirecting to new copy.',
}); });
const clonedWidget = updatedDashboard.data?.data?.widgets?.find(
(w) => w.id === uuid,
) as Widgets;
const queryParams = { const queryParams = {
graphType: widget?.panelTypes, [QueryParams.graphType]: clonedWidget?.panelTypes,
widgetId: uuid, [QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
}),
}; };
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`); safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
}, },

View File

@ -235,6 +235,7 @@ export const handleGraphClick = async ({
? customTracesTimeRange?.end ? customTracesTimeRange?.end
: xValue + (stepInterval ?? 60), : xValue + (stepInterval ?? 60),
shouldResolveQuery: true, shouldResolveQuery: true,
widgetQuery: widget?.query,
}), }),
})); }));

View File

@ -1,8 +1,8 @@
import { getQueryRangeFormat } from 'api/dashboard/queryRangeFormat'; import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems'; import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables'; import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
@ -32,7 +32,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
GlobalReducer GlobalReducer
>((state) => state.globalTime); >((state) => state.globalTime);
const queryRangeMutation = useMutation(getQueryRangeFormat); const queryRangeMutation = useMutation(getSubstituteVars);
const getUpdatedQuery = useCallback( const getUpdatedQuery = useCallback(
async ({ async ({
@ -40,7 +40,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
selectedDashboard, selectedDashboard,
}: UseUpdatedQueryOptions): Promise<Query> => { }: UseUpdatedQueryOptions): Promise<Query> => {
// Prepare query payload with resolved variables // Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayload({ const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query, query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes), graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance, selectedTime: widgetConfig.timePreferance,
@ -53,7 +53,10 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryResult = await queryRangeMutation.mutateAsync(queryPayload); const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
// Map query data from API response // Map query data from API response
return mapQueryDataFromApi(queryResult.compositeQuery, widgetConfig?.query); return mapQueryDataFromApi(
queryResult.data.compositeQuery,
widgetConfig?.query,
);
}, },
[globalSelectedInterval, queryRangeMutation], [globalSelectedInterval, queryRangeMutation],
); );

View File

@ -7,7 +7,7 @@ import { ColumnType } from 'antd/es/table';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { Events } from 'constants/events'; import { Events } from 'constants/events';
import { QueryTable } from 'container/QueryTable'; import { QueryTable } from 'container/QueryTable';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { getColumnUnit, RowData } from 'lib/query/createTableColumnsFromQuery';
import { cloneDeep, get, isEmpty } from 'lodash-es'; import { cloneDeep, get, isEmpty } from 'lodash-es';
import { Compass } from 'lucide-react'; import { Compass } from 'lucide-react';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText'; import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
@ -84,10 +84,11 @@ function GridTableComponent({
(val): RowData => { (val): RowData => {
const newValue = { ...val }; const newValue = { ...val };
Object.keys(val).forEach((k) => { Object.keys(val).forEach((k) => {
if (columnUnits[k]) { const unit = getColumnUnit(k, columnUnits);
if (unit) {
// the check below takes care of not adding units for rows that have n/a or null values // the check below takes care of not adding units for rows that have n/a or null values
if (val[k] !== 'n/a' && val[k] !== null) { if (val[k] !== 'n/a' && val[k] !== null) {
newValue[k] = getYAxisFormattedValue(String(val[k]), columnUnits[k]); newValue[k] = getYAxisFormattedValue(String(val[k]), unit);
} else if (val[k] === null) { } else if (val[k] === null) {
newValue[k] = 'n/a'; newValue[k] = 'n/a';
} }
@ -121,7 +122,8 @@ function GridTableComponent({
render: (text: string, ...rest: any): ReactNode => { render: (text: string, ...rest: any): ReactNode => {
let textForThreshold = text; let textForThreshold = text;
const dataIndex = (e as ColumnType<RowData>)?.dataIndex || e.title; const dataIndex = (e as ColumnType<RowData>)?.dataIndex || e.title;
if (columnUnits && columnUnits?.[dataIndex as string]) { const unit = getColumnUnit(dataIndex as string, columnUnits || {});
if (unit) {
textForThreshold = rest[0][`${dataIndex}_without_unit`]; textForThreshold = rest[0][`${dataIndex}_without_unit`];
} }
const isNumber = !Number.isNaN(Number(textForThreshold)); const isNumber = !Number.isNaN(Number(textForThreshold));
@ -131,7 +133,7 @@ function GridTableComponent({
thresholds, thresholds,
dataIndex as string, dataIndex as string,
Number(textForThreshold), Number(textForThreshold),
columnUnits?.[dataIndex as string], unit,
); );
const idx = thresholds.findIndex( const idx = thresholds.findIndex(

View File

@ -1,7 +1,11 @@
import { orange } from '@ant-design/colors'; import { orange } from '@ant-design/colors';
import { SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { Dropdown, MenuProps } from 'antd'; import { Dropdown, MenuProps } from 'antd';
import { OPERATORS } from 'constants/queryBuilder'; import {
negateOperator,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useActiveLog } from 'hooks/logs/useActiveLog';
import { TitleWrapper } from './BodyTitleRenderer.styles'; import { TitleWrapper } from './BodyTitleRenderer.styles';
@ -29,7 +33,9 @@ function BodyTitleRenderer({
getDataTypes(value), getDataTypes(value),
), ),
`${value}`, `${value}`,
isFilterIn ? OPERATORS.HAS : OPERATORS.NHAS, isFilterIn
? QUERY_BUILDER_FUNCTIONS.HAS
: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
true, true,
parentIsArray ? getDataTypes([value]) : getDataTypes(value), parentIsArray ? getDataTypes([value]) : getDataTypes(value),
); );

View File

@ -82,6 +82,48 @@ jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
useGetExplorerQueryRange: jest.fn(), useGetExplorerQueryRange: jest.fn(),
})); }));
// Mock ErrorStateComponent to handle APIError properly
jest.mock(
'components/Common/ErrorStateComponent',
() =>
function MockErrorStateComponent({ error, message }: any): JSX.Element {
if (error) {
// Mock the getErrorMessage and getErrorDetails methods
const getErrorMessage = jest
.fn()
.mockReturnValue(
error.error?.message ||
'Something went wrong. Please try again or contact support.',
);
const getErrorDetails = jest.fn().mockReturnValue(error);
// Add the methods to the error object
const errorWithMethods = {
...error,
getErrorMessage,
getErrorDetails,
};
return (
<div data-testid="error-state-component">
<div>{errorWithMethods.getErrorMessage()}</div>
{errorWithMethods.getErrorDetails().error?.errors?.map((err: any) => (
<div key={`error-${err.message}`}> {err.message}</div>
))}
</div>
);
}
return (
<div data-testid="error-state-component">
<div>
{message || 'Something went wrong. Please try again or contact support.'}
</div>
</div>
);
},
);
jest.mock('hooks/useSafeNavigate', () => ({ jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({ useSafeNavigate: (): any => ({
safeNavigate: jest.fn(), safeNavigate: jest.fn(),

View File

@ -239,10 +239,7 @@ function QuerySection({
onChange={handleQueryCategoryChange} onChange={handleQueryCategoryChange}
tabBarExtraContent={ tabBarExtraContent={
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> <span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<TextToolTip <TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
text="This will temporarily save the current query and graph state. This will persist across tab change"
url="https://signoz.io/docs/userguide/query-builder?utm_source=product&utm_medium=query-builder"
/>
<Button <Button
loading={queryResponse.isFetching} loading={queryResponse.isFetching}
type="primary" type="primary"

View File

@ -3,7 +3,8 @@ import './ColumnUnitSelector.styles.scss';
import { Typography } from 'antd'; import { Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels'; import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Dispatch, SetStateAction, useCallback } from 'react'; import { isEmpty } from 'lodash-es';
import { Dispatch, SetStateAction, useCallback, useEffect } from 'react';
import { ColumnUnit } from 'types/api/dashboard/getAll'; import { ColumnUnit } from 'types/api/dashboard/getAll';
import YAxisUnitSelector from '../YAxisUnitSelector'; import YAxisUnitSelector from '../YAxisUnitSelector';
@ -31,12 +32,49 @@ export function ColumnUnitSelector(
[setColumnUnits], [setColumnUnits],
); );
const getValues = (value: string): string => {
const currentValue = columnUnits[value];
if (currentValue) {
return currentValue;
}
// if base query has value, return it
const baseQuery = value.split('.')[0];
if (columnUnits[baseQuery]) {
return columnUnits[baseQuery];
}
// if we have value as base query i.e. value = B, but the columnUnit have let say B.count(): 'h' then we need to return B.count()
// get the queryName B.count() from the columnUnits keys based on the B that we have (first match - 0th aggregationIndex)
const newQueryWithExpression = Object.keys(columnUnits).find(
(key) =>
key.startsWith(baseQuery) &&
!isEmpty(aggregationQueries.find((query) => query.value === key)),
);
if (newQueryWithExpression) {
return columnUnits[newQueryWithExpression];
}
return '';
};
useEffect(() => {
const newColumnUnits = aggregationQueries.reduce((acc, query) => {
acc[query.value] = getValues(query.value);
return acc;
}, {} as Record<string, string>);
setColumnUnits(newColumnUnits);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aggregationQueries]);
return ( return (
<section className="column-unit-selector"> <section className="column-unit-selector">
<Typography.Text className="heading">Column Units</Typography.Text> <Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => ( {aggregationQueries.map(({ value, label }) => (
<YAxisUnitSelector <YAxisUnitSelector
defaultValue={columnUnits[value]} defaultValue={columnUnits[value]}
value={columnUnits[value] || ''}
onSelect={(unitValue: string): void => onSelect={(unitValue: string): void =>
handleColumnUnitSelect(value, unitValue) handleColumnUnitSelect(value, unitValue)
} }

View File

@ -4,6 +4,7 @@ import { Button, Collapse, ColorPicker, Tooltip, Typography } from 'antd';
import { themeColors } from 'constants/theme'; import { themeColors } from 'constants/theme';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName'; import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor'; import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Palette } from 'lucide-react'; import { Palette } from 'lucide-react';
@ -69,7 +70,11 @@ function LegendColors({
const legendLabels = useMemo(() => { const legendLabels = useMemo(() => {
if (queryResponse?.data?.payload?.data?.result) { if (queryResponse?.data?.payload?.data?.result) {
return queryResponse.data.payload.data.result.map((item: any) => return queryResponse.data.payload.data.result.map((item: any) =>
getLegend(
item,
currentQuery,
getLabelName(item.metric || {}, item.queryName || '', item.legend || ''), getLabelName(item.metric || {}, item.queryName || '', item.legend || ''),
),
); );
} }

View File

@ -5,6 +5,7 @@ import { Button, Input, InputNumber, Select, Space, Typography } from 'antd';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { unitOptions } from 'container/NewWidget/utils'; import { unitOptions } from 'container/NewWidget/utils';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { getColumnUnit } from 'lib/query/createTableColumnsFromQuery';
import { Check, Pencil, Trash2, X } from 'lucide-react'; import { Check, Pencil, Trash2, X } from 'lucide-react';
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useDrag, useDrop, XYCoord } from 'react-dnd'; import { useDrag, useDrop, XYCoord } from 'react-dnd';
@ -197,7 +198,11 @@ function Threshold({
const isInvalidUnitComparison = useMemo( const isInvalidUnitComparison = useMemo(
() => () =>
unit !== 'none' && unit !== 'none' &&
convertUnit(value, unit, columnUnits?.[tableSelectedOption]) === null, convertUnit(
value,
unit,
getColumnUnit(tableSelectedOption, columnUnits || {}),
) === null,
[unit, value, columnUnits, tableSelectedOption], [unit, value, columnUnits, tableSelectedOption],
); );
@ -312,7 +317,9 @@ function Threshold({
{isEditMode ? ( {isEditMode ? (
<Select <Select
defaultValue={unit} defaultValue={unit}
options={unitOptions(columnUnits?.[tableSelectedOption] || '')} options={unitOptions(
getColumnUnit(tableSelectedOption, columnUnits || {}) || '',
)}
onChange={handleUnitChange} onChange={handleUnitChange}
showSearch showSearch
className="unit-selection" className="unit-selection"
@ -351,7 +358,7 @@ function Threshold({
{isInvalidUnitComparison && ( {isInvalidUnitComparison && (
<Typography.Text className="invalid-unit"> <Typography.Text className="invalid-unit">
Threshold unit ({unit}) is not valid in comparison with the column unit ( Threshold unit ({unit}) is not valid in comparison with the column unit (
{columnUnits?.[tableSelectedOption] || 'none'}) {getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'})
</Typography.Text> </Typography.Text>
)} )}
{isEditMode && ( {isEditMode && (

View File

@ -16,11 +16,13 @@ const findCategoryByName = (
type OnSelectType = Dispatch<SetStateAction<string>> | ((val: string) => void); type OnSelectType = Dispatch<SetStateAction<string>> | ((val: string) => void);
function YAxisUnitSelector({ function YAxisUnitSelector({
defaultValue, defaultValue,
value,
onSelect, onSelect,
fieldLabel, fieldLabel,
handleClear, handleClear,
}: { }: {
defaultValue: string; defaultValue: string;
value: string;
onSelect: OnSelectType; onSelect: OnSelectType;
fieldLabel: string; fieldLabel: string;
handleClear?: () => void; handleClear?: () => void;
@ -40,6 +42,7 @@ function YAxisUnitSelector({
options={options} options={options}
allowClear allowClear
defaultValue={findCategoryById(defaultValue)?.name} defaultValue={findCategoryById(defaultValue)?.name}
value={findCategoryById(value)?.name || ''}
onClear={handleClear} onClear={handleClear}
onSelect={onSelectHandler} onSelect={onSelectHandler}
filterOption={(inputValue, option): boolean => { filterOption={(inputValue, option): boolean => {

View File

@ -339,6 +339,7 @@ function RightContainer({
<YAxisUnitSelector <YAxisUnitSelector
defaultValue={yAxisUnit} defaultValue={yAxisUnit}
onSelect={setYAxisUnit} onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={ fieldLabel={
selectedGraphType === PanelDisplay.VALUE || selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE selectedGraphType === PanelDisplay.PIE

View File

@ -16,10 +16,8 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery, import { QueryFunction } from 'types/api/v5/queryRange';
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '..'; import { DataSourceDropdown } from '..';
@ -36,7 +34,7 @@ interface QBEntityOptionsProps {
onCloneQuery?: (type: string, query: IBuilderQuery) => void; onCloneQuery?: (type: string, query: IBuilderQuery) => void;
onToggleVisibility: () => void; onToggleVisibility: () => void;
onCollapseEntity: () => void; onCollapseEntity: () => void;
onQueryFunctionsUpdates?: (functions: QueryFunctionProps[]) => void; onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
showDeleteButton?: boolean; showDeleteButton?: boolean;
showCloneOption?: boolean; showCloneOption?: boolean;
isListViewPanel?: boolean; isListViewPanel?: boolean;

View File

@ -9,15 +9,14 @@ import {
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { debounce, isNil } from 'lodash-es'; import { debounce, isNil } from 'lodash-es';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery, import { QueryFunction } from 'types/api/v5/queryRange';
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder'; import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
import { normalizeFunctionName } from 'utils/functionNameNormalizer';
interface FunctionProps { interface FunctionProps {
query: IBuilderQuery; query: IBuilderQuery;
funcData: QueryFunctionProps; funcData: QueryFunction;
index: any; index: any;
handleUpdateFunctionArgs: any; handleUpdateFunctionArgs: any;
handleUpdateFunctionName: any; handleUpdateFunctionName: any;
@ -33,17 +32,19 @@ export default function Function({
handleDeleteFunction, handleDeleteFunction,
}: FunctionProps): JSX.Element { }: FunctionProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { showInput, disabled } = queryFunctionsTypesConfig[funcData.name]; // Normalize function name to handle backend response case sensitivity
const normalizedFunctionName = normalizeFunctionName(funcData.name);
const { showInput, disabled } = queryFunctionsTypesConfig[
normalizedFunctionName
];
let functionValue; let functionValue;
const hasValue = !isNil( const hasValue = !isNil(funcData.args?.[0]?.value);
funcData.args && funcData.args.length > 0 && funcData.args[0],
);
if (hasValue) { if (hasValue) {
// eslint-disable-next-line prefer-destructuring // eslint-disable-next-line prefer-destructuring
functionValue = funcData.args[0]; functionValue = funcData.args?.[0]?.value;
} }
const debouncedhandleUpdateFunctionArgs = debounce( const debouncedhandleUpdateFunctionArgs = debounce(
@ -57,9 +58,10 @@ export default function Function({
? logsQueryFunctionOptions ? logsQueryFunctionOptions
: metricQueryFunctionOptions; : metricQueryFunctionOptions;
const disableRemoveFunction = funcData.name === QueryFunctionsTypes.ANOMALY; const disableRemoveFunction =
normalizedFunctionName === QueryFunctionsTypes.ANOMALY;
if (funcData.name === QueryFunctionsTypes.ANOMALY) { if (normalizedFunctionName === QueryFunctionsTypes.ANOMALY) {
// eslint-disable-next-line react/jsx-no-useless-fragment // eslint-disable-next-line react/jsx-no-useless-fragment
return <></>; return <></>;
} }
@ -68,7 +70,7 @@ export default function Function({
<Flex className="query-function"> <Flex className="query-function">
<Select <Select
className={cx('query-function-name-selector', showInput ? 'showInput' : '')} className={cx('query-function-name-selector', showInput ? 'showInput' : '')}
value={funcData.name} value={normalizedFunctionName}
disabled={disabled} disabled={disabled}
style={{ minWidth: '100px' }} style={{ minWidth: '100px' }}
onChange={(value): void => { onChange={(value): void => {

View File

@ -6,29 +6,28 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { cloneDeep, pullAt } from 'lodash-es'; import { cloneDeep, pullAt } from 'lodash-es';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery, import { QueryFunction } from 'types/api/v5/queryRange';
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder'; import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
import { normalizeFunctionName } from 'utils/functionNameNormalizer';
import Function from './Function'; import Function from './Function';
import { toFloat64 } from './utils'; import { toFloat64 } from './utils';
const defaultMetricFunctionStruct: QueryFunctionProps = { const defaultMetricFunctionStruct: QueryFunction = {
name: QueryFunctionsTypes.CUTOFF_MIN, name: QueryFunctionsTypes.CUTOFF_MIN as any,
args: [], args: [],
}; };
const defaultLogFunctionStruct: QueryFunctionProps = { const defaultLogFunctionStruct: QueryFunction = {
name: QueryFunctionsTypes.TIME_SHIFT, name: QueryFunctionsTypes.TIME_SHIFT as any,
args: [], args: [],
}; };
interface QueryFunctionsProps { interface QueryFunctionsProps {
query: IBuilderQuery; query: IBuilderQuery;
queryFunctions: QueryFunctionProps[]; queryFunctions: QueryFunction[];
onChange: (functions: QueryFunctionProps[]) => void; onChange: (functions: QueryFunction[]) => void;
maxFunctions: number; maxFunctions: number;
} }
@ -87,8 +86,11 @@ export default function QueryFunctions({
onChange, onChange,
maxFunctions = 3, maxFunctions = 3,
}: QueryFunctionsProps): JSX.Element { }: QueryFunctionsProps): JSX.Element {
const [functions, setFunctions] = useState<QueryFunctionProps[]>( const [functions, setFunctions] = useState<QueryFunction[]>(
queryFunctions, queryFunctions.map((func) => ({
...func,
name: normalizeFunctionName(func.name) as any,
})),
); );
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
@ -127,7 +129,7 @@ export default function QueryFunctions({
}; };
const handleDeleteFunction = ( const handleDeleteFunction = (
queryFunction: QueryFunctionProps, queryFunction: QueryFunction,
index: number, index: number,
): void => { ): void => {
const clonedFunctions = cloneDeep(functions); const clonedFunctions = cloneDeep(functions);
@ -138,21 +140,23 @@ export default function QueryFunctions({
}; };
const handleUpdateFunctionName = ( const handleUpdateFunctionName = (
func: QueryFunctionProps, func: QueryFunction,
index: number, index: number,
value: string, value: string,
): void => { ): void => {
const updateFunctions = cloneDeep(functions); const updateFunctions = cloneDeep(functions);
if (updateFunctions && updateFunctions.length > 0 && updateFunctions[index]) { if (updateFunctions && updateFunctions.length > 0 && updateFunctions[index]) {
updateFunctions[index].name = value; // Normalize function name from backend response to match frontend expectations
const normalizedValue = normalizeFunctionName(value);
updateFunctions[index].name = normalizedValue as any;
setFunctions(updateFunctions); setFunctions(updateFunctions);
onChange(updateFunctions); onChange(updateFunctions);
} }
}; };
const handleUpdateFunctionArgs = ( const handleUpdateFunctionArgs = (
func: QueryFunctionProps, func: QueryFunction,
index: number, index: number,
value: string, value: string,
): void => { ): void => {
@ -160,11 +164,12 @@ export default function QueryFunctions({
if (updateFunctions && updateFunctions.length > 0 && updateFunctions[index]) { if (updateFunctions && updateFunctions.length > 0 && updateFunctions[index]) {
updateFunctions[index].args = [ updateFunctions[index].args = [
// timeShift expects a float64 value, so we convert the string to a number {
// For other functions, we keep the value as a string value:
updateFunctions[index].name === QueryFunctionsTypes.TIME_SHIFT updateFunctions[index].name === QueryFunctionsTypes.TIME_SHIFT
? toFloat64(value) ? toFloat64(value)
: value, : value,
},
]; ];
setFunctions(updateFunctions); setFunctions(updateFunctions);
onChange(updateFunctions); onChange(updateFunctions);

View File

@ -7,4 +7,5 @@ export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
onChange: (value: BaseAutocompleteData) => void; onChange: (value: BaseAutocompleteData) => void;
defaultValue?: string; defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void; onSelect?: (value: BaseAutocompleteData) => void;
index?: number;
}; };

View File

@ -13,7 +13,7 @@ import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { getAutocompleteValueAndType } from 'lib/newQueryBuilder/getAutocompleteValueAndType'; import { getAutocompleteValueAndType } from 'lib/newQueryBuilder/getAutocompleteValueAndType';
import { transformStringWithPrefix } from 'lib/query/transformStringWithPrefix'; import { transformStringWithPrefix } from 'lib/query/transformStringWithPrefix';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query'; import { useQuery, useQueryClient } from 'react-query';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { import {
@ -24,7 +24,6 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select'; import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
import { transformToUpperCase } from 'utils/transformToUpperCase';
import { removePrefix } from '../GroupByFilter/utils'; import { removePrefix } from '../GroupByFilter/utils';
import { selectStyle } from '../QueryBuilderSearch/config'; import { selectStyle } from '../QueryBuilderSearch/config';
@ -38,10 +37,10 @@ export const AggregatorFilter = memo(function AggregatorFilter({
onChange, onChange,
defaultValue, defaultValue,
onSelect, onSelect,
index,
}: AgregatorFilterProps): JSX.Element { }: AgregatorFilterProps): JSX.Element {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]); const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
const [searchText, setSearchText] = useState<string>('');
// this function is only relevant for metrics and now operators are part of aggregations // this function is only relevant for metrics and now operators are part of aggregations
const queryAggregation = useMemo( const queryAggregation = useMemo(
@ -49,6 +48,10 @@ export const AggregatorFilter = memo(function AggregatorFilter({
[query.aggregations], [query.aggregations],
); );
const [searchText, setSearchText] = useState<string>(
(query.aggregations?.[0] as MetricAggregation)?.metricName || '',
);
const debouncedSearchText = useMemo(() => { const debouncedSearchText = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
const [_, value] = getAutocompleteValueAndType(searchText); const [_, value] = getAutocompleteValueAndType(searchText);
@ -57,12 +60,13 @@ export const AggregatorFilter = memo(function AggregatorFilter({
}, [searchText]); }, [searchText]);
const debouncedValue = useDebounce(debouncedSearchText, DEBOUNCE_DELAY); const debouncedValue = useDebounce(debouncedSearchText, DEBOUNCE_DELAY);
const { isFetching } = useQuery( const { isFetching, data: aggregateAttributeData } = useQuery(
[ [
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE, QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
debouncedValue, debouncedValue,
queryAggregation.timeAggregation, queryAggregation.timeAggregation,
query.dataSource, query.dataSource,
index,
], ],
async () => async () =>
getAggregateAttribute({ getAggregateAttribute({
@ -108,13 +112,49 @@ export const AggregatorFilter = memo(function AggregatorFilter({
}, },
); );
// Handle edit mode: update aggregateAttribute type when data is available
useEffect(() => {
const metricName = queryAggregation?.metricName;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
// Check if we're in edit mode and have data from the existing query
// Also ensure this is for the correct query by checking the metric name matches
if (
query.dataSource === DataSource.METRICS &&
metricName &&
!hasAggregateAttributeType &&
aggregateAttributeData?.payload?.attributeKeys &&
// Only update if the data contains the metric we're looking for
aggregateAttributeData.payload.attributeKeys.some(
(item) => item.key === metricName,
)
) {
const metricData = aggregateAttributeData.payload.attributeKeys.find(
(item) => item.key === metricName,
);
if (metricData) {
// Update the aggregateAttribute with the fetched type information
onChange(metricData);
}
}
}, [
query.dataSource,
queryAggregation?.metricName,
query.aggregateAttribute?.type,
aggregateAttributeData,
onChange,
index,
query,
]);
const handleSearchText = useCallback((text: string): void => { const handleSearchText = useCallback((text: string): void => {
setSearchText(text); setSearchText(text);
}, []); }, []);
const placeholder: string = const placeholder: string =
query.dataSource === DataSource.METRICS query.dataSource === DataSource.METRICS
? `${transformToUpperCase(query.dataSource)} name` ? `Search metric name`
: 'Aggregate attribute'; : 'Aggregate attribute';
const getAttributesData = useCallback( const getAttributesData = useCallback(
@ -124,12 +164,14 @@ export const AggregatorFilter = memo(function AggregatorFilter({
debouncedValue, debouncedValue,
queryAggregation.timeAggregation, queryAggregation.timeAggregation,
query.dataSource, query.dataSource,
index,
])?.payload?.attributeKeys || [], ])?.payload?.attributeKeys || [],
[ [
debouncedValue, debouncedValue,
queryAggregation.timeAggregation, queryAggregation.timeAggregation,
query.dataSource, query.dataSource,
queryClient, queryClient,
index,
], ],
); );
@ -140,6 +182,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
searchText, searchText,
queryAggregation.timeAggregation, queryAggregation.timeAggregation,
query.dataSource, query.dataSource,
index,
], ],
async () => async () =>
getAggregateAttribute({ getAggregateAttribute({
@ -155,6 +198,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
query.dataSource, query.dataSource,
queryClient, queryClient,
searchText, searchText,
index,
]); ]);
const handleChangeCustomValue = useCallback( const handleChangeCustomValue = useCallback(

View File

@ -195,6 +195,7 @@ export const GroupByFilter = memo(function GroupByFilter({
notFoundContent={isFetching ? <Spin size="small" /> : null} notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange} onChange={handleChange}
data-testid="group-by" data-testid="group-by"
placeholder={localValues.length === 0 ? 'Everything (no breakdown)' : ''}
/> />
); );
}); });

View File

@ -5,6 +5,7 @@ import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import Uplot from 'components/Uplot'; import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch'; import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading'; import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch'; import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading'; import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
@ -36,6 +37,7 @@ import { useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions'; import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { SuccessResponse, Warning } from 'types/api'; import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
@ -76,6 +78,7 @@ function TimeSeriesView({
const [minTimeScale, setMinTimeScale] = useState<number>(); const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>(); const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState, AppState,
@ -89,6 +92,19 @@ function TimeSeriesView({
setMaxTimeScale(endTime); setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedInterval, data]); }, [maxTime, minTime, globalSelectedInterval, data]);
// Initialize graph visibility from localStorage
useEffect(() => {
if (data?.payload?.data?.result) {
const {
graphVisibilityStates: localStoredVisibilityState,
} = getLocalStorageGraphVisibilityState({
apiResponse: data.payload.data.result,
name: 'time-series-explorer',
});
setGraphVisibility(localStoredVisibilityState);
}
}, [data?.payload?.data?.result]);
const onDragSelect = useCallback( const onDragSelect = useCallback(
(start: number, end: number): void => { (start: number, end: number): void => {
const startTimestamp = Math.trunc(start); const startTimestamp = Math.trunc(start);
@ -162,6 +178,7 @@ function TimeSeriesView({
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const chartOptions = getUPlotChartOptions({ const chartOptions = getUPlotChartOptions({
id: 'time-series-explorer',
onDragSelect, onDragSelect,
yAxisUnit: yAxisUnit || '', yAxisUnit: yAxisUnit || '',
apiResponse: data?.payload, apiResponse: data?.payload,
@ -179,6 +196,10 @@ function TimeSeriesView({
timezone: timezone.value, timezone: timezone.value,
currentQuery, currentQuery,
query: currentQuery, query: currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
}); });
return ( return (

View File

@ -36,7 +36,6 @@ function TableView({
dataSource: 'traces', dataSource: 'traces',
}, },
}, },
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5, ENTITY_VERSION_V5,
{ {
queryKey: [ queryKey: [

View File

@ -1,14 +1,14 @@
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { getQueryRangeFormat } from 'api/dashboard/queryRangeFormat'; import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants'; import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables'; import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
import history from 'lib/history'; import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
@ -25,7 +25,7 @@ const useCreateAlerts = (
caller?: string, caller?: string,
thresholds?: ThresholdProps[], thresholds?: ThresholdProps[],
): VoidFunction => { ): VoidFunction => {
const queryRangeMutation = useMutation(getQueryRangeFormat); const queryRangeMutation = useMutation(getSubstituteVars);
const { selectedTime: globalSelectedInterval } = useSelector< const { selectedTime: globalSelectedInterval } = useSelector<
AppState, AppState,
@ -57,7 +57,7 @@ const useCreateAlerts = (
queryType: widget.query.queryType, queryType: widget.query.queryType,
}); });
} }
const { queryPayload } = prepareQueryRangePayload({ const { queryPayload } = prepareQueryRangePayloadV5({
query: widget.query, query: widget.query,
globalSelectedInterval, globalSelectedInterval,
graphType: getGraphType(widget.panelTypes), graphType: getGraphType(widget.panelTypes),
@ -68,7 +68,7 @@ const useCreateAlerts = (
queryRangeMutation.mutate(queryPayload, { queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => { onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi( const updatedQuery = mapQueryDataFromApi(
data.compositeQuery, data.data.compositeQuery,
widget?.query, widget?.query,
); );
@ -76,9 +76,7 @@ const useCreateAlerts = (
QueryParams.compositeQuery QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(updatedQuery))}&${ }=${encodeURIComponent(JSON.stringify(updatedQuery))}&${
QueryParams.panelTypes QueryParams.panelTypes
}=${widget.panelTypes}&version=${ }=${widget.panelTypes}&version=${ENTITY_VERSION_V5}`;
selectedDashboard?.data.version || DEFAULT_ENTITY_VERSION
}`;
history.push(url, { history.push(url, {
thresholds, thresholds,

View File

@ -30,10 +30,10 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { import {
IBuilderFormula, IBuilderFormula,
IBuilderQuery, IBuilderQuery,
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { import {
MetricAggregation, MetricAggregation,
QueryFunction,
SpaceAggregation, SpaceAggregation,
TimeAggregation, TimeAggregation,
} from 'types/api/v5/queryRange'; } from 'types/api/v5/queryRange';
@ -138,7 +138,6 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: value as TimeAggregation, timeAggregation: value as TimeAggregation,
}, },
], ],
having: [],
limit: null, limit: null,
...(shouldResetAggregateAttribute ...(shouldResetAggregateAttribute
? { aggregateAttribute: initialAutocompleteData } ? { aggregateAttribute: initialAutocompleteData }
@ -217,7 +216,6 @@ export const useQueryOperations: UseQueryOperations = ({
const newQuery: IBuilderQuery = { const newQuery: IBuilderQuery = {
...query, ...query,
aggregateAttribute: value, aggregateAttribute: value,
having: [],
}; };
if ( if (
@ -416,7 +414,7 @@ export const useQueryOperations: UseQueryOperations = ({
); );
const handleQueryFunctionsUpdates = useCallback( const handleQueryFunctionsUpdates = useCallback(
(functions: QueryFunctionProps[]): void => { (functions: QueryFunction[]): void => {
const newQuery: IBuilderQuery = { const newQuery: IBuilderQuery = {
...query, ...query,
}; };

View File

@ -72,6 +72,70 @@ const getQueryDataSource = (
return queryItem?.dataSource || null; return queryItem?.dataSource || null;
}; };
const getLegendForSingleAggregation = (
queryData: QueryData,
payloadQuery: Query,
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
(query) => query.queryName === queryData.queryName,
);
const legend = queryItem?.legend;
// Check if groupBy exists and has items
const hasGroupBy = queryItem?.groupBy && queryItem.groupBy.length > 0;
if (hasGroupBy) {
if (singleAggregation) {
return labelName;
} else {
return `${aggregationAlias || aggregationExpression}-${labelName}`;
}
} else {
if (singleAggregation) {
return aggregationAlias || legend || aggregationExpression;
} else {
return aggregationAlias || aggregationExpression;
}
}
};
const getLegendForMultipleAggregations = (
queryData: QueryData,
payloadQuery: Query,
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
(query) => query.queryName === queryData.queryName,
);
const legend = queryItem?.legend;
// Check if groupBy exists and has items
const hasGroupBy = queryItem?.groupBy && queryItem.groupBy.length > 0;
if (hasGroupBy) {
if (singleAggregation) {
return labelName;
} else {
return `${aggregationAlias || aggregationExpression}-${labelName}`;
}
} else {
if (singleAggregation) {
return aggregationAlias || labelName || aggregationExpression;
} else {
return `${aggregationAlias || aggregationExpression}-${labelName}`;
}
}
};
export const getLegend = ( export const getLegend = (
queryData: QueryData, queryData: QueryData,
payloadQuery: Query, payloadQuery: Query,
@ -91,21 +155,34 @@ export const getLegend = (
const aggregation = const aggregation =
aggregationPerQuery?.[metaData?.queryName]?.[metaData?.index]; aggregationPerQuery?.[metaData?.queryName]?.[metaData?.index];
const aggregationName = aggregation?.alias || aggregation?.expression || ''; const aggregationAlias = aggregation?.alias || '';
const aggregationExpression = aggregation?.expression || '';
// Check if there's only one total query (queryData + queryFormulas) // Check if there's only one total query (queryData)
const totalQueries = const singleQuery = payloadQuery?.builder?.queryData?.length === 1;
(payloadQuery?.builder?.queryData?.length || 0) + const singleAggregation =
(payloadQuery?.builder?.queryFormulas?.length || 0); aggregationPerQuery?.[metaData?.queryName]?.length === 1;
const showSingleAggregationName =
totalQueries === 1 && labelName === metaData?.queryName;
if (aggregationName) { if (aggregationAlias || aggregationExpression) {
return showSingleAggregationName return singleQuery
? aggregationName ? getLegendForSingleAggregation(
: `${aggregationName}-${labelName}`; queryData,
payloadQuery,
aggregationAlias,
aggregationExpression,
labelName,
singleAggregation,
)
: getLegendForMultipleAggregations(
queryData,
payloadQuery,
aggregationAlias,
aggregationExpression,
labelName,
singleAggregation,
);
} }
return labelName || metaData?.queryName; return labelName || metaData?.queryName || queryData.queryName;
}; };
export async function GetMetricQueryRange( export async function GetMetricQueryRange(
@ -151,6 +228,7 @@ export async function GetMetricQueryRange(
warning: undefined, warning: undefined,
}, },
params: props, params: props,
warnings: [],
}; };
} }
@ -178,6 +256,7 @@ export async function GetMetricQueryRange(
}, },
warning: undefined, warning: undefined,
params: props, params: props,
warnings: [],
}; };
} }

View File

@ -237,37 +237,6 @@ const addOperatorFormulaColumns = (
} }
}; };
const transformColumnTitles = (
dynamicColumns: DynamicColumns,
): DynamicColumns =>
dynamicColumns.map((item) => {
if (isFormula(item.field as string)) {
return item;
}
const sameValues = dynamicColumns.filter(
(column) => column.title === item.title,
);
if (sameValues.length > 1) {
return {
...item,
dataIndex: `${item.title} - ${get(
item.query,
'queryName',
get(item.query, 'name', ''),
)}`,
title: `${item.title} - ${get(
item.query,
'queryName',
get(item.query, 'name', ''),
)}`,
};
}
return item;
});
const processTableColumns = ( const processTableColumns = (
table: NonNullable<QueryDataV3['table']>, table: NonNullable<QueryDataV3['table']>,
currentStagedQuery: currentStagedQuery:
@ -369,7 +338,7 @@ const getDynamicColumns: GetDynamicColumns = (queryTableData, query) => {
} }
}); });
return transformColumnTitles(dynamicColumns); return dynamicColumns;
}; };
const fillEmptyRowCells = ( const fillEmptyRowCells = (
@ -634,9 +603,9 @@ const generateData = (
for (let i = 0; i < rowsLength; i += 1) { for (let i = 0; i < rowsLength; i += 1) {
const rowData: RowData = dynamicColumns.reduce((acc, item) => { const rowData: RowData = dynamicColumns.reduce((acc, item) => {
const { dataIndex } = item; const { dataIndex, id } = item;
acc[dataIndex] = item.data[i]; acc[id || dataIndex] = item.data[i];
acc.key = uuid(); acc.key = uuid();
return acc; return acc;
@ -655,25 +624,22 @@ const generateTableColumns = (
const columns: ColumnsType<RowData> = dynamicColumns.reduce< const columns: ColumnsType<RowData> = dynamicColumns.reduce<
ColumnsType<RowData> ColumnsType<RowData>
>((acc, item) => { >((acc, item) => {
const dataIndex = item.id || item.dataIndex;
const column: ColumnType<RowData> = { const column: ColumnType<RowData> = {
dataIndex: item.dataIndex, dataIndex,
title: item.title, title: item.title,
width: QUERY_TABLE_CONFIG.width, width: QUERY_TABLE_CONFIG.width,
render: renderColumnCell && renderColumnCell[item.dataIndex], render: renderColumnCell && renderColumnCell[dataIndex],
sorter: (a: RowData, b: RowData): number => { sorter: (a: RowData, b: RowData): number => {
const valueA = Number( const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
a[`${item.dataIndex}_without_unit`] ?? a[item.dataIndex], const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
);
const valueB = Number(
b[`${item.dataIndex}_without_unit`] ?? b[item.dataIndex],
);
if (!isNaN(valueA) && !isNaN(valueB)) { if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB; return valueA - valueB;
} }
return ((a[item.dataIndex] as string) || '').localeCompare( return ((a[dataIndex] as string) || '').localeCompare(
(b[item.dataIndex] as string) || '', (b[dataIndex] as string) || '',
); );
}, },
}; };
@ -684,6 +650,70 @@ const generateTableColumns = (
return columns; return columns;
}; };
/**
* Gets the appropriate column unit with fallback logic
* New syntax: queryName.expression -> unit
* Old syntax: queryName -> unit (fallback)
*
* Examples:
* - New syntax: "A.count()" -> looks for "A.count()" first, then falls back to "A"
* - Old syntax: "A" -> looks for "A" directly
* - Mixed: "A.avg(test)" -> looks for "A.avg(test)" first, then falls back to "A"
*
* @param columnKey - The column identifier (could be queryName.expression or queryName)
* @param columnUnits - The column units mapping
* @returns The unit string or undefined if not found
*/
export const getColumnUnit = (
columnKey: string,
columnUnits: Record<string, string>,
): string | undefined => {
// First try the exact match (new syntax: queryName.expression)
if (columnUnits[columnKey]) {
return columnUnits[columnKey];
}
// Fallback to old syntax: extract queryName from queryName.expression
if (columnKey.includes('.')) {
const queryName = columnKey.split('.')[0];
return columnUnits[queryName];
}
return undefined;
};
/**
* Gets the appropriate column width with fallback logic
* New syntax: queryName.expression -> width
* Old syntax: queryName -> width (fallback)
*
* Examples:
* - New syntax: "A.count()" -> looks for "A.count()" first, then falls back to "A"
* - Old syntax: "A" -> looks for "A" directly
* - Mixed: "A.avg(test)" -> looks for "A.avg(test)" first, then falls back to "A"
*
* @param columnKey - The column identifier (could be queryName.expression or queryName)
* @param columnWidths - The column widths mapping
* @returns The width number or undefined if not found
*/
export const getColumnWidth = (
columnKey: string,
columnWidths: Record<string, number>,
): number | undefined => {
// First try the exact match (new syntax: queryName.expression)
if (columnWidths[columnKey]) {
return columnWidths[columnKey];
}
// Fallback to old syntax: extract queryName from queryName.expression
if (columnKey.includes('.')) {
const queryName = columnKey.split('.')[0];
return columnWidths[queryName];
}
return undefined;
};
export const createTableColumnsFromQuery: CreateTableDataFromQuery = ({ export const createTableColumnsFromQuery: CreateTableDataFromQuery = ({
query, query,
queryTableData, queryTableData,

View File

@ -60,9 +60,9 @@ const getSeries = ({
: baseLabelName; : baseLabelName;
const color = const color =
colorMapping?.[label] || colorMapping?.[label || ''] ||
generateColor( generateColor(
label, label || '',
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
); );

View File

@ -252,7 +252,7 @@ describe('Logs Explorer Tests', () => {
); );
const queries = queryAllByText( const queries = queryAllByText(
"Enter your filter query (e.g., status = 'error' AND service = 'frontend')", "Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
); );
expect(queries.length).toBe(1); expect(queries.length).toBe(1);
}); });

View File

@ -899,9 +899,22 @@ export function QueryBuilderProvider({
[currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData], [currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData],
); );
useEffect(() => {
if (location.pathname !== currentPathnameRef.current) {
currentPathnameRef.current = location.pathname;
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
}
}, [location.pathname]);
// Separate useEffect to handle initQueryBuilderData after pathname changes
useEffect(() => { useEffect(() => {
if (!compositeQueryParam) return; if (!compositeQueryParam) return;
// Only run initQueryBuilderData if we're not in the middle of a pathname change
if (location.pathname === currentPathnameRef.current) {
if (stagedQuery && stagedQuery.id === compositeQueryParam.id) { if (stagedQuery && stagedQuery.id === compositeQueryParam.id) {
return; return;
} }
@ -916,11 +929,13 @@ export function QueryBuilderProvider({
} else { } else {
initQueryBuilderData(compositeQueryParam); initQueryBuilderData(compositeQueryParam);
} }
}
}, [ }, [
initQueryBuilderData, initQueryBuilderData,
redirectWithQueryBuilderData, redirectWithQueryBuilderData,
compositeQueryParam, compositeQueryParam,
stagedQuery, stagedQuery,
location.pathname,
]); ]);
const resetQuery = (newCurrentQuery?: QueryState): void => { const resetQuery = (newCurrentQuery?: QueryState): void => {
@ -932,16 +947,6 @@ export function QueryBuilderProvider({
} }
}; };
useEffect(() => {
if (location.pathname !== currentPathnameRef.current) {
currentPathnameRef.current = location.pathname;
setStagedQuery(null);
// reset the last used query to 0 when navigating away from the page
setLastUsedQuery(0);
}
}, [location.pathname]);
const handleOnUnitsChange = useCallback( const handleOnUnitsChange = useCallback(
(unit: string) => { (unit: string) => {
setCurrentQuery((prevState) => ({ setCurrentQuery((prevState) => ({

View File

@ -1,3 +1,4 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { TelemetryFieldKey } from 'api/v5/v5'; import { TelemetryFieldKey } from 'api/v5/v5';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants'; import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs'; import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
@ -31,6 +32,16 @@ export function usePreferenceSync({
setSavedViewPreferences, setSavedViewPreferences,
] = useState<Preferences | null>(null); ] = useState<Preferences | null>(null);
const updateExtraDataSelectColumns = (
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] | null => {
if (!columns) return null;
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
};
useEffect(() => { useEffect(() => {
const extraData = viewsData?.data?.data?.find( const extraData = viewsData?.data?.data?.find(
(view) => view.id === savedViewId, (view) => view.id === savedViewId,
@ -40,7 +51,9 @@ export function usePreferenceSync({
let columns: TelemetryFieldKey[] = []; let columns: TelemetryFieldKey[] = [];
let formatting: FormattingOptions | undefined; let formatting: FormattingOptions | undefined;
if (dataSource === DataSource.LOGS) { if (dataSource === DataSource.LOGS) {
columns = parsedExtraData?.selectColumns || defaultLogsSelectedColumns; columns =
updateExtraDataSelectColumns(parsedExtraData?.selectColumns) ||
defaultLogsSelectedColumns;
formatting = { formatting = {
maxLines: parsedExtraData?.maxLines ?? 2, maxLines: parsedExtraData?.maxLines ?? 2,
format: parsedExtraData?.format ?? 'table', format: parsedExtraData?.format ?? 'table',

View File

@ -33,6 +33,7 @@ export interface MetricRangePayloadProps {
result: QueryData[]; result: QueryData[];
resultType: string; resultType: string;
newResult: MetricRangePayloadV3; newResult: MetricRangePayloadV3;
warnings?: string[];
}; };
} }
@ -40,6 +41,7 @@ export interface MetricRangePayloadV3 {
data: { data: {
result: QueryDataV3[]; result: QueryDataV3[];
resultType: string; resultType: string;
warnings?: string[];
}; };
warning?: Warning; warning?: Warning;
} }

View File

@ -12,6 +12,7 @@ import {
Having as HavingV5, Having as HavingV5,
LogAggregation, LogAggregation,
MetricAggregation, MetricAggregation,
QueryFunction,
TraceAggregation, TraceAggregation,
} from '../v5/queryRange'; } from '../v5/queryRange';
import { BaseAutocompleteData } from './queryAutocompleteResponse'; import { BaseAutocompleteData } from './queryAutocompleteResponse';
@ -71,7 +72,7 @@ export type IBuilderQuery = {
timeAggregation?: string; timeAggregation?: string;
spaceAggregation?: string; spaceAggregation?: string;
temporality?: string; temporality?: string;
functions: QueryFunctionProps[]; functions: QueryFunction[];
filter?: Filter; filter?: Filter;
filters?: TagFilter; filters?: TagFilter;
groupBy: BaseAutocompleteData[]; groupBy: BaseAutocompleteData[];

View File

@ -127,6 +127,7 @@ export interface VariableItem {
export interface TelemetryFieldKey { export interface TelemetryFieldKey {
name: string; name: string;
key?: string;
description?: string; description?: string;
unit?: string; unit?: string;
signal?: SignalType; signal?: SignalType;
@ -170,6 +171,7 @@ export interface FunctionArg {
export interface QueryFunction { export interface QueryFunction {
name: FunctionName; name: FunctionName;
args?: FunctionArg[]; args?: FunctionArg[];
namedArgs?: Record<string, string | number>;
} }
// ===================== Aggregation Types ===================== // ===================== Aggregation Types =====================
@ -418,7 +420,7 @@ export type QueryRangeDataV5 =
export interface QueryRangeResponseV5 { export interface QueryRangeResponseV5 {
type: RequestType; type: RequestType;
data: QueryRangeDataV5; data: QueryRangeDataV5 & { warnings?: string[] };
meta: ExecStats; meta: ExecStats;
warning?: Warning; warning?: Warning;
} }

View File

@ -4,12 +4,12 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { import {
IBuilderFormula, IBuilderFormula,
IBuilderQuery, IBuilderQuery,
QueryFunctionProps,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { import {
BaseBuilderQuery, BaseBuilderQuery,
LogBuilderQuery, LogBuilderQuery,
MetricBuilderQuery, MetricBuilderQuery,
QueryFunction,
TraceBuilderQuery, TraceBuilderQuery,
} from 'types/api/v5/queryRange'; } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
@ -63,6 +63,6 @@ export type UseQueryOperations = (
handleDeleteQuery: () => void; handleDeleteQuery: () => void;
handleChangeQueryData: HandleChangeQueryData; handleChangeQueryData: HandleChangeQueryData;
handleChangeFormulaData: HandleChangeFormulaData; handleChangeFormulaData: HandleChangeFormulaData;
handleQueryFunctionsUpdates: (functions: QueryFunctionProps[]) => void; handleQueryFunctionsUpdates: (functions: QueryFunction[]) => void;
listOfAdditionalFormulaFilters: string[]; listOfAdditionalFormulaFilters: string[];
}; };

View File

@ -1,895 +0,0 @@
/* eslint-disable sonarjs/no-collapsible-if */
/* eslint-disable no-continue */
/* eslint-disable sonarjs/cognitive-complexity */
import { CharStreams, CommonTokenStream } from 'antlr4';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser from 'parser/FilterQueryParser';
import {
IDetailedError,
IQueryContext,
IToken,
IValidationResult,
} from 'types/antlrQueryTypes';
// Custom error listener to capture ANTLR errors
class QueryErrorListener {
private errors: IDetailedError[] = [];
syntaxError(
_recognizer: any,
offendingSymbol: any,
line: number,
column: number,
msg: string,
): void {
// For unterminated quotes, we only want to show one error
if (this.hasUnterminatedQuoteError() && msg.includes('expecting')) {
return;
}
const error: IDetailedError = {
message: msg,
line,
column,
offendingSymbol: offendingSymbol?.text || String(offendingSymbol),
};
// Extract expected tokens if available
if (msg.includes('expecting')) {
const expectedTokens = msg
.split('expecting')[1]
.trim()
.split(',')
.map((token) => token.trim());
error.expectedTokens = expectedTokens;
}
// Check if this is a duplicate error (same location and similar message)
const isDuplicate = this.errors.some(
(e) =>
e.line === line &&
e.column === column &&
this.isSimilarError(e.message, msg),
);
if (!isDuplicate) {
this.errors.push(error);
}
}
private hasUnterminatedQuoteError(): boolean {
return this.errors.some(
(error) =>
error.message.includes('unterminated') ||
(error.message.includes('missing') && error.message.includes("'")),
);
}
private isSimilarError = (msg1: string, msg2: string): boolean => {
// Consider errors similar if they're for the same core issue
const normalize = (msg: string): string =>
msg.toLowerCase().replace(/['"`]/g, 'quote').replace(/\s+/g, ' ').trim();
return normalize(msg1) === normalize(msg2);
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportAmbiguity = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportAttemptingFullContext = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportContextSensitivity = (): void => {};
getErrors(): IDetailedError[] {
return this.errors;
}
hasErrors(): boolean {
return this.errors.length > 0;
}
getFormattedErrors(): string[] {
return this.errors.map((error) => {
const {
offendingSymbol,
expectedTokens,
message: errorMessage,
line,
column,
} = error;
let message = `Line ${line}:${column} - ${errorMessage}`;
if (offendingSymbol && offendingSymbol !== 'undefined') {
message += `\n Symbol: '${offendingSymbol}'`;
}
if (expectedTokens && expectedTokens.length > 0) {
message += `\n Expected: ${expectedTokens.join(', ')}`;
}
return message;
});
}
}
export const validateQuery = (query: string): IValidationResult => {
// Empty query is considered invalid
if (!query.trim()) {
return {
isValid: true,
message: 'Query is empty',
errors: [],
};
}
try {
const errorListener = new QueryErrorListener();
const inputStream = CharStreams.fromString(query);
// Setup lexer
const lexer = new FilterQueryLexer(inputStream);
lexer.removeErrorListeners(); // Remove default error listeners
lexer.addErrorListener(errorListener);
// Setup parser
const tokenStream = new CommonTokenStream(lexer);
const parser = new FilterQueryParser(tokenStream);
parser.removeErrorListeners(); // Remove default error listeners
parser.addErrorListener(errorListener);
// Try parsing
parser.query();
// Check if any errors were captured
if (errorListener.hasErrors()) {
return {
isValid: false,
message: 'Query syntax error',
errors: errorListener.getErrors(),
};
}
return {
isValid: true,
message: 'Query is valid!',
errors: [],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Invalid query syntax';
const detailedError: IDetailedError = {
message: errorMessage,
line: 0,
column: 0,
offendingSymbol: '',
expectedTokens: [],
};
return {
isValid: false,
message: 'Invalid query syntax',
errors: [detailedError],
};
}
};
// Helper function to find key-operator-value triplets in token stream
export function findKeyOperatorValueTriplet(
allTokens: IToken[],
currentToken: IToken,
isInKey: boolean,
isInOperator: boolean,
isInValue: boolean,
): { keyToken?: string; operatorToken?: string; valueToken?: string } {
// Find current token index in allTokens
let currentTokenIndex = -1;
for (let i = 0; i < allTokens.length; i++) {
if (
allTokens[i].start === currentToken.start &&
allTokens[i].stop === currentToken.stop &&
allTokens[i].type === currentToken.type
) {
currentTokenIndex = i;
break;
}
}
if (currentTokenIndex === -1) return {};
// Initialize result with empty object
const result: {
keyToken?: string;
operatorToken?: string;
valueToken?: string;
} = {};
if (isInKey) {
// When in key context, we only know the key
result.keyToken = currentToken.text;
} else if (isInOperator) {
// When in operator context, we know the operator and can find the preceding key
result.operatorToken = currentToken.text;
// Look backward for key
for (let i = currentTokenIndex - 1; i >= 0; i--) {
const token = allTokens[i];
// Skip whitespace and other hidden channel tokens
if (token.channel !== 0) continue;
if (token.type === FilterQueryLexer.KEY) {
result.keyToken = token.text;
break;
}
}
} else if (isInValue) {
// When in value context, we know the value and can find the preceding operator and key
result.valueToken = currentToken.text;
let foundOperator = false;
// Look backward for operator and key
for (let i = currentTokenIndex - 1; i >= 0; i--) {
const token = allTokens[i];
// Skip whitespace and other hidden channel tokens
if (token.channel !== 0) continue;
// If we haven't found an operator yet, check for operator
if (
!foundOperator &&
[
FilterQueryLexer.EQUALS,
FilterQueryLexer.NOT_EQUALS,
FilterQueryLexer.NEQ,
FilterQueryLexer.LT,
FilterQueryLexer.LE,
FilterQueryLexer.GT,
FilterQueryLexer.GE,
FilterQueryLexer.LIKE,
// FilterQueryLexer.NOT_LIKE,
FilterQueryLexer.ILIKE,
// FilterQueryLexer.NOT_ILIKE,
FilterQueryLexer.BETWEEN,
FilterQueryLexer.EXISTS,
FilterQueryLexer.REGEXP,
FilterQueryLexer.CONTAINS,
FilterQueryLexer.IN,
FilterQueryLexer.NOT,
].includes(token.type)
) {
result.operatorToken = token.text;
foundOperator = true;
}
// If we already found an operator and this is a key, record it
else if (foundOperator && token.type === FilterQueryLexer.KEY) {
result.keyToken = token.text;
break; // We found our triplet
}
}
}
return result;
}
export function getQueryContextAtCursor(
query: string,
cursorIndex: number,
): IQueryContext {
try {
// Create input stream and lexer
const input = query || '';
const chars = CharStreams.fromString(input);
const lexer = new FilterQueryLexer(chars);
// Create token stream and force token generation
const tokenStream = new CommonTokenStream(lexer);
tokenStream.fill();
// Get all tokens including whitespace
const allTokens = tokenStream.tokens as IToken[];
// Find exact token at cursor, including whitespace
let exactToken: IToken | null = null;
let previousToken: IToken | null = null;
let nextToken: IToken | null = null;
// Handle cursor at the very end of input
if (cursorIndex === input.length && allTokens.length > 0) {
const lastRealToken = allTokens
.filter((t) => t.type !== FilterQueryLexer.EOF)
.pop();
if (lastRealToken) {
exactToken = lastRealToken;
previousToken =
allTokens.filter((t) => t.stop < lastRealToken.start).pop() || null;
}
} else {
// Normal token search
for (let i = 0; i < allTokens.length; i++) {
const token = allTokens[i];
// Skip EOF token in normal search
if (token.type === FilterQueryLexer.EOF) {
continue;
}
// Check if cursor is within token bounds (inclusive)
if (token.start <= cursorIndex && cursorIndex <= token.stop + 1) {
exactToken = token;
previousToken = i > 0 ? allTokens[i - 1] : null;
nextToken = i < allTokens.length - 1 ? allTokens[i + 1] : null;
break;
}
}
// If cursor is between tokens, find surrounding tokens
if (!exactToken) {
for (let i = 0; i < allTokens.length - 1; i++) {
const current = allTokens[i];
const next = allTokens[i + 1];
if (current.type === FilterQueryLexer.EOF) {
continue;
}
if (next.type === FilterQueryLexer.EOF) {
continue;
}
if (current.stop + 1 < cursorIndex && cursorIndex < next.start) {
previousToken = current;
nextToken = next;
break;
}
}
}
}
// Determine the context based on cursor position and surrounding tokens
let currentToken: IToken | null = null;
if (exactToken) {
// If cursor is in a non-whitespace token, use that
if (exactToken.channel === 0) {
currentToken = exactToken;
} else {
// If in whitespace, use the previous non-whitespace token
currentToken = previousToken?.channel === 0 ? previousToken : nextToken;
}
} else if (previousToken?.channel === 0) {
// If between tokens, prefer the previous non-whitespace token
currentToken = previousToken;
} else if (nextToken?.channel === 0) {
// Otherwise use the next non-whitespace token
currentToken = nextToken;
}
// If still no token (empty query or all whitespace), return default context
if (!currentToken) {
// Handle transitions based on spaces and current state
if (query.trim() === '') {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: true, // Default to key context when input is empty
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
};
}
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInNegation: false,
isInKey: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
};
}
// Determine if the current token is a conjunction (AND or OR)
const isInConjunction = [FilterQueryLexer.AND, FilterQueryLexer.OR].includes(
currentToken.type,
);
// Determine if the current token is a parenthesis or bracket
const isInParenthesis = [
FilterQueryLexer.LPAREN,
FilterQueryLexer.RPAREN,
FilterQueryLexer.LBRACK,
FilterQueryLexer.RBRACK,
].includes(currentToken.type);
// Determine the context based on the token type
const isInValue = [
FilterQueryLexer.QUOTED_TEXT,
FilterQueryLexer.NUMBER,
FilterQueryLexer.BOOL,
].includes(currentToken.type);
const isInKey = currentToken.type === FilterQueryLexer.KEY;
const isInNegation = currentToken.type === FilterQueryLexer.NOT;
const isInOperator = [
FilterQueryLexer.EQUALS,
FilterQueryLexer.NOT_EQUALS,
FilterQueryLexer.NEQ,
FilterQueryLexer.LT,
FilterQueryLexer.LE,
FilterQueryLexer.GT,
FilterQueryLexer.GE,
FilterQueryLexer.LIKE,
// FilterQueryLexer.NOT_LIKE,
FilterQueryLexer.ILIKE,
// FilterQueryLexer.NOT_ILIKE,
FilterQueryLexer.BETWEEN,
FilterQueryLexer.EXISTS,
FilterQueryLexer.REGEXP,
FilterQueryLexer.CONTAINS,
FilterQueryLexer.IN,
FilterQueryLexer.NOT,
].includes(currentToken.type);
const isInFunction = [
FilterQueryLexer.HAS,
FilterQueryLexer.HASANY,
FilterQueryLexer.HASALL,
// FilterQueryLexer.HASNONE,
].includes(currentToken.type);
// Get the context-related tokens (key, operator, value)
const relationTokens = findKeyOperatorValueTriplet(
allTokens,
currentToken,
isInKey,
isInOperator,
isInValue,
);
// Handle transitions based on spaces
// When a user adds a space after a token, change the context accordingly
if (
currentToken &&
cursorIndex === currentToken.stop + 2 &&
query[currentToken.stop + 1] === ' '
) {
// User added a space right after this token
if (isInKey) {
// After a key + space, we should be in operator context
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: true,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (isInOperator) {
// After an operator + space, we should be in value context
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: true,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (isInValue) {
// After a value + space, we should be in conjunction context
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: true,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (isInConjunction) {
// After a conjunction + space, we should be in key context again
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInNegation: false,
isInKey: true,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (isInParenthesis) {
// After a parenthesis/bracket + space, determine context based on which bracket
if (currentToken.type === FilterQueryLexer.LPAREN) {
// After an opening parenthesis + space, we should be in key context
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInNegation: false,
isInKey: true,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens,
};
}
if (
currentToken.type === FilterQueryLexer.RPAREN ||
currentToken.type === FilterQueryLexer.RBRACK
) {
// After a closing parenthesis/bracket + space, we should be in conjunction context
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInNegation: false,
isInKey: false,
isInOperator: false,
isInFunction: false,
isInConjunction: true,
isInParenthesis: false,
...relationTokens,
};
}
if (currentToken.type === FilterQueryLexer.LBRACK) {
// After an opening bracket + space, we should be in value context (for arrays)
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: true,
isInNegation: false,
isInKey: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens,
};
}
}
}
// Add logic for context detection that works for both forward and backward navigation
// This handles both cases: when user is typing forward and when they're moving backward
if (previousToken && nextToken) {
// Determine context based on token sequence pattern
// Key -> Operator -> Value -> Conjunction pattern detection
if (isInKey && nextToken.type === FilterQueryLexer.EQUALS) {
// When cursor is on a key and next token is an operator
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: true,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (isInNegation && nextToken.type === FilterQueryLexer.NOT) {
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: false,
isInNegation: true,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (
isInOperator &&
previousToken.type === FilterQueryLexer.KEY &&
(nextToken.type === FilterQueryLexer.QUOTED_TEXT ||
nextToken.type === FilterQueryLexer.NUMBER ||
nextToken.type === FilterQueryLexer.BOOL)
) {
// When cursor is on an operator between a key and value
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: true,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (
isInValue &&
previousToken.type !== FilterQueryLexer.AND &&
previousToken.type !== FilterQueryLexer.OR &&
(nextToken.type === FilterQueryLexer.AND ||
nextToken.type === FilterQueryLexer.OR)
) {
// When cursor is on a value and next token is a conjunction
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: true,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (
isInConjunction &&
(previousToken.type === FilterQueryLexer.QUOTED_TEXT ||
previousToken.type === FilterQueryLexer.NUMBER ||
previousToken.type === FilterQueryLexer.BOOL) &&
nextToken.type === FilterQueryLexer.KEY
) {
// When cursor is on a conjunction between a value and a key
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: true,
isInParenthesis: false,
};
}
}
// If we're in between tokens (no exact token match), use next token type to determine context
if (!exactToken && nextToken) {
if (nextToken.type === FilterQueryLexer.KEY) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: true,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (nextToken.type === FilterQueryLexer.NOT) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: false,
isInNegation: true,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (
[
FilterQueryLexer.EQUALS,
FilterQueryLexer.NOT_EQUALS,
FilterQueryLexer.GT,
FilterQueryLexer.LT,
FilterQueryLexer.GE,
FilterQueryLexer.LE,
].includes(nextToken.type)
) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: true,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if (
[
FilterQueryLexer.QUOTED_TEXT,
FilterQueryLexer.NUMBER,
FilterQueryLexer.BOOL,
].includes(nextToken.type)
) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInNegation: false,
isInValue: true,
isInKey: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
if ([FilterQueryLexer.AND, FilterQueryLexer.OR].includes(nextToken.type)) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: true,
isInParenthesis: false,
...relationTokens, // Include related tokens
};
}
// Add case for parentheses and brackets
if (
[
FilterQueryLexer.LPAREN,
FilterQueryLexer.RPAREN,
FilterQueryLexer.LBRACK,
FilterQueryLexer.RBRACK,
].includes(nextToken.type)
) {
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: true,
...relationTokens, // Include related tokens
};
}
}
// Fall back to default context detection based on current token
return {
tokenType: currentToken.type,
text: currentToken.text,
start: currentToken.start,
stop: currentToken.stop,
currentToken: currentToken.text,
isInValue,
isInKey,
isInNegation,
isInOperator,
isInFunction,
isInConjunction,
isInParenthesis,
...relationTokens, // Include related tokens
};
} catch (error) {
console.error('Error in getQueryContextAtCursor:', error);
return {
tokenType: -1,
text: '',
start: cursorIndex,
stop: cursorIndex,
currentToken: '',
isInValue: false,
isInKey: false,
isInNegation: false,
isInOperator: false,
isInFunction: false,
isInConjunction: false,
isInParenthesis: false,
};
}
}

View File

@ -31,6 +31,7 @@ export const convertBuilderQueryToIBuilderQuery = (
const result: IBuilderQuery = ({ const result: IBuilderQuery = ({
...builderQuery, ...builderQuery,
queryName: builderQuery.name,
dataSource, dataSource,
legend: builderQuery.legend, legend: builderQuery.legend,
groupBy: builderQuery.groupBy?.map((group) => ({ groupBy: builderQuery.groupBy?.map((group) => ({

View File

@ -0,0 +1,86 @@
import { QueryFunctionsTypes } from 'types/common/queryBuilder';
import { normalizeFunctionName } from './functionNameNormalizer';
describe('functionNameNormalizer', () => {
describe('normalizeFunctionName', () => {
it('should normalize timeshift to timeShift', () => {
expect(normalizeFunctionName('timeshift')).toBe(
QueryFunctionsTypes.TIME_SHIFT,
);
});
it('should normalize TIMESHIFT to timeShift', () => {
expect(normalizeFunctionName('TIMESHIFT')).toBe(
QueryFunctionsTypes.TIME_SHIFT,
);
});
it('should normalize TimeShift to timeShift', () => {
expect(normalizeFunctionName('TimeShift')).toBe(
QueryFunctionsTypes.TIME_SHIFT,
);
});
it('should return original value if no normalization needed', () => {
expect(normalizeFunctionName('timeShift')).toBe('timeShift');
});
it('should handle unknown function names', () => {
expect(normalizeFunctionName('unknownFunction')).toBe('unknownFunction');
});
it('should normalize other function names', () => {
expect(normalizeFunctionName('cutoffmin')).toBe(
QueryFunctionsTypes.CUTOFF_MIN,
);
expect(normalizeFunctionName('cutoffmax')).toBe(
QueryFunctionsTypes.CUTOFF_MAX,
);
expect(normalizeFunctionName('clampmin')).toBe(
QueryFunctionsTypes.CLAMP_MIN,
);
expect(normalizeFunctionName('clampmax')).toBe(
QueryFunctionsTypes.CLAMP_MAX,
);
expect(normalizeFunctionName('absolut')).toBe(QueryFunctionsTypes.ABSOLUTE);
expect(normalizeFunctionName('runningdiff')).toBe(
QueryFunctionsTypes.RUNNING_DIFF,
);
expect(normalizeFunctionName('log2')).toBe(QueryFunctionsTypes.LOG_2);
expect(normalizeFunctionName('log10')).toBe(QueryFunctionsTypes.LOG_10);
expect(normalizeFunctionName('cumulativesum')).toBe(
QueryFunctionsTypes.CUMULATIVE_SUM,
);
expect(normalizeFunctionName('ewma3')).toBe(QueryFunctionsTypes.EWMA_3);
expect(normalizeFunctionName('ewma5')).toBe(QueryFunctionsTypes.EWMA_5);
expect(normalizeFunctionName('ewma7')).toBe(QueryFunctionsTypes.EWMA_7);
expect(normalizeFunctionName('median3')).toBe(QueryFunctionsTypes.MEDIAN_3);
expect(normalizeFunctionName('median5')).toBe(QueryFunctionsTypes.MEDIAN_5);
expect(normalizeFunctionName('median7')).toBe(QueryFunctionsTypes.MEDIAN_7);
expect(normalizeFunctionName('anomaly')).toBe(QueryFunctionsTypes.ANOMALY);
});
});
describe('function argument handling', () => {
it('should handle string arguments correctly', () => {
const func = {
name: 'timeshift',
args: ['5m'],
};
const normalizedName = normalizeFunctionName(func.name);
expect(normalizedName).toBe(QueryFunctionsTypes.TIME_SHIFT);
expect(func.args[0]).toBe('5m');
});
it('should handle numeric arguments correctly', () => {
const func = {
name: 'cutoffmin',
args: [100],
};
const normalizedName = normalizeFunctionName(func.name);
expect(normalizedName).toBe(QueryFunctionsTypes.CUTOFF_MIN);
expect(func.args[0]).toBe(100);
});
});
});

View File

@ -0,0 +1,37 @@
import { QueryFunctionsTypes } from 'types/common/queryBuilder';
/**
* Normalizes function names from backend responses to match frontend expectations
* Backend returns lowercase function names (e.g., 'timeshift') while frontend expects camelCase (e.g., 'timeShift')
*/
export const normalizeFunctionName = (functionName: string): string => {
// Create a mapping from lowercase to expected camelCase function names
const functionNameMap: Record<string, string> = {
// Time shift function
timeshift: QueryFunctionsTypes.TIME_SHIFT,
// Other functions that might have case sensitivity issues
cutoffmin: QueryFunctionsTypes.CUTOFF_MIN,
cutoffmax: QueryFunctionsTypes.CUTOFF_MAX,
clampmin: QueryFunctionsTypes.CLAMP_MIN,
clampmax: QueryFunctionsTypes.CLAMP_MAX,
absolut: QueryFunctionsTypes.ABSOLUTE,
runningdiff: QueryFunctionsTypes.RUNNING_DIFF,
log2: QueryFunctionsTypes.LOG_2,
log10: QueryFunctionsTypes.LOG_10,
cumulativesum: QueryFunctionsTypes.CUMULATIVE_SUM,
ewma3: QueryFunctionsTypes.EWMA_3,
ewma5: QueryFunctionsTypes.EWMA_5,
ewma7: QueryFunctionsTypes.EWMA_7,
median3: QueryFunctionsTypes.MEDIAN_3,
median5: QueryFunctionsTypes.MEDIAN_5,
median7: QueryFunctionsTypes.MEDIAN_7,
anomaly: QueryFunctionsTypes.ANOMALY,
};
// Convert to lowercase for case-insensitive matching
const normalizedName = functionName.toLowerCase();
// Return the mapped function name or the original if no mapping exists
return functionNameMap[normalizedName] || functionName;
};

View File

@ -0,0 +1,171 @@
/* eslint-disable sonarjs/no-collapsible-if */
/* eslint-disable no-continue */
import { CharStreams, CommonTokenStream } from 'antlr4';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser from 'parser/FilterQueryParser';
import { IDetailedError, IValidationResult } from 'types/antlrQueryTypes';
// Custom error listener to capture ANTLR errors
class QueryErrorListener {
private errors: IDetailedError[] = [];
syntaxError(
_recognizer: any,
offendingSymbol: any,
line: number,
column: number,
msg: string,
): void {
// For unterminated quotes, we only want to show one error
if (this.hasUnterminatedQuoteError() && msg.includes('expecting')) {
return;
}
const error: IDetailedError = {
message: msg,
line,
column,
offendingSymbol: offendingSymbol?.text || String(offendingSymbol),
};
// Extract expected tokens if available
if (msg.includes('expecting')) {
const expectedTokens = msg
.split('expecting')[1]
.trim()
.split(',')
.map((token) => token.trim());
error.expectedTokens = expectedTokens;
}
// Check if this is a duplicate error (same location and similar message)
const isDuplicate = this.errors.some(
(e) =>
e.line === line &&
e.column === column &&
this.isSimilarError(e.message, msg),
);
if (!isDuplicate) {
this.errors.push(error);
}
}
private hasUnterminatedQuoteError(): boolean {
return this.errors.some(
(error) =>
error.message.includes('unterminated') ||
(error.message.includes('missing') && error.message.includes("'")),
);
}
private isSimilarError = (msg1: string, msg2: string): boolean => {
// Consider errors similar if they're for the same core issue
const normalize = (msg: string): string =>
msg.toLowerCase().replace(/['"`]/g, 'quote').replace(/\s+/g, ' ').trim();
return normalize(msg1) === normalize(msg2);
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportAmbiguity = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportAttemptingFullContext = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
reportContextSensitivity = (): void => {};
getErrors(): IDetailedError[] {
return this.errors;
}
hasErrors(): boolean {
return this.errors.length > 0;
}
getFormattedErrors(): string[] {
return this.errors.map((error) => {
const {
offendingSymbol,
expectedTokens,
message: errorMessage,
line,
column,
} = error;
let message = `Line ${line}:${column} - ${errorMessage}`;
if (offendingSymbol && offendingSymbol !== 'undefined') {
message += `\n Symbol: '${offendingSymbol}'`;
}
if (expectedTokens && expectedTokens.length > 0) {
message += `\n Expected: ${expectedTokens.join(', ')}`;
}
return message;
});
}
}
export const validateQuery = (query: string): IValidationResult => {
// Empty query is considered valid
if (!query.trim()) {
return {
isValid: true,
message: 'Query is empty',
errors: [],
};
}
try {
const errorListener = new QueryErrorListener();
const inputStream = CharStreams.fromString(query);
// Setup lexer
const lexer = new FilterQueryLexer(inputStream);
lexer.removeErrorListeners(); // Remove default error listeners
lexer.addErrorListener(errorListener);
// Setup parser
const tokenStream = new CommonTokenStream(lexer);
const parser = new FilterQueryParser(tokenStream);
parser.removeErrorListeners(); // Remove default error listeners
parser.addErrorListener(errorListener);
// Try parsing
parser.query();
// Check if any errors were captured
if (errorListener.hasErrors()) {
return {
isValid: false,
message: 'Query syntax error',
errors: errorListener.getErrors(),
};
}
return {
isValid: true,
message: 'Query is valid!',
errors: [],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Invalid query syntax';
const detailedError: IDetailedError = {
message: errorMessage,
line: 0,
column: 0,
offendingSymbol: '',
expectedTokens: [],
};
return {
isValid: false,
message: 'Invalid query syntax',
errors: [detailedError],
};
}
};

View File

@ -1,4 +1,8 @@
import { NON_VALUE_OPERATORS } from 'constants/antlrQueryConstants'; import {
NON_VALUE_OPERATORS,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import FilterQueryLexer from 'parser/FilterQueryLexer'; import FilterQueryLexer from 'parser/FilterQueryLexer';
import { IQueryPair } from 'types/antlrQueryTypes'; import { IQueryPair } from 'types/antlrQueryTypes';
@ -91,3 +95,21 @@ export function isQueryPairComplete(queryPair: Partial<IQueryPair>): boolean {
// For other operators, we need a value as well // For other operators, we need a value as well
return Boolean(queryPair.key && queryPair.operator && queryPair.value); return Boolean(queryPair.key && queryPair.operator && queryPair.value);
} }
export function isFunctionOperator(operator: string): boolean {
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
const sanitizedOperator = operator.trim();
// Check if it's a direct function operator
if (functionOperators.includes(sanitizedOperator)) {
return true;
}
// Check if it's a NOT function operator (e.g., "NOT has")
if (sanitizedOperator.toUpperCase().startsWith(OPERATORS.NOT)) {
const operatorWithoutNot = sanitizedOperator.substring(4).toLowerCase();
return functionOperators.includes(operatorWithoutNot);
}
return false;
}