Compare commits

...

12 Commits

Author SHA1 Message Date
srikanthccv
dc4d5150d0 chore: fix text 2025-08-02 20:01:06 +05:30
srikanthccv
2c258cea9a chore: resolve conflicts 2025-08-02 19:52:57 +05:30
SagarRajput-7
71c4ead128 fix: metric histogram UI config state in edit mode 2025-08-01 19:34:03 +05:30
SagarRajput-7
a495ed4916 feat: fixed failing test case 2025-08-01 19:27:09 +05:30
SagarRajput-7
a5d9f6411d 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
2025-08-01 18:53:14 +05:30
Abhi kumar
aede92a958 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>
2025-08-01 18:49:00 +05:30
SagarRajput-7
b9440a93d1 feat: updated the error state in explorer pages with new APIError 2025-08-01 14:53:09 +05:30
SagarRajput-7
f6cff1dd19 feat: enabled legend enhancement for explorer pages and alert page 2025-08-01 12:25:01 +05:30
SagarRajput-7
d523ca25e4 feat: added tooltips in metric aggregations 2025-08-01 11:01:40 +05:30
srikanthccv
dfa3e867c1 chore: add tooltips with links to documentation 2025-08-01 01:23:20 +05:30
Abhi kumar
d715059a5a Update frontend/src/utils/queryValidationUtils.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-31 18:59:34 +05:30
Abhi kumar
ef88505608 fix: removed unused code for querycontext (#8674) 2025-07-31 18:49:54 +05:30
39 changed files with 924 additions and 1047 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

@@ -373,6 +373,7 @@ export function convertV5ResponseToLegacy(
data: {
resultType: 'scalar',
result: webTables,
warnings: v5Data?.data?.warnings || [],
},
},
};
@@ -389,7 +390,10 @@ export function convertV5ResponseToLegacy(
const legacyResponse: SuccessResponse<MetricRangePayloadV3> = {
...v5Response,
payload: {
data: convertedData,
data: {
...convertedData,
warnings: v5Data?.data?.warnings || [],
},
},
};

View File

@@ -19,6 +19,7 @@ export interface NavigateToExplorerProps {
endTime?: number;
sameTab?: boolean;
shouldResolveQuery?: boolean;
widgetQuery?: Query;
}
export function useNavigateToExplorer(): (
@@ -30,27 +31,34 @@ export function useNavigateToExplorer(): (
);
const prepareQuery = useCallback(
(selectedFilters: TagFilterItem[], dataSource: DataSource): Query => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData
.map((item) => ({
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: [...(item.filters?.items || []), ...selectedFilters],
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
}))
.slice(0, 1),
queryFormulas: [],
},
}),
(
selectedFilters: TagFilterItem[],
dataSource: DataSource,
query?: Query,
): Query => {
const widgetQuery = query || currentQuery;
return {
...widgetQuery,
builder: {
...widgetQuery.builder,
queryData: widgetQuery.builder.queryData
.map((item) => ({
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: [...(item.filters?.items || []), ...selectedFilters],
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
}))
.slice(0, 1),
queryFormulas: [],
},
};
},
[currentQuery],
);
@@ -67,6 +75,7 @@ export function useNavigateToExplorer(): (
endTime,
sameTab,
shouldResolveQuery,
widgetQuery,
} = props;
const urlParams = new URLSearchParams();
if (startTime && endTime) {
@@ -77,7 +86,7 @@ export function useNavigateToExplorer(): (
urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString());
}
let preparedQuery = prepareQuery(filters, dataSource);
let preparedQuery = prepareQuery(filters, dataSource, widgetQuery);
if (shouldResolveQuery) {
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

@@ -7,7 +7,6 @@ import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import SpaceAggregationOptions from 'container/QueryBuilder/components/SpaceAggregationOptions/SpaceAggregationOptions';
import { GroupByFilter, OperatorsSelect } from 'container/QueryBuilder/filters';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Info } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
@@ -45,8 +44,10 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
);
const isHistogram = useMemo(
() => query.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM,
[query.aggregateAttribute?.type],
() =>
query.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM ||
queryAggregation.metricName?.endsWith('.bucket'),
[query.aggregateAttribute?.type, queryAggregation.metricName],
);
useEffect(() => {
@@ -100,12 +101,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<div className="metrics-time-aggregation-section">
<div className="metrics-aggregation-section-content">
<div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-label main-label">
AGGREGATE BY TIME{' '}
<Tooltip title="AGGREGATE BY TIME">
<Info size={12} />
</Tooltip>
</div>
<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">
AGGREGATE WITHIN TIME SERIES{' '}
</div>
</Tooltip>
<div className="metrics-aggregation-section-content-item-value">
<OperatorsSelect
value={queryAggregation.timeAggregation || ''}
@@ -116,11 +127,30 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
</div>
</div>
{showAggregationInterval && (
<div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-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="metrics-aggregation-section-content-item-value">
<InputWithLabel
@@ -138,12 +168,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
<div className="metrics-space-aggregation-section">
<div className="metrics-aggregation-section-content">
<div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-label main-label">
AGGREGATE LABELS
<Tooltip title="AGGREGATE LABELS">
<Info size={12} />
<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">
AGGREGATE ACROSS TIME SERIES
</div>
</Tooltip>
</div>
<div className="metrics-aggregation-section-content-item-value">
<SpaceAggregationOptions
panelType={panelType}
@@ -208,9 +248,27 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
</div>
</div>
<div className="metrics-aggregation-section-content-item">
<div className="metrics-aggregation-section-content-item-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="metrics-aggregation-section-content-item-value">
<InputWithLabel

View File

@@ -1,6 +1,6 @@
import './QueryAddOns.styles.scss';
import { Button, Radio, RadioChangeEvent } from 'antd';
import { Button, Radio, RadioChangeEvent, Tooltip } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
@@ -9,7 +9,7 @@ import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/Re
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ScrollText } from 'lucide-react';
import { BarChart2, ChevronUp, ScrollText, ExternalLink } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
@@ -21,6 +21,8 @@ interface AddOn {
icon: React.ReactNode;
label: string;
key: string;
description?: string;
docLink?: string;
}
const ADD_ONS_KEYS = {
@@ -36,26 +38,36 @@ const ADD_ONS = [
icon: <BarChart2 size={14} />,
label: '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} />,
label: '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} />,
label: '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} />,
label: '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} />,
label: '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 +75,44 @@ const REDUCE_TO = {
icon: <ScrollText size={14} />,
label: '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
const TooltipContent = ({ label, description, docLink }: { label: string; description?: string; docLink?: string }) => (
<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) => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
color: '#4096ff',
fontSize: '12px',
marginTop: '4px'
}}
>
Learn more
<ExternalLink size={12} />
</a>
)}
</div>
);
function QueryAddOns({
query,
version,
@@ -212,7 +260,19 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'group_by') && (
<div className="add-on-content">
<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">
<GroupByFilter
disabled={
@@ -234,7 +294,19 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'having') && (
<div className="add-on-content">
<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">
<HavingFilter
onClose={(): void => {
@@ -266,7 +338,19 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'order_by') && (
<div className="add-on-content">
<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">
<OrderByFilter
entityVersion={version}
@@ -290,7 +374,19 @@ function QueryAddOns({
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
<div className="add-on-content">
<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">
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div>
@@ -330,20 +426,26 @@ function QueryAddOns({
value={selectedViews}
>
{addOns.map((addOn) => (
<Radio.Button
key={addOn.label}
className={
selectedViews.find((view) => view.key === addOn.key)
? 'selected-view tab'
: 'tab'
}
value={addOn}
<Tooltip
key={addOn.key}
title={<TooltipContent label={addOn.label} description={addOn.description} docLink={addOn.docLink} />}
placement="top"
mouseEnterDelay={0.5}
>
<div className="add-on-tab-title">
{addOn.icon}
{addOn.label}
</div>
</Radio.Button>
<Radio.Button
className={
selectedViews.find((view) => view.key === addOn.key)
? 'selected-view tab'
: 'tab'
}
value={addOn}
>
<div className="add-on-tab-title">
{addOn.icon}
{addOn.label}
</div>
</Radio.Button>
</Tooltip>
))}
</Radio.Group>
</div>

View File

@@ -6,6 +6,9 @@ import { useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tooltip } from 'antd';
import QueryAggregationSelect from './QueryAggregationSelect';
function QueryAggregationOptions({
@@ -53,7 +56,28 @@ function QueryAggregationOptions({
{showAggregationInterval && (
<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">
<InputWithLabel
initialValue={

View File

@@ -27,7 +27,7 @@ import CodeMirror, {
ViewPlugin,
ViewUpdate,
} from '@uiw/react-codemirror';
import { Button, Popover } from 'antd';
import { Button, Popover, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryBuilderKeys } from 'constants/queryBuilder';
@@ -41,6 +41,9 @@ import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
import { Info } from 'lucide-react';
const chipDecoration = Decoration.mark({
class: 'chip-decorator',
});
@@ -639,6 +642,47 @@ 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 && (
<div className="query-aggregation-error-container">
<Popover

View File

@@ -16,15 +16,6 @@ export default function QueryFooter({
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>
}
>
@@ -43,7 +34,7 @@ export default function QueryFooter({
<div style={{ textAlign: 'center' }}>
Add New Formula
<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"
style={{ textDecoration: 'underline' }}
>

View File

@@ -16,7 +16,7 @@ import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
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 { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
@@ -30,7 +30,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
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 {
IDetailedError,
@@ -40,11 +40,11 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/antlrQueryUtils';
import {
getCurrentValueIndexAtCursor,
getQueryContextAtCursor,
} from 'utils/queryContextUtils';
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
@@ -1068,6 +1068,22 @@ function QuerySearch({
}
}, [queryContext, activeKey, isLoadingSuggestions, fetchValueSuggestions]);
const getTooltipContent = () => (
<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 (
<div className="code-mirror-where-clause">
{editingMode && (
@@ -1109,6 +1125,32 @@ function QuerySearch({
)}
<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) => e.stopPropagation()}
>
<Info size={14} style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }} />
</a>
</Tooltip>
<CodeMirror
value={query}
theme={isDarkMode ? copilot : githubLight}
@@ -1167,7 +1209,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={{
lineNumbers: false,
}}

View File

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

View File

@@ -1,6 +1,6 @@
import { Form, Row } from 'antd';
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 FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
@@ -71,14 +71,14 @@ function CreateRules(): JSX.Element {
case AlertTypes.ANOMALY_BASED_ALERT:
setInitValues({
...anamolyAlertDefaults,
version: version || ENTITY_VERSION_V4,
version: version || ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
});
break;
default:
setInitValues({
...alertDefaults,
version: version || ENTITY_VERSION_V4,
version: version || ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.THRESHOLD_ALERT,
});
}

View File

@@ -7,6 +7,7 @@ import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
@@ -35,6 +36,7 @@ import { useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { AlertDef } from 'types/api/alerts/def';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -80,6 +82,7 @@ function ChartPreview({
const threshold = alertDef?.condition.target || 0;
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const { currentQuery } = useQueryBuilder();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
@@ -171,6 +174,19 @@ function ChartPreview({
setMaxTimeScale(endTime);
}, [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) {
const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result,
@@ -257,6 +273,10 @@ function ChartPreview({
timezone: timezone.value,
currentQuery,
query: query || currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
}),
[
yAxisUnit,
@@ -274,6 +294,7 @@ function ChartPreview({
timezone.value,
currentQuery,
query,
graphVisibility,
],
);

View File

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

View File

@@ -235,6 +235,7 @@ export const handleGraphClick = async ({
? customTracesTimeRange?.end
: xValue + (stepInterval ?? 60),
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 { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
@@ -32,7 +32,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
GlobalReducer
>((state) => state.globalTime);
const queryRangeMutation = useMutation(getQueryRangeFormat);
const queryRangeMutation = useMutation(getSubstituteVars);
const getUpdatedQuery = useCallback(
async ({
@@ -40,7 +40,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
selectedDashboard,
}: UseUpdatedQueryOptions): Promise<Query> => {
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayload({
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
@@ -53,7 +53,10 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
// Map query data from API response
return mapQueryDataFromApi(queryResult.compositeQuery, widgetConfig?.query);
return mapQueryDataFromApi(
queryResult.data.compositeQuery,
widgetConfig?.query,
);
},
[globalSelectedInterval, queryRangeMutation],
);

View File

@@ -1,3 +1,4 @@
import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -9,4 +10,5 @@ export type LogsExplorerListProps = {
onEndReached: (index: number) => void;
isError: boolean;
isFilterApplied: boolean;
error: APIError;
};

View File

@@ -2,6 +2,7 @@ import './LogsExplorerList.style.scss';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
// components
@@ -12,7 +13,6 @@ import Spinner from 'components/Spinner';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
@@ -46,6 +46,7 @@ function LogsExplorerList({
onEndReached,
isError,
isFilterApplied,
error,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
@@ -256,7 +257,10 @@ function LogsExplorerList({
/>
)}
{isError && !isLoading && !isFetching && <LogsError />}
{/** Using No data as we have error modal working on top of this hence just showing no data */}
{isError && !isLoading && !isFetching && (
<ErrorStateComponent error={error} />
)}
{!isLoading && !isError && logs.length > 0 && (
<>

View File

@@ -1,7 +1,9 @@
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export type LogsExplorerTableProps = {
data: QueryDataV3[];
isLoading: boolean;
isError: boolean;
error?: APIError;
};

View File

@@ -1,11 +1,12 @@
import './LogsExplorerTable.styles.scss';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import { initialQueriesMap } from 'constants/queryBuilder';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { QueryTable } from 'container/QueryTable';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo } from 'react';
import APIError from 'types/api/error';
import { LogsExplorerTableProps } from './LogsExplorerTable.interfaces';
@@ -13,6 +14,7 @@ function LogsExplorerTable({
data,
isLoading,
isError,
error,
}: LogsExplorerTableProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
@@ -21,7 +23,7 @@ function LogsExplorerTable({
}
if (isError) {
return <LogsError />;
return <ErrorStateComponent error={error as APIError} />;
}
return (

View File

@@ -63,6 +63,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -269,6 +270,7 @@ function LogsExplorerViewsContainer({
isFetching,
isError,
isSuccess,
error,
} = useGetExplorerQueryRange(
requestData,
panelType,
@@ -771,6 +773,7 @@ function LogsExplorerViewsContainer({
logs={logs}
onEndReached={handleEndReached}
isError={isError}
error={error as APIError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
/>
)}
@@ -782,6 +785,7 @@ function LogsExplorerViewsContainer({
isError={isError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
error={error as APIError}
/>
)}
@@ -794,6 +798,7 @@ function LogsExplorerViewsContainer({
}
isLoading={isLoading || isFetching}
isError={isError}
error={error as APIError}
/>
)}
</div>

View File

@@ -9,6 +9,7 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { VirtuosoMockContext } from 'react-virtuoso';
import { fireEvent, render, RenderResult, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import LogsExplorerViews from '..';
@@ -82,6 +83,48 @@ jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
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', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
@@ -174,17 +217,36 @@ describe('LogsExplorerViews -', () => {
it('check error state', async () => {
lodsQueryServerRequest();
// Create APIError instance
const apiError = new APIError({
httpStatusCode: 400,
error: {
code: 'invalid_input',
message: 'found 1 errors while parsing the search expression',
url: '',
errors: [
{
message: 'key `abc` not found',
},
],
},
});
// Mock the hook to return the APIError instance
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
data: { payload: logsQueryRangeSuccessNewFormatResponse },
data: undefined,
isLoading: false,
isFetching: false,
isError: true,
error: apiError,
isSuccess: false,
});
const { queryByText } = renderer();
expect(
queryByText('Something went wrong. Please try again or contact support.'),
).toBeInTheDocument();
const { queryByTestId } = renderer();
// Verify that the error state component is rendered
expect(queryByTestId('error-state-component')).toBeInTheDocument();
});
it('should add activeLogId filter when present in URL', async () => {

View File

@@ -239,10 +239,7 @@ function QuerySection({
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<TextToolTip
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"
/>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<Button
loading={queryResponse.isFetching}
type="primary"

View File

@@ -24,7 +24,6 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { transformToUpperCase } from 'utils/transformToUpperCase';
import { removePrefix } from '../GroupByFilter/utils';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -114,7 +113,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
const placeholder: string =
query.dataSource === DataSource.METRICS
? `${transformToUpperCase(query.dataSource)} name`
? `Search metric name`
: 'Aggregate attribute';
const getAttributesData = useCallback(

View File

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

View File

@@ -1,10 +1,11 @@
import './TimeSeriesView.styles.scss';
import logEvent from 'api/common/logEvent';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
@@ -28,6 +29,8 @@ import { useLocation } from 'react-router-dom';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -41,6 +44,7 @@ function TimeSeriesView({
yAxisUnit,
isFilterApplied,
dataSource,
error,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -58,6 +62,7 @@ function TimeSeriesView({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
@@ -71,6 +76,19 @@ function TimeSeriesView({
setMaxTimeScale(endTime);
}, [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(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
@@ -144,6 +162,7 @@ function TimeSeriesView({
const { timezone } = useTimezone();
const chartOptions = getUPlotChartOptions({
id: 'time-series-explorer',
onDragSelect,
yAxisUnit: yAxisUnit || '',
apiResponse: data?.payload,
@@ -161,11 +180,15 @@ function TimeSeriesView({
timezone: timezone.value,
currentQuery,
query: currentQuery,
graphsVisibilityStates: graphVisibility,
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
});
return (
<div className="time-series-view">
{isError && <LogsError />}
{isError && <ErrorStateComponent error={error as APIError} />}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -217,11 +240,13 @@ interface TimeSeriesViewProps {
isError: boolean;
isFilterApplied: boolean;
dataSource: DataSource;
error?: APIError;
}
TimeSeriesView.defaultProps = {
data: undefined,
yAxisUnit: 'short',
error: undefined,
};
export default TimeSeriesView;

View File

@@ -8,6 +8,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -44,7 +45,7 @@ function TimeSeriesViewContainer({
return isValid.every(Boolean);
}, [currentQuery]);
const { data, isLoading, isError } = useGetQueryRange(
const { data, isLoading, isError, error } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap[dataSource],
graphType: panelType || PANEL_TYPES.TIME_SERIES,
@@ -81,6 +82,7 @@ function TimeSeriesViewContainer({
data={responseData}
yAxisUnit={isValidToConvertToMs ? 'ms' : 'short'}
dataSource={dataSource}
error={error as APIError}
/>
);
}

View File

@@ -1,6 +1,7 @@
import './ListView.styles.scss';
import logEvent from 'api/common/logEvent';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -27,12 +28,13 @@ import { useTimezone } from 'providers/Timezone';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
import { Container, ErrorText, tableStyles } from './styles';
import { Container, tableStyles } from './styles';
import { getListColumns, transformDataWithDate } from './utils';
interface ListViewProps {
@@ -116,7 +118,7 @@ function ListView({ isFilterApplied }: ListViewProps): JSX.Element {
],
);
const { data, isFetching, isLoading, isError } = useGetQueryRange(
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
{
query: requestQuery,
graphType: panelType,
@@ -220,7 +222,7 @@ function ListView({ isFilterApplied }: ListViewProps): JSX.Element {
</div>
)}
{isError && <ErrorText>{data?.error || 'Something went wrong'}</ErrorText>}
{isError && <ErrorStateComponent error={error as APIError} />}
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
<TracesLoading />

View File

@@ -1,4 +1,5 @@
import { Space } from 'antd';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -8,6 +9,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -19,7 +21,7 @@ function TableView(): JSX.Element {
GlobalReducer
>((state) => state.globalTime);
const { data, isLoading } = useGetQueryRange(
const { data, isLoading, error, isError } = useGetQueryRange(
{
query: stagedQuery || initialQueriesMap.traces,
graphType: panelType || PANEL_TYPES.TABLE,
@@ -51,6 +53,10 @@ function TableView(): JSX.Element {
[data],
);
if (isError) {
return <ErrorStateComponent error={error as APIError} />;
}
return (
<Space.Compact block direction="vertical">
<QueryTable

View File

@@ -1,5 +1,6 @@
import { Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorStateComponent from 'components/Common/ErrorStateComponent';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -16,6 +17,7 @@ import { ArrowUp10, Minus } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import APIError from 'types/api/error';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
@@ -60,7 +62,7 @@ function TracesView({ isFilterApplied }: TracesViewProps): JSX.Element {
setOrderBy(value);
}, []);
const { data, isLoading, isFetching, isError } = useGetQueryRange(
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
{
query: transformedQuery,
graphType: panelType || PANEL_TYPES.TRACE,
@@ -155,6 +157,10 @@ function TracesView({ isFilterApplied }: TracesViewProps): JSX.Element {
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
)}
{isError && !isLoading && !isFetching && (
<ErrorStateComponent error={error as APIError} />
)}
{(tableData || []).length !== 0 && (
<ResizeTable
loading={isLoading}

View File

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

View File

@@ -2,6 +2,7 @@ import { isAxiosError } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateStepInterval } from 'container/GridCardLayout/utils';
import { useNotifications } from 'hooks/useNotifications';
import {
GetMetricQueryRange,
GetQueryResultsProps,
@@ -16,7 +17,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
type UseGetQueryRangeOptions = UseQueryOptions<
SuccessResponse<MetricRangePayloadProps>,
SuccessResponse<MetricRangePayloadProps> & { warnings?: string[] },
APIError | Error
> & {
showErrorModal?: boolean;
@@ -27,7 +28,10 @@ type UseGetQueryRange = (
version: string,
options?: UseGetQueryRangeOptions,
headers?: Record<string, string>,
) => UseQueryResult<SuccessResponse<MetricRangePayloadProps>, Error>;
) => UseQueryResult<
SuccessResponse<MetricRangePayloadProps> & { warnings?: string[] },
Error
>;
export const useGetQueryRange: UseGetQueryRange = (
requestData,
@@ -36,7 +40,8 @@ export const useGetQueryRange: UseGetQueryRange = (
headers,
) => {
const { showErrorModal: showErrorModalFn } = useErrorModal();
const newRequestData: GetQueryResultsProps = useMemo(() => {
const { notifications } = useNotifications();
const newRequestData = useMemo(() => {
const firstQueryData = requestData.query.builder?.queryData[0];
const isListWithSingleTimestampOrder =
requestData.graphType === PANEL_TYPES.LIST &&
@@ -139,6 +144,43 @@ export const useGetQueryRange: UseGetQueryRange = (
GetMetricQueryRange(modifiedRequestData, version, signal, headers),
...options,
retry,
onSuccess: (data) => {
// Check for warnings in response
if (data.payload?.data?.warnings && data.payload.data.warnings.length > 0) {
const { warnings } = data.payload.data;
if (warnings.length === 1) {
// Single warning - show as simple message
notifications.warning({
message: warnings[0].replace(/\\n/g, '\n'),
key: 'query-warning-single',
duration: 6,
});
} else {
// Multiple warnings - show as formatted list
const formattedWarnings = warnings
.map((warning) => {
// Clean up the warning message
const cleanWarning = warning
.replace(/\\n/g, '\n') // Convert literal \n to actual newlines
.replace(/\[/g, '\n [') // Add line breaks before brackets
.replace(/\]/g, ']\n') // Add line breaks after brackets
.replace(/,/g, ',\n '); // Add line breaks after commas
return `${cleanWarning}`;
})
.join('\n');
notifications.warning({
message: `Query Warnings (${warnings.length}):\n\n${formattedWarnings}`,
key: 'query-warning-multiple',
duration: 10, // Show for 10 seconds for multiple warnings
style: { whiteSpace: 'pre-line' }, // Preserve line breaks
});
}
}
options?.onSuccess?.(data);
},
onError: (error) => {
if (options?.showErrorModal !== false) {
showErrorModalFn(error as APIError);

View File

@@ -114,11 +114,12 @@ export async function GetMetricQueryRange(
signal?: AbortSignal,
headers?: Record<string, string>,
isInfraMonitoring?: boolean,
): Promise<SuccessResponse<MetricRangePayloadProps>> {
): Promise<SuccessResponse<MetricRangePayloadProps> & { warnings?: string[] }> {
let legendMap: Record<string, string>;
let response:
| SuccessResponse<MetricRangePayloadProps>
| SuccessResponseV2<MetricRangePayloadV5>;
let warnings: string[] = [];
const panelType = props.originalGraphType || props.graphType;
@@ -148,6 +149,7 @@ export async function GetMetricQueryRange(
},
},
params: props,
warnings: [],
};
}
@@ -174,6 +176,7 @@ export async function GetMetricQueryRange(
},
},
params: props,
warnings: [],
};
}

View File

@@ -899,28 +899,43 @@ export function QueryBuilderProvider({
[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(() => {
if (!compositeQueryParam) return;
if (stagedQuery && stagedQuery.id === compositeQueryParam.id) {
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) {
return;
}
const { isValid, validData } = replaceIncorrectObjectFields(
compositeQueryParam,
initialQueriesMap.metrics,
);
const { isValid, validData } = replaceIncorrectObjectFields(
compositeQueryParam,
initialQueriesMap.metrics,
);
if (!isValid) {
redirectWithQueryBuilderData(validData);
} else {
initQueryBuilderData(compositeQueryParam);
if (!isValid) {
redirectWithQueryBuilderData(validData);
} else {
initQueryBuilderData(compositeQueryParam);
}
}
}, [
initQueryBuilderData,
redirectWithQueryBuilderData,
compositeQueryParam,
stagedQuery,
location.pathname,
]);
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(
(unit: string) => {
setCurrentQuery((prevState) => ({

View File

@@ -32,6 +32,7 @@ export interface MetricRangePayloadProps {
result: QueryData[];
resultType: string;
newResult: MetricRangePayloadV3;
warnings?: string[];
};
}
@@ -39,5 +40,6 @@ export interface MetricRangePayloadV3 {
data: {
result: QueryDataV3[];
resultType: string;
warnings?: string[];
};
}

View File

@@ -416,7 +416,7 @@ export type QueryRangeDataV5 =
export interface QueryRangeResponseV5 {
type: RequestType;
data: QueryRangeDataV5;
data: QueryRangeDataV5 & { warnings?: string[] };
meta: ExecStats;
}

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

@@ -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],
};
}
};