From f11b9644cf4818f8ddbc87442202eb7e9b6081f9 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 21 Apr 2025 11:11:05 +0530 Subject: [PATCH] Introduce new Resource Attribute FIlter in exceptions tab (#7589) * chore: resource attr filter init * chore: resource attr filter api integration * chore: operator config updated * chore: fliter show hide logic and styles * chore: add support for custom operator list to qb * chore: minor refactor * chore: minor code refactor * test: quick filters test suite added * test: quick filters test suite added * test: all errors test suite added * chore: style fix * test: all errors mock fix * chore: test case fix and mixpanel update * chore: color update * chore: minor refactor * chore: style fix * chore: set default query in exceptions tab * chore: style fix * chore: minor refactor * chore: minor refactor * chore: minor refactor * chore: test update * chore: fix filter header with no query name --------- Co-authored-by: Aditya Singh --- .../components/QuickFilters/QuickFilters.tsx | 65 +-- .../QuickFilters/tests/QuickFilters.test.tsx | 111 +++++ .../__snapshots__/QuickFilters.test.tsx.snap | 382 ++++++++++++++++++ .../QuickFilters/tests/constants.ts | 30 ++ frontend/src/components/QuickFilters/types.ts | 1 + frontend/src/constants/localStorage.ts | 1 + frontend/src/constants/queryBuilder.ts | 17 + frontend/src/constants/resourceAttributes.ts | 48 +++ frontend/src/container/AllError/index.tsx | 31 +- .../AllError/tests/AllError.test.tsx | 114 ++++++ .../src/container/AllError/tests/constants.ts | 94 +++++ frontend/src/container/AppLayout/index.tsx | 5 +- .../QueryBuilderSearchV2.tsx | 17 +- .../container/QueryBuilder/filters/utils.ts | 11 + .../ResourceAttributesFilter.styles.scss | 17 + .../ResourceAttributesFilterV2.tsx | 60 +++ .../TopNav/DateTimeSelectionV2/config.ts | 1 + .../src/hooks/useResourceAttribute/utils.ts | 31 +- .../src/pages/AllErrors/AllErrors.styles.scss | 27 ++ frontend/src/pages/AllErrors/index.tsx | 74 +++- frontend/src/pages/AllErrors/utils.tsx | 92 +++++ 21 files changed, 1174 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx create mode 100644 frontend/src/components/QuickFilters/tests/__snapshots__/QuickFilters.test.tsx.snap create mode 100644 frontend/src/components/QuickFilters/tests/constants.ts create mode 100644 frontend/src/container/AllError/tests/AllError.test.tsx create mode 100644 frontend/src/container/AllError/tests/constants.ts create mode 100644 frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilter.styles.scss create mode 100644 frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilterV2.tsx create mode 100644 frontend/src/pages/AllErrors/AllErrors.styles.scss create mode 100644 frontend/src/pages/AllErrors/utils.tsx diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx index fc50363ec1..21861e77d4 100644 --- a/frontend/src/components/QuickFilters/QuickFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -6,6 +6,7 @@ import { VerticalAlignTopOutlined, } from '@ant-design/icons'; import { Tooltip, Typography } from 'antd'; +import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { cloneDeep, isFunction } from 'lodash-es'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; @@ -68,10 +69,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
- Filters for - - {lastQueryName} - + + {lastQueryName ? 'Filters for' : 'Filters'} + + {lastQueryName && ( + + {lastQueryName} + + )}
@@ -89,31 +94,33 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
)} -
- {config.map((filter) => { - switch (filter.type) { - case FiltersType.CHECKBOX: - return ( - - ); - case FiltersType.SLIDER: - return ; - // eslint-disable-next-line sonarjs/no-duplicated-branches - default: - return ( - - ); - } - })} -
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ( + + ); + case FiltersType.SLIDER: + return ; + // eslint-disable-next-line sonarjs/no-duplicated-branches + default: + return ( + + ); + } + })} +
+
); } diff --git a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx new file mode 100644 index 0000000000..4aa026f60c --- /dev/null +++ b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx @@ -0,0 +1,111 @@ +import '@testing-library/jest-dom'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; + +import QuickFilters from '../QuickFilters'; +import { QuickFiltersSource } from '../types'; +import { QuickFiltersConfig } from './constants'; + +// Mock the useQueryBuilder hook +jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ + useQueryBuilder: jest.fn(), +})); +// Mock the useGetAggregateValues hook +jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({ + useGetAggregateValues: jest.fn(), +})); + +const handleFilterVisibilityChange = jest.fn(); +const redirectWithQueryBuilderData = jest.fn(); + +function TestQuickFilters(): JSX.Element { + return ( + + + + ); +} + +describe('Quick Filters', () => { + beforeEach(() => { + // Provide a mock implementation for useQueryBuilder + (useQueryBuilder as jest.Mock).mockReturnValue({ + currentQuery: { + builder: { + queryData: [ + { + queryName: 'Test Query', + filters: { items: [{ key: 'test', value: 'value' }] }, + }, + ], + }, + }, + lastUsedQuery: 0, + redirectWithQueryBuilderData, + }); + + // Provide a mock implementation for useGetAggregateValues + (useGetAggregateValues as jest.Mock).mockReturnValue({ + data: { + statusCode: 200, + error: null, + message: 'success', + payload: { + stringAttributeValues: [ + 'mq-kafka', + 'otel-demo', + 'otlp-python', + 'sample-flask', + ], + numberAttributeValues: null, + boolAttributeValues: null, + }, + }, // Mocked API response + isLoading: false, + }); + }); + + it('renders correctly with default props', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('displays the correct query name in the header', () => { + render(); + expect(screen.getByText('Filters for')).toBeInTheDocument(); + expect(screen.getByText('Test Query')).toBeInTheDocument(); + }); + + it('should add filter data to query when checkbox is clicked', () => { + render(); + const checkbox = screen.getByText('mq-kafka'); + fireEvent.click(checkbox); + expect(redirectWithQueryBuilderData).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + queryData: expect.arrayContaining([ + expect.objectContaining({ + filters: expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + key: expect.objectContaining({ + key: 'deployment.environment', + }), + value: 'mq-kafka', + }), + ]), + }), + }), + ]), + }, + }), + ); // sets composite query param + }); +}); diff --git a/frontend/src/components/QuickFilters/tests/__snapshots__/QuickFilters.test.tsx.snap b/frontend/src/components/QuickFilters/tests/__snapshots__/QuickFilters.test.tsx.snap new file mode 100644 index 0000000000..907f49a456 --- /dev/null +++ b/frontend/src/components/QuickFilters/tests/__snapshots__/QuickFilters.test.tsx.snap @@ -0,0 +1,382 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Quick Filters renders correctly with default props 1`] = ` +
+
+
+
+ + + + + Filters for + + + Test Query + +
+
+ + + +
+ + + +
+
+
+
+
+
+
+
+ + + + + Environment + +
+
+ + Clear All + +
+
+ +
+
+ +
+ + mq-kafka + + + +
+
+
+ +
+ + otel-demo + + + +
+
+
+ +
+ + otlp-python + + + +
+
+
+ +
+ + sample-flask + + + +
+
+
+
+
+
+
+ + + + + Service Name + +
+
+
+
+
+
+
+
+
+`; diff --git a/frontend/src/components/QuickFilters/tests/constants.ts b/frontend/src/components/QuickFilters/tests/constants.ts new file mode 100644 index 0000000000..9bf7a00265 --- /dev/null +++ b/frontend/src/components/QuickFilters/tests/constants.ts @@ -0,0 +1,30 @@ +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +import { FiltersType } from '../types'; + +export const QuickFiltersConfig = [ + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, +]; diff --git a/frontend/src/components/QuickFilters/types.ts b/frontend/src/components/QuickFilters/types.ts index 601bfee72a..fee95b10cb 100644 --- a/frontend/src/components/QuickFilters/types.ts +++ b/frontend/src/components/QuickFilters/types.ts @@ -40,4 +40,5 @@ export enum QuickFiltersSource { INFRA_MONITORING = 'infra-monitoring', TRACES_EXPLORER = 'traces-explorer', API_MONITORING = 'api-monitoring', + EXCEPTIONS = 'exceptions', } diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 7614a670d7..7cf51ccb11 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -27,4 +27,5 @@ export enum LOCALSTORAGE { CELERY_OVERVIEW_COLUMNS = 'CELERY_OVERVIEW_COLUMNS', DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING', METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS', + SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS', } diff --git a/frontend/src/constants/queryBuilder.ts b/frontend/src/constants/queryBuilder.ts index 359f4a22de..2ddc210289 100644 --- a/frontend/src/constants/queryBuilder.ts +++ b/frontend/src/constants/queryBuilder.ts @@ -398,6 +398,23 @@ export const QUERY_BUILDER_OPERATORS_BY_TYPES = { ], }; +export enum OperatorConfigKeys { + 'EXCEPTIONS' = 'EXCEPTIONS', +} + +export const OPERATORS_CONFIG = { + [OperatorConfigKeys.EXCEPTIONS]: [ + OPERATORS['='], + OPERATORS['!='], + OPERATORS.IN, + OPERATORS.NIN, + OPERATORS.EXISTS, + OPERATORS.NOT_EXISTS, + OPERATORS.CONTAINS, + OPERATORS.NOT_CONTAINS, + ], +}; + export const HAVING_OPERATORS: string[] = [ OPERATORS['='], OPERATORS['!='], diff --git a/frontend/src/constants/resourceAttributes.ts b/frontend/src/constants/resourceAttributes.ts index ba61d4055a..5d71643a1e 100644 --- a/frontend/src/constants/resourceAttributes.ts +++ b/frontend/src/constants/resourceAttributes.ts @@ -16,3 +16,51 @@ export const OperatorConversions: Array<{ traceValue: 'NotIn', }, ]; + +// mapping from qb to exceptions +export const CompositeQueryOperatorsConfig: Array<{ + label: string; + metricValue: string; + traceValue: OperatorValues; +}> = [ + { + label: 'in', + metricValue: '=~', + traceValue: 'In', + }, + { + label: 'nin', + metricValue: '!~', + traceValue: 'NotIn', + }, + { + label: '=', + metricValue: '=', + traceValue: 'Equals', + }, + { + label: '!=', + metricValue: '!=', + traceValue: 'NotEquals', + }, + { + label: 'exists', + metricValue: '=~', + traceValue: 'Exists', + }, + { + label: 'nexists', + metricValue: '!~', + traceValue: 'NotExists', + }, + { + label: 'contains', + metricValue: '=~', + traceValue: 'Contains', + }, + { + label: 'ncontains', + metricValue: '!~', + traceValue: 'NotContains', + }, +]; diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx index e7f399a71f..96415daec6 100644 --- a/frontend/src/container/AllError/index.tsx +++ b/frontend/src/container/AllError/index.tsx @@ -18,16 +18,17 @@ import getErrorCounts from 'api/errors/getErrorCounts'; import { ResizeTable } from 'components/ResizeTable'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import ROUTES from 'constants/routes'; +import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useNotifications } from 'hooks/useNotifications'; import useResourceAttribute from 'hooks/useResourceAttribute'; -import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; +import { convertCompositeQueryToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter'; import useUrlQuery from 'hooks/useUrlQuery'; import createQueryParams from 'lib/createQueryParams'; import history from 'lib/history'; import { isUndefined } from 'lodash-es'; import { useTimezone } from 'providers/Timezone'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries } from 'react-query'; import { useSelector } from 'react-redux'; @@ -109,10 +110,11 @@ function AllErrors(): JSX.Element { ); const { queries } = useResourceAttribute(); + const compositeData = useGetCompositeQueryParam(); const [{ isLoading, data }, errorCountResponse] = useQueries([ { - queryKey: ['getAllErrors', updatedPath, maxTime, minTime, queries], + queryKey: ['getAllErrors', updatedPath, maxTime, minTime, compositeData], queryFn: (): Promise | ErrorResponse> => getAll({ end: maxTime, @@ -123,7 +125,9 @@ function AllErrors(): JSX.Element { orderParam: getUpdatedParams, exceptionType: getUpdatedExceptionType, serviceName: getUpdatedServiceName, - tags: convertRawQueriesToTraceSelectedTags(queries), + tags: convertCompositeQueryToTraceSelectedTags( + compositeData?.builder.queryData?.[0]?.filters.items, + ), }), enabled: !loading, }, @@ -134,7 +138,7 @@ function AllErrors(): JSX.Element { minTime, getUpdatedExceptionType, getUpdatedServiceName, - queries, + compositeData, ], queryFn: (): Promise> => getErrorCounts({ @@ -142,7 +146,9 @@ function AllErrors(): JSX.Element { start: minTime, exceptionType: getUpdatedExceptionType, serviceName: getUpdatedServiceName, - tags: convertRawQueriesToTraceSelectedTags(queries), + tags: convertCompositeQueryToTraceSelectedTags( + compositeData?.builder.queryData?.[0]?.filters.items, + ), }), enabled: !loading, }, @@ -429,12 +435,8 @@ function AllErrors(): JSX.Element { [pathname], ); - const logEventCalledRef = useRef(false); useEffect(() => { - if ( - !logEventCalledRef.current && - !isUndefined(errorCountResponse.data?.payload) - ) { + if (!isUndefined(errorCountResponse.data?.payload)) { const selectedEnvironments = queries.find( (val) => val.tagKey === 'resource_deployment_environment', )?.tagValue; @@ -442,9 +444,12 @@ function AllErrors(): JSX.Element { logEvent('Exception: List page visited', { numberOfExceptions: errorCountResponse?.data?.payload, selectedEnvironments, - resourceAttributeUsed: !!queries?.length, + resourceAttributeUsed: !!compositeData?.builder.queryData?.[0]?.filters + .items?.length, + tags: convertCompositeQueryToTraceSelectedTags( + compositeData?.builder.queryData?.[0]?.filters.items, + ), }); - logEventCalledRef.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [errorCountResponse.data?.payload]); diff --git a/frontend/src/container/AllError/tests/AllError.test.tsx b/frontend/src/container/AllError/tests/AllError.test.tsx new file mode 100644 index 0000000000..812786a5c1 --- /dev/null +++ b/frontend/src/container/AllError/tests/AllError.test.tsx @@ -0,0 +1,114 @@ +import '@testing-library/jest-dom'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { ENVIRONMENT } from 'constants/env'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; +import TimezoneProvider from 'providers/Timezone'; +import { Provider, useSelector } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +import AllErrors from '../index'; +import { + INIT_URL_WITH_COMMON_QUERY, + MOCK_ERROR_LIST, + TAG_FROM_QUERY, +} from './constants'; + +jest.mock('hooks/useResourceAttribute', () => + jest.fn(() => ({ + queries: [], + })), +); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +function Exceptions({ initUrl }: { initUrl?: string[] }): JSX.Element { + return ( + + + + + + + + + + ); +} + +Exceptions.defaultProps = { + initUrl: ['/exceptions'], +}; + +const BASE_URL = ENVIRONMENT.baseURL; +const listErrorsURL = `${BASE_URL}/api/v1/listErrors`; +const countErrorsURL = `${BASE_URL}/api/v1/countErrors`; + +const postListErrorsSpy = jest.fn(); + +describe('Exceptions - All Errors', () => { + beforeEach(() => { + (useSelector as jest.Mock).mockReturnValue({ + maxTime: 1000, + minTime: 0, + loading: false, + }); + server.use( + rest.post(listErrorsURL, async (req, res, ctx) => { + const body = await req.json(); + postListErrorsSpy(body); + return res(ctx.status(200), ctx.json(MOCK_ERROR_LIST)); + }), + ); + server.use( + rest.post(countErrorsURL, (req, res, ctx) => + res(ctx.status(200), ctx.json(540)), + ), + ); + }); + + it('renders correctly with default props', async () => { + render(); + const item = await screen.findByText(/redis timeout/i); + expect(item).toBeInTheDocument(); + }); + + it('should sort Error Message appropriately', async () => { + render(); + await screen.findByText(/redis timeout/i); + + const caretIconUp = screen.getAllByLabelText('caret-up')[0]; + const caretIconDown = screen.getAllByLabelText('caret-down')[0]; + + // sort by ascending + expect(caretIconUp.className).not.toContain('active'); + fireEvent.click(caretIconUp); + expect(caretIconUp.className).toContain('active'); + let queryParams = new URLSearchParams(window.location.search); + expect(queryParams.get('order')).toBe('ascending'); + expect(queryParams.get('orderParam')).toBe('exceptionType'); + + // sort by descending + expect(caretIconDown.className).not.toContain('active'); + fireEvent.click(caretIconDown); + expect(caretIconDown.className).toContain('active'); + queryParams = new URLSearchParams(window.location.search); + expect(queryParams.get('order')).toBe('descending'); + }); + + it('should call useQueries with exact composite query object', async () => { + render(); + await screen.findByText(/redis timeout/i); + expect(postListErrorsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tags: TAG_FROM_QUERY, + }), + ); + }); +}); diff --git a/frontend/src/container/AllError/tests/constants.ts b/frontend/src/container/AllError/tests/constants.ts new file mode 100644 index 0000000000..551885c77b --- /dev/null +++ b/frontend/src/container/AllError/tests/constants.ts @@ -0,0 +1,94 @@ +export const MOCK_USE_QUERIES_DATA = [ + { + isLoading: false, + isError: false, + error: null, + data: { + statusCode: 200, + payload: [ + { + exceptionType: '*errors.errorString', + exceptionMessage: 'redis timeout', + exceptionCount: 2510, + lastSeen: '2025-04-14T18:27:57.797616374Z', + firstSeen: '2025-04-14T17:58:00.262775497Z', + serviceName: 'redis-manual', + groupID: '511b9c91a92b9c5166ecb77235f5743b', + }, + ], + }, + }, + { + status: 'success', + isLoading: false, + isSuccess: true, + isError: false, + isIdle: false, + data: { + statusCode: 200, + error: null, + payload: 525, + }, + dataUpdatedAt: 1744661020341, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isPlaceholderData: false, + isPreviousData: false, + isRefetchError: false, + isStale: true, + }, +]; + +export const INIT_URL_WITH_COMMON_QUERY = + '/exceptions?compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522traces%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----resource--false%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522type%2522%253A%2522resource%2522%252C%2522isJSON%2522%253Afalse%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522db118ac7-9313-4adb-963f-f31b5b32c496%2522%252C%2522op%2522%253A%2522in%2522%252C%2522key%2522%253A%257B%2522key%2522%253A%2522deployment.environment%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522type%2522%253A%2522resource%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522value%2522%253A%2522mq-kafka%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A60%252C%2522having%2522%253A%255B%255D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522dd576d04-0822-476d-b0c2-807a7af2e5e7%2522%257D'; + +export const extractCompositeQueryObject = ( + url: string, +): Record | null => { + try { + const urlObj = new URL(`http://dummy-base${url}`); // Add dummy base to parse relative URL + const encodedParam = urlObj.searchParams.get('compositeQuery'); + + if (!encodedParam) return null; + + // Decode twice + const firstDecode = decodeURIComponent(encodedParam); + const secondDecode = decodeURIComponent(firstDecode); + + // Parse JSON + return JSON.parse(secondDecode); + } catch (err) { + console.error('Failed to extract compositeQuery:', err); + return null; + } +}; + +export const TAG_FROM_QUERY = [ + { + BoolValues: [], + Key: 'deployment.environment', + NumberValues: [], + Operator: 'In', + StringValues: ['mq-kafka'], + TagType: 'ResourceAttribute', + }, +]; + +export const MOCK_ERROR_LIST = [ + { + exceptionType: '*errors.errorString', + exceptionMessage: 'redis timeout', + exceptionCount: 2510, + lastSeen: '2025-04-14T18:27:57.797616374Z', + firstSeen: '2025-04-14T17:58:00.262775497Z', + serviceName: 'redis-manual', + groupID: '511b9c91a92b9c5166ecb77235f5743b', + }, +]; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index d7065fcc8f..2cff413199 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -339,6 +339,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isApiMonitoringView = (): boolean => routeKey === 'API_MONITORING'; + const isExceptionsView = (): boolean => routeKey === 'ALL_ERROR'; + const isTracesView = (): boolean => routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS'; @@ -661,7 +663,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isMessagingQueues() || isCloudIntegrationPage() || isInfraMonitoring() || - isApiMonitoringView() + isApiMonitoringView() || + isExceptionsView() ? 0 : '0 1rem', diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index f85b41b209..fa4c9aea0c 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -5,6 +5,7 @@ import { Select, Spin, Tag, Tooltip } from 'antd'; import cx from 'classnames'; import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY, + OperatorConfigKeys, OPERATORS, QUERY_BUILDER_OPERATORS_BY_TYPES, QUERY_BUILDER_SEARCH_VALUES, @@ -62,6 +63,7 @@ import { getTagToken, isInNInOperator, } from '../QueryBuilderSearch/utils'; +import { filterByOperatorConfig } from '../utils'; import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown'; import Suggestions from './Suggestions'; @@ -88,6 +90,7 @@ interface QueryBuilderSearchV2Props { className?: string; suffixIcon?: React.ReactNode; hardcodedAttributeKeys?: BaseAutocompleteData[]; + operatorConfigKey?: OperatorConfigKeys; } export interface Option { @@ -121,6 +124,7 @@ function QueryBuilderSearchV2( suffixIcon, whereClauseConfig, hardcodedAttributeKeys, + operatorConfigKey, } = props; const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); @@ -717,15 +721,11 @@ function QueryBuilderSearchV2( op.label.startsWith(partialOperator.toLocaleUpperCase()), ); } - operatorOptions = [{ label: '', value: '' }, ...operatorOptions]; - setDropdownOptions(operatorOptions); } else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) { operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({ label: operator, value: operator, })); - operatorOptions = [{ label: '', value: '' }, ...operatorOptions]; - setDropdownOptions(operatorOptions); } else { operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map( (operator) => ({ @@ -739,9 +739,12 @@ function QueryBuilderSearchV2( op.label.startsWith(partialOperator.toLocaleUpperCase()), ); } - operatorOptions = [{ label: '', value: '' }, ...operatorOptions]; - setDropdownOptions(operatorOptions); } + const filterOperatorOptions = filterByOperatorConfig( + operatorOptions, + operatorConfigKey, + ); + setDropdownOptions([{ label: '', value: '' }, ...filterOperatorOptions]); } if (currentState === DropdownState.ATTRIBUTE_VALUE) { @@ -774,6 +777,7 @@ function QueryBuilderSearchV2( isLogsDataSource, searchValue, suggestionsData?.payload?.attributes, + operatorConfigKey, ]); // keep the query in sync with the selected tags in logs explorer page @@ -1000,6 +1004,7 @@ QueryBuilderSearchV2.defaultProps = { suffixIcon: null, whereClauseConfig: {}, hardcodedAttributeKeys: undefined, + operatorConfigKey: undefined, }; export default QueryBuilderSearchV2; diff --git a/frontend/src/container/QueryBuilder/filters/utils.ts b/frontend/src/container/QueryBuilder/filters/utils.ts index ff52e7cb70..889c9ed321 100644 --- a/frontend/src/container/QueryBuilder/filters/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/utils.ts @@ -1,4 +1,5 @@ import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch'; +import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder'; import { HAVING_FILTER_REGEXP } from 'constants/regExp'; import { IOption } from 'hooks/useResourceAttribute/types'; import uniqWith from 'lodash-es/unionWith'; @@ -110,3 +111,13 @@ export const transformKeyValuesToAttributeValuesMap = ( }, ]), ); + +export const filterByOperatorConfig = ( + options: IOption[], + key?: OperatorConfigKeys, +): IOption[] => { + if (!key || !OPERATORS_CONFIG[key]) return options; + return options.filter((option) => + OPERATORS_CONFIG[key].includes(option.label), + ); +}; diff --git a/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilter.styles.scss b/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilter.styles.scss new file mode 100644 index 0000000000..632f7a8dfa --- /dev/null +++ b/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilter.styles.scss @@ -0,0 +1,17 @@ +.resourceAttributesFilter-container-v2 { + margin: 8px; + + .ant-select-selector { + border-radius: 2px; + border: 1px solid var(--bg-slate-400) !important; + background-color: var(--bg-ink-300) !important; + + input { + font-size: 12px; + } + + .ant-tag .ant-typography { + font-size: 12px; + } + } +} \ No newline at end of file diff --git a/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilterV2.tsx b/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilterV2.tsx new file mode 100644 index 0000000000..80dc3136a5 --- /dev/null +++ b/frontend/src/container/ResourceAttributeFilterV2/ResourceAttributesFilterV2.tsx @@ -0,0 +1,60 @@ +import './ResourceAttributesFilter.styles.scss'; + +import { initialQueriesMap, OperatorConfigKeys } from 'constants/queryBuilder'; +import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; +import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; +import { useCallback } from 'react'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +function ResourceAttributesFilter(): JSX.Element | null { + const { currentQuery } = useQueryBuilder(); + const query = currentQuery?.builder?.queryData[0] || null; + + const { handleChangeQueryData } = useQueryOperations({ + index: 0, + query, + entityVersion: '', + }); + + // initialise tab with default query. + useShareBuilderUrl({ + ...initialQueriesMap.traces, + builder: { + ...initialQueriesMap.traces.builder, + queryData: [ + { + ...initialQueriesMap.traces.builder.queryData[0], + dataSource: DataSource.TRACES, + aggregateOperator: 'noop', + aggregateAttribute: { + ...initialQueriesMap.traces.builder.queryData[0].aggregateAttribute, + type: 'resource', + }, + queryName: '', + }, + ], + }, + }); + + const handleChangeTagFilters = useCallback( + (value: IBuilderQuery['filters']) => { + handleChangeQueryData('filters', value); + }, + [handleChangeQueryData], + ); + + return ( +
+ +
+ ); +} + +export default ResourceAttributesFilter; diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index 9445840cbc..68920ce553 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -230,6 +230,7 @@ export const routesToSkip = [ ROUTES.CHANNELS_NEW, ROUTES.CHANNELS_EDIT, ROUTES.WORKSPACE_ACCESS_RESTRICTED, + ROUTES.ALL_ERROR, ]; export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS]; diff --git a/frontend/src/hooks/useResourceAttribute/utils.ts b/frontend/src/hooks/useResourceAttribute/utils.ts index 4dd8c56563..9c644221ec 100644 --- a/frontend/src/hooks/useResourceAttribute/utils.ts +++ b/frontend/src/hooks/useResourceAttribute/utils.ts @@ -2,7 +2,10 @@ import { getResourceAttributesTagKeys, getResourceAttributesTagValues, } from 'api/metrics/getResourceAttributes'; -import { OperatorConversions } from 'constants/resourceAttributes'; +import { + CompositeQueryOperatorsConfig, + OperatorConversions, +} from 'constants/resourceAttributes'; import ROUTES from 'constants/routes'; import { MetricsType } from 'container/MetricsApplication/constant'; import { @@ -49,6 +52,32 @@ export const convertOperatorLabelToTraceOperator = ( OperatorConversions.find((operator) => operator.label === label) ?.traceValue as OperatorValues; +export function convertOperatorLabelForExceptions( + label: string, +): OperatorValues { + return CompositeQueryOperatorsConfig.find( + (operator) => operator.label === label, + )?.traceValue as OperatorValues; +} + +export function formatStringValuesForTrace( + val: TagFilterItem['value'] = [], +): string[] { + return !Array.isArray(val) ? [String(val)] : val; +} + +export const convertCompositeQueryToTraceSelectedTags = ( + filterItems: TagFilterItem[] = [], +): Tags[] => + filterItems.map((item) => ({ + Key: item?.key?.key, + Operator: convertOperatorLabelForExceptions(item.op), + StringValues: formatStringValuesForTrace(item?.value), + NumberValues: [], + BoolValues: [], + TagType: 'ResourceAttribute', + })) as Tags[]; + export const convertRawQueriesToTraceSelectedTags = ( queries: IResourceAttribute[], tagType = 'ResourceAttribute', diff --git a/frontend/src/pages/AllErrors/AllErrors.styles.scss b/frontend/src/pages/AllErrors/AllErrors.styles.scss new file mode 100644 index 0000000000..adc9220122 --- /dev/null +++ b/frontend/src/pages/AllErrors/AllErrors.styles.scss @@ -0,0 +1,27 @@ +.all-errors-page { + display: flex; + height: 100%; + .all-errors-quick-filter-section { + width: 0%; + flex-shrink: 0; + color: var(--bg-vanilla-100); + } + + .all-errors-right-section { + padding: 0 10px; + } + + .ant-tabs { + margin: 0 8px; + } + + &.filter-visible { + .all-errors-quick-filter-section { + width: 260px; + } + + .all-errors-right-section { + width: calc(100% - 260px); + } + } +} diff --git a/frontend/src/pages/AllErrors/index.tsx b/frontend/src/pages/AllErrors/index.tsx index 44f0d3bcdd..4d0c9d7b88 100644 --- a/frontend/src/pages/AllErrors/index.tsx +++ b/frontend/src/pages/AllErrors/index.tsx @@ -1,18 +1,82 @@ +import './AllErrors.styles.scss'; + +import { FilterOutlined } from '@ant-design/icons'; +import { Button, Tooltip } from 'antd'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import cx from 'classnames'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { QuickFiltersSource } from 'components/QuickFilters/types'; import RouteTab from 'components/RouteTab'; -import ResourceAttributesFilter from 'container/ResourceAttributesFilter'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; +import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2'; +import Toolbar from 'container/Toolbar/Toolbar'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import history from 'lib/history'; +import { isNull } from 'lodash-es'; +import { useState } from 'react'; import { useLocation } from 'react-router-dom'; import { routes } from './config'; +import { ExceptionsQuickFiltersConfig } from './utils'; function AllErrors(): JSX.Element { const { pathname } = useLocation(); + const { handleRunQuery } = useQueryBuilder(); + + const [showFilters, setShowFilters] = useState(() => { + const localStorageValue = getLocalStorageKey( + LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, + ); + if (!isNull(localStorageValue)) { + return localStorageValue === 'true'; + } + return true; + }); + + const handleFilterVisibilityChange = (): void => { + setLocalStorageApi( + LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, + String(!showFilters), + ); + setShowFilters((prev) => !prev); + }; return ( - <> - - - +
+ {showFilters && ( +
+ +
+ )} +
+ + + + ) : undefined + } + rightActions={} + /> + + +
+
); } diff --git a/frontend/src/pages/AllErrors/utils.tsx b/frontend/src/pages/AllErrors/utils.tsx new file mode 100644 index 0000000000..7f52e89193 --- /dev/null +++ b/frontend/src/pages/AllErrors/utils.tsx @@ -0,0 +1,92 @@ +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/types'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +export const ExceptionsQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Hostname', + attributeKey: { + key: 'host.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Cluster Name', + attributeKey: { + key: 'k8s.cluster.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Deployment Name', + attributeKey: { + key: 'k8s.deployment.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Namespace Name', + attributeKey: { + key: 'k8s.namespace.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Pod Name', + attributeKey: { + key: 'k8s.pod.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, +];