diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 1e16e3ecc56d..f5ce505de3c6 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -48,6 +48,6 @@ "INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring", "INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring", "METER_EXPLORER": "SigNoz | Meter Explorer", - "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer", - "METER_EXPLORER_BASE": "SigNoz | Meter Explorer" + "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", + "METER": "SigNoz | Meter" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index ec43f8c026d3..3ab532a9138f 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -71,6 +71,6 @@ "METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer", "API_MONITORING": "SigNoz | External APIs", "METER_EXPLORER": "SigNoz | Meter Explorer", - "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer", - "METER_EXPLORER_BASE": "SigNoz | Meter Explorer" + "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", + "METER": "SigNoz | Meter" } diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index c2528be48bf6..9553c1096acc 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -437,10 +437,10 @@ const routes: AppRoutes[] = [ }, { - path: ROUTES.METER_EXPLORER_BASE, + path: ROUTES.METER, exact: true, component: MeterExplorer, - key: 'METER_EXPLORER_BASE', + key: 'METER', isPrivate: true, }, { diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss index e0bf77e52b8d..2f050ab78eb6 100644 --- a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss @@ -137,5 +137,11 @@ h6 { color: var(--text-ink-500); } + + code { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + color: var(--text-ink-500); + } } } diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss index 4cdb6bd77861..9038e8ce433a 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss +++ b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss @@ -44,13 +44,14 @@ .lightMode { .metrics-select-container { .ant-select-selector { - border: 1px solid var(--bg-slate-300) !important; + border: 1px solid var(--bg-vanilla-300) !important; background: var(--bg-vanilla-100); color: var(--text-ink-100); } .ant-select-dropdown { background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300) !important; box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05); backdrop-filter: none; diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts new file mode 100644 index 000000000000..6e30c69ab338 --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts @@ -0,0 +1,536 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; + +import { convertFiltersToExpression } from '../utils'; + +describe('convertFiltersToExpression', () => { + it('should handle empty, null, and undefined inputs', () => { + // Test null and undefined + expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' }); + expect(convertFiltersToExpression(undefined as any)).toEqual({ + expression: '', + }); + + // Test empty filters + expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({ + expression: '', + }); + expect( + convertFiltersToExpression({ items: undefined, op: 'AND' } as any), + ).toEqual({ expression: '' }); + }); + + it('should convert basic comparison operators with proper value formatting', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: '=', + value: 'api-gateway', + }, + { + id: '2', + key: { key: 'status', type: 'string' }, + op: '!=', + value: 'error', + }, + { + id: '3', + key: { key: 'duration', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '4', + key: { key: 'count', type: 'number' }, + op: '<=', + value: 50, + }, + { + id: '5', + key: { key: 'is_active', type: 'boolean' }, + op: '=', + value: true, + }, + { + id: '6', + key: { key: 'enabled', type: 'boolean' }, + op: '=', + value: false, + }, + { + id: '7', + key: { key: 'count', type: 'number' }, + op: '=', + value: 0, + }, + { + id: '7', + key: { key: 'regex', type: 'string' }, + op: 'regex', + value: '.*', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'", + }); + }); + + it('should handle string value formatting and escaping', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'message', type: 'string' }, + op: '=', + value: "user's data", + }, + { + id: '2', + key: { key: 'description', type: 'string' }, + op: '=', + value: '', + }, + { + id: '3', + key: { key: 'path', type: 'string' }, + op: '=', + value: '/api/v1/users', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'", + }); + }); + + it('should handle IN operator with various value types and array formatting', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service', 'auth-service'], + }, + { + id: '2', + key: { key: 'status', type: 'string' }, + op: 'IN', + value: 'success', // Single value should be converted to array + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'IN', + value: [], // Empty array + }, + { + id: '4', + key: { key: 'name', type: 'string' }, + op: 'IN', + value: ["John's", "Mary's", 'Bob'], // Values with quotes + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']", + }); + }); + + it('should convert deprecated operators to their modern equivalents', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'nin', + value: ['api-gateway', 'user-service'], + }, + { + id: '2', + key: { key: 'message', type: 'string' }, + op: 'nlike', + value: 'error', + }, + { + id: '3', + key: { key: 'path', type: 'string' }, + op: 'nregex', + value: '/api/.*', + }, + { + id: '4', + key: { key: 'service', type: 'string' }, + op: 'NIN', // Test case insensitivity + value: ['api-gateway'], + }, + { + id: '5', + key: { key: 'user_id', type: 'string' }, + op: 'nexists', + value: '', + }, + { + id: '6', + key: { key: 'description', type: 'string' }, + op: 'ncontains', + value: 'error', + }, + { + id: '7', + key: { key: 'tags', type: 'string' }, + op: 'nhas', + value: 'production', + }, + { + id: '8', + key: { key: 'labels', type: 'string' }, + op: 'nhasany', + value: ['env:prod', 'service:api'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", + }); + }); + + it('should handle non-value operators and function operators', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: '', // Value should be ignored for EXISTS + }, + { + id: '2', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: 'some-value', // Value should be ignored for EXISTS + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '4', + key: { key: 'tags', type: 'string' }, + op: 'hasAny', + value: ['production', 'staging'], + }, + { + id: '5', + key: { key: 'tags', type: 'string' }, + op: 'hasAll', + value: ['production', 'monitoring'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])", + }); + }); + + it('should filter out invalid filters and handle edge cases', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: '=', + value: 'api-gateway', + }, + { + id: '2', + key: undefined, // Invalid filter - should be skipped + op: '=', + value: 'error', + }, + { + id: '3', + key: { key: '', type: 'string' }, // Invalid filter with empty key - should be skipped + op: '=', + value: 'test', + }, + { + id: '4', + key: { key: 'status', type: 'string' }, + op: ' = ', // Test whitespace handling + value: 'success', + }, + { + id: '5', + key: { key: 'service', type: 'string' }, + op: 'In', // Test mixed case handling + value: ['api-gateway'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']", + }); + }); + + it('should handle complex mixed operator scenarios with proper joining', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service'], + }, + { + id: '2', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: '', + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '4', + key: { key: 'duration', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '5', + key: { key: 'status', type: 'string' }, + op: 'nin', + value: ['error', 'timeout'], + }, + { + id: '6', + key: { key: 'method', type: 'string' }, + op: '=', + value: 'POST', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'", + }); + }); + + it('should handle all numeric comparison operators and edge cases', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'count', type: 'number' }, + op: '=', + value: 0, + }, + { + id: '2', + key: { key: 'score', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '3', + key: { key: 'limit', type: 'number' }, + op: '>=', + value: 50, + }, + { + id: '4', + key: { key: 'threshold', type: 'number' }, + op: '<', + value: 1000, + }, + { + id: '5', + key: { key: 'max_value', type: 'number' }, + op: '<=', + value: 999, + }, + { + id: '6', + key: { key: 'values', type: 'string' }, + op: 'IN', + value: ['1', '2', '3', '4', '5'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']", + }); + }); + + it('should handle boolean values and string comparisons with special characters', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'is_active', type: 'boolean' }, + op: '=', + value: true, + }, + { + id: '2', + key: { key: 'is_deleted', type: 'boolean' }, + op: '=', + value: false, + }, + { + id: '3', + key: { key: 'email', type: 'string' }, + op: '=', + value: 'user@example.com', + }, + { + id: '4', + key: { key: 'description', type: 'string' }, + op: '=', + value: 'Contains "quotes" and \'apostrophes\'', + }, + { + id: '5', + key: { key: 'path', type: 'string' }, + op: '=', + value: '/api/v1/users/123?filter=true', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'", + }); + }); + + it('should handle all function operators and complex array scenarios', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '2', + key: { key: 'labels', type: 'string' }, + op: 'hasAny', + value: ['env:prod', 'service:api'], + }, + { + id: '3', + key: { key: 'metadata', type: 'string' }, + op: 'hasAll', + value: ['version:1.0', 'team:backend'], + }, + { + id: '4', + key: { key: 'services', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service', 'auth-service', 'payment-service'], + }, + { + id: '5', + key: { key: 'excluded_services', type: 'string' }, + op: 'nin', + value: ['legacy-service', 'deprecated-service'], + }, + { + id: '6', + key: { key: 'status_codes', type: 'string' }, + op: 'IN', + value: ['200', '201', '400', '500'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']", + }); + }); + + it('should handle specific deprecated operators: nhas, ncontains, nexists', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'user_id', type: 'string' }, + op: 'nexists', + value: '', + }, + { + id: '2', + key: { key: 'description', type: 'string' }, + op: 'ncontains', + value: 'error', + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'nhas', + value: 'production', + }, + { + id: '4', + key: { key: 'labels', type: 'string' }, + op: 'nhasany', + value: ['env:prod', 'service:api'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", + }); + }); +}); diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 3bed5857a06d..49f6b8988de1 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -1,6 +1,10 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5'; -import { OPERATORS } from 'constants/antlrQueryConstants'; +import { + DEPRECATED_OPERATORS_MAP, + OPERATORS, + QUERY_BUILDER_FUNCTIONS, +} from 'constants/antlrQueryConstants'; import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; import { cloneDeep } from 'lodash-es'; import { IQueryPair } from 'types/antlrQueryTypes'; @@ -21,7 +25,7 @@ import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; import { extractQueryPairs } from 'utils/queryContextUtils'; import { unquote } from 'utils/stringUtils'; -import { isFunctionOperator } from 'utils/tokenUtils'; +import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils'; import { v4 as uuid } from 'uuid'; /** @@ -87,12 +91,32 @@ export const convertFiltersToExpression = ( return ''; } - if (isFunctionOperator(op)) { - return `${op}(${key.key}, ${value})`; + let operator = op.trim().toLowerCase(); + if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(operator)) { + operator = + DEPRECATED_OPERATORS_MAP[ + operator as keyof typeof DEPRECATED_OPERATORS_MAP + ]; } - const formattedValue = formatValueForExpression(value, op); - return `${key.key} ${op} ${formattedValue}`; + if (isNonValueOperator(operator)) { + return `${key.key} ${operator}`; + } + + if (isFunctionOperator(operator)) { + // Get the proper function name from QUERY_BUILDER_FUNCTIONS + const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS); + const properFunctionName = + functionOperators.find( + (func: string) => func.toLowerCase() === operator.toLowerCase(), + ) || operator; + + const formattedValue = formatValueForExpression(value, operator); + return `${properFunctionName}(${key.key}, ${formattedValue})`; + } + + const formattedValue = formatValueForExpression(value, operator); + return `${key.key} ${operator} ${formattedValue}`; }) .filter((expression) => expression !== ''); // Remove empty expressions @@ -117,7 +141,6 @@ export const convertExpressionToFilters = ( if (!expression) return []; const queryPairs = extractQueryPairs(expression); - const filters: TagFilterItem[] = []; queryPairs.forEach((pair) => { @@ -145,19 +168,36 @@ export const convertFiltersToExpressionWithExistingQuery = ( filters: TagFilter, existingQuery: string | undefined, ): { filters: TagFilter; filter: { expression: string } } => { + // Check for deprecated operators and replace them with new operators + const updatedFilters = cloneDeep(filters); + + // Replace deprecated operators in filter items + if (updatedFilters?.items) { + updatedFilters.items = updatedFilters.items.map((item) => { + const opLower = item.op?.toLowerCase(); + if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(opLower)) { + return { + ...item, + op: DEPRECATED_OPERATORS_MAP[ + opLower as keyof typeof DEPRECATED_OPERATORS_MAP + ].toLowerCase(), + }; + } + return item; + }); + } + if (!existingQuery) { // If no existing query, return filters with a newly generated expression return { - filters, - filter: convertFiltersToExpression(filters), + filters: updatedFilters, + filter: convertFiltersToExpression(updatedFilters), }; } // Extract query pairs from the existing query const queryPairs = extractQueryPairs(existingQuery.trim()); let queryPairsMap: Map = new Map(); - - const updatedFilters = cloneDeep(filters); // Clone filters to avoid direct mutation const nonExistingFilters: TagFilterItem[] = []; let modifiedQuery = existingQuery; // We'll modify this query as we proceed const visitedPairs: Set = new Set(); // Set to track visited query pairs diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx index 635338ef5e78..c482165dd15d 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx @@ -5,8 +5,11 @@ import { SignalType } from 'components/QuickFilters/types'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions'; +import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions'; import { useMemo } from 'react'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types'; import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters'; import { DataSource } from 'types/common/queryBuilder'; @@ -40,6 +43,10 @@ function OtherFilters({ () => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS, [signal], ); + const isMeterDataSource = useMemo( + () => signal && signal === SignalType.METER_EXPLORER, + [signal], + ); const { data: suggestionsData, @@ -69,7 +76,22 @@ function OtherFilters({ }, { queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue], - enabled: !!signal && !isLogDataSource, + enabled: !!signal && !isLogDataSource && !isMeterDataSource, + }, + ); + + const { + data: fieldKeysData, + isLoading: isLoadingFieldKeys, + } = useGetQueryKeySuggestions( + { + searchText: inputValue, + signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType], + signalSource: 'meter', + }, + { + queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue], + enabled: !!signal && isMeterDataSource, }, ); @@ -77,13 +99,33 @@ function OtherFilters({ let filterAttributes; if (isLogDataSource) { filterAttributes = suggestionsData?.payload?.attributes || []; + } else if (isMeterDataSource) { + const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values( + fieldKeysData?.data?.data?.keys || {}, + )?.flat(); + filterAttributes = fieldKeys.map( + (attr) => + ({ + key: attr.name, + dataType: attr.fieldDataType, + type: attr.fieldContext, + signal: attr.signal, + } as BaseAutocompleteData), + ); } else { filterAttributes = aggregateKeysData?.payload?.attributeKeys || []; } return filterAttributes?.filter( (attr) => !addedFilters.some((filter) => filter.key === attr.key), ); - }, [suggestionsData, aggregateKeysData, addedFilters, isLogDataSource]); + }, [ + suggestionsData, + aggregateKeysData, + addedFilters, + isLogDataSource, + fieldKeysData, + isMeterDataSource, + ]); const handleAddFilter = (filter: FilterType): void => { setAddedFilters((prev) => [ @@ -99,7 +141,8 @@ function OtherFilters({ }; const renderFilters = (): React.ReactNode => { - const isLoading = isFetchingSuggestions || isFetchingAggregateKeys; + const isLoading = + isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys; if (isLoading) return ; if (!otherFilters?.length) return
No values found
; diff --git a/frontend/src/components/YAxisUnitSelector/YAxisUnitSelector.tsx b/frontend/src/components/YAxisUnitSelector/YAxisUnitSelector.tsx new file mode 100644 index 000000000000..52f160fd4008 --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/YAxisUnitSelector.tsx @@ -0,0 +1,63 @@ +import './styles.scss'; + +import { Select } from 'antd'; +import { DefaultOptionType } from 'antd/es/select'; + +import { UniversalYAxisUnitMappings, Y_AXIS_CATEGORIES } from './constants'; +import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types'; +import { mapMetricUnitToUniversalUnit } from './utils'; + +function YAxisUnitSelector({ + value, + onChange, + placeholder = 'Please select a unit', + loading = false, +}: YAxisUnitSelectorProps): JSX.Element { + const universalUnit = mapMetricUnitToUniversalUnit(value); + + const handleSearch = ( + searchTerm: string, + currentOption: DefaultOptionType | undefined, + ): boolean => { + if (!currentOption?.value) return false; + + const search = searchTerm.toLowerCase(); + const unitId = currentOption.value.toString().toLowerCase(); + const unitLabel = currentOption.children?.toString().toLowerCase() || ''; + + // Check label and id + if (unitId.includes(search) || unitLabel.includes(search)) return true; + + // Check aliases (from the mapping) using array iteration + const aliases = Array.from( + UniversalYAxisUnitMappings[currentOption.value as UniversalYAxisUnit] ?? [], + ); + + return aliases.some((alias) => alias.toLowerCase().includes(search)); + }; + + return ( +
+ +
+ ); +} + +export default YAxisUnitSelector; diff --git a/frontend/src/components/YAxisUnitSelector/__tests__/YAxisUnitSelector.test.tsx b/frontend/src/components/YAxisUnitSelector/__tests__/YAxisUnitSelector.test.tsx new file mode 100644 index 000000000000..5449b1ebf1cb --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/__tests__/YAxisUnitSelector.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import YAxisUnitSelector from '../YAxisUnitSelector'; + +describe('YAxisUnitSelector', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders with default placeholder', () => { + render(); + expect(screen.getByText('Please select a unit')).toBeInTheDocument(); + }); + + it('renders with custom placeholder', () => { + render( + , + ); + expect(screen.queryByText('Custom placeholder')).toBeInTheDocument(); + }); + + it('calls onChange when a value is selected', () => { + render(); + const select = screen.getByRole('combobox'); + + fireEvent.mouseDown(select); + const option = screen.getByText('Bytes (B)'); + fireEvent.click(option); + + expect(mockOnChange).toHaveBeenCalledWith('By', { + children: 'Bytes (B)', + key: 'By', + value: 'By', + }); + }); + + it('filters options based on search input', () => { + render(); + const select = screen.getByRole('combobox'); + + fireEvent.mouseDown(select); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'byte' } }); + + expect(screen.getByText('Bytes/sec')).toBeInTheDocument(); + }); + + it('shows all categories and their units', () => { + render(); + const select = screen.getByRole('combobox'); + + fireEvent.mouseDown(select); + + // Check for category headers + expect(screen.getByText('Data')).toBeInTheDocument(); + expect(screen.getByText('Time')).toBeInTheDocument(); + + // Check for some common units + expect(screen.getByText('Bytes (B)')).toBeInTheDocument(); + expect(screen.getByText('Seconds (s)')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/YAxisUnitSelector/__tests__/utils.test.tsx b/frontend/src/components/YAxisUnitSelector/__tests__/utils.test.tsx new file mode 100644 index 000000000000..f93d5fea9035 --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/__tests__/utils.test.tsx @@ -0,0 +1,39 @@ +import { + getUniversalNameFromMetricUnit, + mapMetricUnitToUniversalUnit, +} from '../utils'; + +describe('YAxisUnitSelector utils', () => { + describe('mapMetricUnitToUniversalUnit', () => { + it('maps known units correctly', () => { + expect(mapMetricUnitToUniversalUnit('bytes')).toBe('By'); + expect(mapMetricUnitToUniversalUnit('seconds')).toBe('s'); + expect(mapMetricUnitToUniversalUnit('bytes_per_second')).toBe('By/s'); + }); + + it('returns null or self for unknown units', () => { + expect(mapMetricUnitToUniversalUnit('unknown_unit')).toBe('unknown_unit'); + expect(mapMetricUnitToUniversalUnit('')).toBe(null); + expect(mapMetricUnitToUniversalUnit(undefined)).toBe(null); + }); + }); + + describe('getUniversalNameFromMetricUnit', () => { + it('returns human readable names for known units', () => { + expect(getUniversalNameFromMetricUnit('bytes')).toBe('Bytes (B)'); + expect(getUniversalNameFromMetricUnit('seconds')).toBe('Seconds (s)'); + expect(getUniversalNameFromMetricUnit('bytes_per_second')).toBe('Bytes/sec'); + }); + + it('returns original unit for unknown units', () => { + expect(getUniversalNameFromMetricUnit('unknown_unit')).toBe('unknown_unit'); + expect(getUniversalNameFromMetricUnit('')).toBe('-'); + expect(getUniversalNameFromMetricUnit(undefined)).toBe('-'); + }); + + it('handles case variations', () => { + expect(getUniversalNameFromMetricUnit('bytes')).toBe('Bytes (B)'); + expect(getUniversalNameFromMetricUnit('s')).toBe('Seconds (s)'); + }); + }); +}); diff --git a/frontend/src/components/YAxisUnitSelector/constants.ts b/frontend/src/components/YAxisUnitSelector/constants.ts new file mode 100644 index 000000000000..2ddffe0bd8e8 --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/constants.ts @@ -0,0 +1,627 @@ +import { UniversalYAxisUnit, YAxisUnit } from './types'; + +// Mapping of universal y-axis units to their AWS, UCUM, and OpenMetrics equivalents +export const UniversalYAxisUnitMappings: Record< + UniversalYAxisUnit, + Set +> = { + // Time + [UniversalYAxisUnit.NANOSECONDS]: new Set([ + YAxisUnit.UCUM_NANOSECONDS, + YAxisUnit.OPEN_METRICS_NANOSECONDS, + ]), + [UniversalYAxisUnit.MICROSECONDS]: new Set([ + YAxisUnit.AWS_MICROSECONDS, + YAxisUnit.UCUM_MICROSECONDS, + YAxisUnit.OPEN_METRICS_MICROSECONDS, + ]), + [UniversalYAxisUnit.MILLISECONDS]: new Set([ + YAxisUnit.AWS_MILLISECONDS, + YAxisUnit.UCUM_MILLISECONDS, + YAxisUnit.OPEN_METRICS_MILLISECONDS, + ]), + [UniversalYAxisUnit.SECONDS]: new Set([ + YAxisUnit.AWS_SECONDS, + YAxisUnit.UCUM_SECONDS, + YAxisUnit.OPEN_METRICS_SECONDS, + ]), + [UniversalYAxisUnit.MINUTES]: new Set([ + YAxisUnit.UCUM_MINUTES, + YAxisUnit.OPEN_METRICS_MINUTES, + ]), + [UniversalYAxisUnit.HOURS]: new Set([ + YAxisUnit.UCUM_HOURS, + YAxisUnit.OPEN_METRICS_HOURS, + ]), + [UniversalYAxisUnit.DAYS]: new Set([ + YAxisUnit.UCUM_DAYS, + YAxisUnit.OPEN_METRICS_DAYS, + ]), + [UniversalYAxisUnit.WEEKS]: new Set([YAxisUnit.UCUM_WEEKS]), + + // Data + [UniversalYAxisUnit.BYTES]: new Set([ + YAxisUnit.AWS_BYTES, + YAxisUnit.UCUM_BYTES, + YAxisUnit.OPEN_METRICS_BYTES, + ]), + [UniversalYAxisUnit.KILOBYTES]: new Set([ + YAxisUnit.AWS_KILOBYTES, + YAxisUnit.UCUM_KILOBYTES, + YAxisUnit.OPEN_METRICS_KILOBYTES, + ]), + [UniversalYAxisUnit.MEGABYTES]: new Set([ + YAxisUnit.AWS_MEGABYTES, + YAxisUnit.UCUM_MEGABYTES, + YAxisUnit.OPEN_METRICS_MEGABYTES, + ]), + [UniversalYAxisUnit.GIGABYTES]: new Set([ + YAxisUnit.AWS_GIGABYTES, + YAxisUnit.UCUM_GIGABYTES, + YAxisUnit.OPEN_METRICS_GIGABYTES, + ]), + [UniversalYAxisUnit.TERABYTES]: new Set([ + YAxisUnit.AWS_TERABYTES, + YAxisUnit.UCUM_TERABYTES, + YAxisUnit.OPEN_METRICS_TERABYTES, + ]), + [UniversalYAxisUnit.PETABYTES]: new Set([ + YAxisUnit.AWS_PETABYTES, + YAxisUnit.UCUM_PEBIBYTES, + YAxisUnit.OPEN_METRICS_PEBIBYTES, + ]), + [UniversalYAxisUnit.EXABYTES]: new Set([ + YAxisUnit.AWS_EXABYTES, + YAxisUnit.UCUM_EXABYTES, + YAxisUnit.OPEN_METRICS_EXABYTES, + ]), + [UniversalYAxisUnit.ZETTABYTES]: new Set([ + YAxisUnit.AWS_ZETTABYTES, + YAxisUnit.UCUM_ZETTABYTES, + YAxisUnit.OPEN_METRICS_ZETTABYTES, + ]), + [UniversalYAxisUnit.YOTTABYTES]: new Set([ + YAxisUnit.AWS_YOTTABYTES, + YAxisUnit.UCUM_YOTTABYTES, + YAxisUnit.OPEN_METRICS_YOTTABYTES, + ]), + + // Data Rate + [UniversalYAxisUnit.BYTES_SECOND]: new Set([ + YAxisUnit.AWS_BYTES_SECOND, + YAxisUnit.UCUM_BYTES_SECOND, + YAxisUnit.OPEN_METRICS_BYTES_SECOND, + ]), + [UniversalYAxisUnit.KILOBYTES_SECOND]: new Set([ + YAxisUnit.AWS_KILOBYTES_SECOND, + YAxisUnit.UCUM_KILOBYTES_SECOND, + YAxisUnit.OPEN_METRICS_KILOBYTES_SECOND, + ]), + [UniversalYAxisUnit.MEGABYTES_SECOND]: new Set([ + YAxisUnit.AWS_MEGABYTES_SECOND, + YAxisUnit.UCUM_MEGABYTES_SECOND, + YAxisUnit.OPEN_METRICS_MEGABYTES_SECOND, + ]), + [UniversalYAxisUnit.GIGABYTES_SECOND]: new Set([ + YAxisUnit.AWS_GIGABYTES_SECOND, + YAxisUnit.UCUM_GIGABYTES_SECOND, + YAxisUnit.OPEN_METRICS_GIGABYTES_SECOND, + ]), + [UniversalYAxisUnit.TERABYTES_SECOND]: new Set([ + YAxisUnit.AWS_TERABYTES_SECOND, + YAxisUnit.UCUM_TERABYTES_SECOND, + YAxisUnit.OPEN_METRICS_TERABYTES_SECOND, + ]), + [UniversalYAxisUnit.PETABYTES_SECOND]: new Set([ + YAxisUnit.AWS_PETABYTES_SECOND, + YAxisUnit.UCUM_PETABYTES_SECOND, + YAxisUnit.OPEN_METRICS_PETABYTES_SECOND, + ]), + [UniversalYAxisUnit.EXABYTES_SECOND]: new Set([ + YAxisUnit.AWS_EXABYTES_SECOND, + YAxisUnit.UCUM_EXABYTES_SECOND, + YAxisUnit.OPEN_METRICS_EXABYTES_SECOND, + ]), + [UniversalYAxisUnit.ZETTABYTES_SECOND]: new Set([ + YAxisUnit.AWS_ZETTABYTES_SECOND, + YAxisUnit.UCUM_ZETTABYTES_SECOND, + YAxisUnit.OPEN_METRICS_ZETTABYTES_SECOND, + ]), + [UniversalYAxisUnit.YOTTABYTES_SECOND]: new Set([ + YAxisUnit.AWS_YOTTABYTES_SECOND, + YAxisUnit.UCUM_YOTTABYTES_SECOND, + YAxisUnit.OPEN_METRICS_YOTTABYTES_SECOND, + ]), + + // Bits + [UniversalYAxisUnit.BITS]: new Set([ + YAxisUnit.AWS_BITS, + YAxisUnit.UCUM_BITS, + YAxisUnit.OPEN_METRICS_BITS, + ]), + [UniversalYAxisUnit.KILOBITS]: new Set([ + YAxisUnit.AWS_KILOBITS, + YAxisUnit.UCUM_KILOBITS, + YAxisUnit.OPEN_METRICS_KILOBITS, + ]), + [UniversalYAxisUnit.MEGABITS]: new Set([ + YAxisUnit.AWS_MEGABITS, + YAxisUnit.UCUM_MEGABITS, + YAxisUnit.OPEN_METRICS_MEGABITS, + ]), + [UniversalYAxisUnit.GIGABITS]: new Set([ + YAxisUnit.AWS_GIGABITS, + YAxisUnit.UCUM_GIGABITS, + YAxisUnit.OPEN_METRICS_GIGABITS, + ]), + [UniversalYAxisUnit.TERABITS]: new Set([ + YAxisUnit.AWS_TERABITS, + YAxisUnit.UCUM_TERABITS, + YAxisUnit.OPEN_METRICS_TERABITS, + ]), + [UniversalYAxisUnit.PETABITS]: new Set([ + YAxisUnit.AWS_PETABITS, + YAxisUnit.UCUM_PETABITS, + YAxisUnit.OPEN_METRICS_PETABITS, + ]), + [UniversalYAxisUnit.EXABITS]: new Set([ + YAxisUnit.AWS_EXABITS, + YAxisUnit.UCUM_EXABITS, + YAxisUnit.OPEN_METRICS_EXABITS, + ]), + [UniversalYAxisUnit.ZETTABITS]: new Set([ + YAxisUnit.AWS_ZETTABITS, + YAxisUnit.UCUM_ZETTABITS, + YAxisUnit.OPEN_METRICS_ZETTABITS, + ]), + [UniversalYAxisUnit.YOTTABITS]: new Set([ + YAxisUnit.AWS_YOTTABITS, + YAxisUnit.UCUM_YOTTABITS, + YAxisUnit.OPEN_METRICS_YOTTABITS, + ]), + + // Bit Rate + [UniversalYAxisUnit.BITS_SECOND]: new Set([ + YAxisUnit.AWS_BITS_SECOND, + YAxisUnit.UCUM_BITS_SECOND, + YAxisUnit.OPEN_METRICS_BITS_SECOND, + ]), + [UniversalYAxisUnit.KILOBITS_SECOND]: new Set([ + YAxisUnit.AWS_KILOBITS_SECOND, + YAxisUnit.UCUM_KILOBITS_SECOND, + YAxisUnit.OPEN_METRICS_KILOBITS_SECOND, + ]), + [UniversalYAxisUnit.MEGABITS_SECOND]: new Set([ + YAxisUnit.AWS_MEGABITS_SECOND, + YAxisUnit.UCUM_MEGABITS_SECOND, + YAxisUnit.OPEN_METRICS_MEGABITS_SECOND, + ]), + [UniversalYAxisUnit.GIGABITS_SECOND]: new Set([ + YAxisUnit.AWS_GIGABITS_SECOND, + YAxisUnit.UCUM_GIGABITS_SECOND, + YAxisUnit.OPEN_METRICS_GIGABITS_SECOND, + ]), + [UniversalYAxisUnit.TERABITS_SECOND]: new Set([ + YAxisUnit.AWS_TERABITS_SECOND, + YAxisUnit.UCUM_TERABITS_SECOND, + YAxisUnit.OPEN_METRICS_TERABITS_SECOND, + ]), + [UniversalYAxisUnit.PETABITS_SECOND]: new Set([ + YAxisUnit.AWS_PETABITS_SECOND, + YAxisUnit.UCUM_PETABITS_SECOND, + YAxisUnit.OPEN_METRICS_PETABITS_SECOND, + ]), + [UniversalYAxisUnit.EXABITS_SECOND]: new Set([ + YAxisUnit.AWS_EXABITS_SECOND, + YAxisUnit.UCUM_EXABITS_SECOND, + YAxisUnit.OPEN_METRICS_EXABITS_SECOND, + ]), + [UniversalYAxisUnit.ZETTABITS_SECOND]: new Set([ + YAxisUnit.AWS_ZETTABITS_SECOND, + YAxisUnit.UCUM_ZETTABITS_SECOND, + YAxisUnit.OPEN_METRICS_ZETTABITS_SECOND, + ]), + [UniversalYAxisUnit.YOTTABITS_SECOND]: new Set([ + YAxisUnit.AWS_YOTTABITS_SECOND, + YAxisUnit.UCUM_YOTTABITS_SECOND, + YAxisUnit.OPEN_METRICS_YOTTABITS_SECOND, + ]), + + // Count + [UniversalYAxisUnit.COUNT]: new Set([ + YAxisUnit.AWS_COUNT, + YAxisUnit.UCUM_COUNT, + YAxisUnit.OPEN_METRICS_COUNT, + ]), + [UniversalYAxisUnit.COUNT_SECOND]: new Set([ + YAxisUnit.AWS_COUNT_SECOND, + YAxisUnit.UCUM_COUNT_SECOND, + YAxisUnit.OPEN_METRICS_COUNT_SECOND, + ]), + + // Percent + [UniversalYAxisUnit.PERCENT]: new Set([ + YAxisUnit.AWS_PERCENT, + YAxisUnit.UCUM_PERCENT, + YAxisUnit.OPEN_METRICS_PERCENT, + ]), + [UniversalYAxisUnit.NONE]: new Set([ + YAxisUnit.AWS_NONE, + YAxisUnit.UCUM_NONE, + YAxisUnit.OPEN_METRICS_NONE, + ]), + [UniversalYAxisUnit.PERCENT_UNIT]: new Set([ + YAxisUnit.OPEN_METRICS_PERCENT_UNIT, + ]), + + // Count Rate + [UniversalYAxisUnit.COUNT_MINUTE]: new Set([ + YAxisUnit.UCUM_COUNTS_MINUTE, + YAxisUnit.OPEN_METRICS_COUNTS_MINUTE, + ]), + [UniversalYAxisUnit.OPS_SECOND]: new Set([ + YAxisUnit.UCUM_OPS_SECOND, + YAxisUnit.OPEN_METRICS_OPS_SECOND, + ]), + [UniversalYAxisUnit.OPS_MINUTE]: new Set([ + YAxisUnit.UCUM_OPS_MINUTE, + YAxisUnit.OPEN_METRICS_OPS_MINUTE, + ]), + [UniversalYAxisUnit.REQUESTS_SECOND]: new Set([ + YAxisUnit.UCUM_REQUESTS_SECOND, + YAxisUnit.OPEN_METRICS_REQUESTS_SECOND, + ]), + [UniversalYAxisUnit.REQUESTS_MINUTE]: new Set([ + YAxisUnit.UCUM_REQUESTS_MINUTE, + YAxisUnit.OPEN_METRICS_REQUESTS_MINUTE, + ]), + [UniversalYAxisUnit.READS_SECOND]: new Set([ + YAxisUnit.UCUM_READS_SECOND, + YAxisUnit.OPEN_METRICS_READS_SECOND, + ]), + [UniversalYAxisUnit.WRITES_SECOND]: new Set([ + YAxisUnit.UCUM_WRITES_SECOND, + YAxisUnit.OPEN_METRICS_WRITES_SECOND, + ]), + [UniversalYAxisUnit.READS_MINUTE]: new Set([ + YAxisUnit.UCUM_READS_MINUTE, + YAxisUnit.OPEN_METRICS_READS_MINUTE, + ]), + [UniversalYAxisUnit.WRITES_MINUTE]: new Set([ + YAxisUnit.UCUM_WRITES_MINUTE, + YAxisUnit.OPEN_METRICS_WRITES_MINUTE, + ]), + [UniversalYAxisUnit.IOOPS_SECOND]: new Set([ + YAxisUnit.UCUM_IOPS_SECOND, + YAxisUnit.OPEN_METRICS_IOPS_SECOND, + ]), +}; + +// Mapping of universal y-axis units to their display labels +export const Y_AXIS_UNIT_NAMES: Record = { + [UniversalYAxisUnit.SECONDS]: 'Seconds (s)', + [UniversalYAxisUnit.MILLISECONDS]: 'Milliseconds (ms)', + [UniversalYAxisUnit.MICROSECONDS]: 'Microseconds (µs)', + [UniversalYAxisUnit.BYTES]: 'Bytes (B)', + [UniversalYAxisUnit.KILOBYTES]: 'Kilobytes (KB)', + [UniversalYAxisUnit.MEGABYTES]: 'Megabytes (MB)', + [UniversalYAxisUnit.GIGABYTES]: 'Gigabytes (GB)', + [UniversalYAxisUnit.TERABYTES]: 'Terabytes (TB)', + [UniversalYAxisUnit.PETABYTES]: 'Petabytes (PB)', + [UniversalYAxisUnit.EXABYTES]: 'Exabytes (EB)', + [UniversalYAxisUnit.ZETTABYTES]: 'Zettabytes (ZB)', + [UniversalYAxisUnit.YOTTABYTES]: 'Yottabytes (YB)', + [UniversalYAxisUnit.BITS]: 'Bits (b)', + [UniversalYAxisUnit.KILOBITS]: 'Kilobits (Kb)', + [UniversalYAxisUnit.MEGABITS]: 'Megabits (Mb)', + [UniversalYAxisUnit.GIGABITS]: 'Gigabits (Gb)', + [UniversalYAxisUnit.TERABITS]: 'Terabits (Tb)', + [UniversalYAxisUnit.PETABITS]: 'Petabits (Pb)', + [UniversalYAxisUnit.EXABITS]: 'Exabits (Eb)', + [UniversalYAxisUnit.ZETTABITS]: 'Zettabits (Zb)', + [UniversalYAxisUnit.YOTTABITS]: 'Yottabits (Yb)', + [UniversalYAxisUnit.BYTES_SECOND]: 'Bytes/sec', + [UniversalYAxisUnit.KILOBYTES_SECOND]: 'Kilobytes/sec', + [UniversalYAxisUnit.MEGABYTES_SECOND]: 'Megabytes/sec', + [UniversalYAxisUnit.GIGABYTES_SECOND]: 'Gigabytes/sec', + [UniversalYAxisUnit.TERABYTES_SECOND]: 'Terabytes/sec', + [UniversalYAxisUnit.PETABYTES_SECOND]: 'Petabytes/sec', + [UniversalYAxisUnit.EXABYTES_SECOND]: 'Exabytes/sec', + [UniversalYAxisUnit.ZETTABYTES_SECOND]: 'Zettabytes/sec', + [UniversalYAxisUnit.YOTTABYTES_SECOND]: 'Yottabytes/sec', + [UniversalYAxisUnit.BITS_SECOND]: 'Bits/sec', + [UniversalYAxisUnit.KILOBITS_SECOND]: 'Kilobits/sec', + [UniversalYAxisUnit.MEGABITS_SECOND]: 'Megabits/sec', + [UniversalYAxisUnit.GIGABITS_SECOND]: 'Gigabits/sec', + [UniversalYAxisUnit.TERABITS_SECOND]: 'Terabits/sec', + [UniversalYAxisUnit.PETABITS_SECOND]: 'Petabits/sec', + [UniversalYAxisUnit.EXABITS_SECOND]: 'Exabits/sec', + [UniversalYAxisUnit.ZETTABITS_SECOND]: 'Zettabits/sec', + [UniversalYAxisUnit.YOTTABITS_SECOND]: 'Yottabits/sec', + [UniversalYAxisUnit.COUNT]: 'Count', + [UniversalYAxisUnit.COUNT_SECOND]: 'Count/sec', + [UniversalYAxisUnit.PERCENT]: 'Percent (0 - 100)', + [UniversalYAxisUnit.NONE]: 'None', + [UniversalYAxisUnit.WEEKS]: 'Weeks', + [UniversalYAxisUnit.DAYS]: 'Days', + [UniversalYAxisUnit.HOURS]: 'Hours', + [UniversalYAxisUnit.MINUTES]: 'Minutes', + [UniversalYAxisUnit.NANOSECONDS]: 'Nanoseconds', + [UniversalYAxisUnit.COUNT_MINUTE]: 'Count/min', + [UniversalYAxisUnit.OPS_SECOND]: 'Ops/sec', + [UniversalYAxisUnit.OPS_MINUTE]: 'Ops/min', + [UniversalYAxisUnit.REQUESTS_SECOND]: 'Requests/sec', + [UniversalYAxisUnit.REQUESTS_MINUTE]: 'Requests/min', + [UniversalYAxisUnit.READS_SECOND]: 'Reads/sec', + [UniversalYAxisUnit.WRITES_SECOND]: 'Writes/sec', + [UniversalYAxisUnit.READS_MINUTE]: 'Reads/min', + [UniversalYAxisUnit.WRITES_MINUTE]: 'Writes/min', + [UniversalYAxisUnit.IOOPS_SECOND]: 'IOPS/sec', + [UniversalYAxisUnit.PERCENT_UNIT]: 'Percent (0.0 - 1.0)', +}; + +// Splitting the universal y-axis units into categories +export const Y_AXIS_CATEGORIES = [ + { + name: 'Time', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.SECONDS], + id: UniversalYAxisUnit.SECONDS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MILLISECONDS], + id: UniversalYAxisUnit.MILLISECONDS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MICROSECONDS], + id: UniversalYAxisUnit.MICROSECONDS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.NANOSECONDS], + id: UniversalYAxisUnit.NANOSECONDS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MINUTES], + id: UniversalYAxisUnit.MINUTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.HOURS], + id: UniversalYAxisUnit.HOURS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.DAYS], + id: UniversalYAxisUnit.DAYS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WEEKS], + id: UniversalYAxisUnit.WEEKS, + }, + ], + }, + { + name: 'Data', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES], + id: UniversalYAxisUnit.BYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBYTES], + id: UniversalYAxisUnit.KILOBYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABYTES], + id: UniversalYAxisUnit.MEGABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABYTES], + id: UniversalYAxisUnit.GIGABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABYTES], + id: UniversalYAxisUnit.TERABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABYTES], + id: UniversalYAxisUnit.PETABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES], + id: UniversalYAxisUnit.EXABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES], + id: UniversalYAxisUnit.ZETTABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES], + id: UniversalYAxisUnit.YOTTABYTES, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BITS], + id: UniversalYAxisUnit.BITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS], + id: UniversalYAxisUnit.KILOBITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS], + id: UniversalYAxisUnit.MEGABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS], + id: UniversalYAxisUnit.GIGABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS], + id: UniversalYAxisUnit.TERABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS], + id: UniversalYAxisUnit.PETABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS], + id: UniversalYAxisUnit.EXABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS], + id: UniversalYAxisUnit.ZETTABITS, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS], + id: UniversalYAxisUnit.YOTTABITS, + }, + ], + }, + { + name: 'Data Rate', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES_SECOND], + id: UniversalYAxisUnit.BYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBYTES_SECOND], + id: UniversalYAxisUnit.KILOBYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABYTES_SECOND], + id: UniversalYAxisUnit.MEGABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABYTES_SECOND], + id: UniversalYAxisUnit.GIGABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABYTES_SECOND], + id: UniversalYAxisUnit.TERABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABYTES_SECOND], + id: UniversalYAxisUnit.PETABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES_SECOND], + id: UniversalYAxisUnit.EXABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES_SECOND], + id: UniversalYAxisUnit.ZETTABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES_SECOND], + id: UniversalYAxisUnit.YOTTABYTES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BITS_SECOND], + id: UniversalYAxisUnit.BITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS_SECOND], + id: UniversalYAxisUnit.KILOBITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS_SECOND], + id: UniversalYAxisUnit.MEGABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS_SECOND], + id: UniversalYAxisUnit.GIGABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS_SECOND], + id: UniversalYAxisUnit.TERABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS_SECOND], + id: UniversalYAxisUnit.PETABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS_SECOND], + id: UniversalYAxisUnit.EXABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS_SECOND], + id: UniversalYAxisUnit.ZETTABITS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS_SECOND], + id: UniversalYAxisUnit.YOTTABITS_SECOND, + }, + ], + }, + { + name: 'Count', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT], + id: UniversalYAxisUnit.COUNT, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT_SECOND], + id: UniversalYAxisUnit.COUNT_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT_MINUTE], + id: UniversalYAxisUnit.COUNT_MINUTE, + }, + ], + }, + { + name: 'Operations', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.OPS_SECOND], + id: UniversalYAxisUnit.OPS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.OPS_MINUTE], + id: UniversalYAxisUnit.OPS_MINUTE, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.REQUESTS_SECOND], + id: UniversalYAxisUnit.REQUESTS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.REQUESTS_MINUTE], + id: UniversalYAxisUnit.REQUESTS_MINUTE, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.READS_SECOND], + id: UniversalYAxisUnit.READS_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WRITES_SECOND], + id: UniversalYAxisUnit.WRITES_SECOND, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.READS_MINUTE], + id: UniversalYAxisUnit.READS_MINUTE, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WRITES_MINUTE], + id: UniversalYAxisUnit.WRITES_MINUTE, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.IOOPS_SECOND], + id: UniversalYAxisUnit.IOOPS_SECOND, + }, + ], + }, + { + name: 'Percentage', + units: [ + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT], + id: UniversalYAxisUnit.PERCENT, + }, + { + name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT_UNIT], + id: UniversalYAxisUnit.PERCENT_UNIT, + }, + ], + }, +]; diff --git a/frontend/src/components/YAxisUnitSelector/index.ts b/frontend/src/components/YAxisUnitSelector/index.ts new file mode 100644 index 000000000000..a1d998e51b55 --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/index.ts @@ -0,0 +1,3 @@ +import YAxisUnitSelector from './YAxisUnitSelector'; + +export default YAxisUnitSelector; diff --git a/frontend/src/components/YAxisUnitSelector/styles.scss b/frontend/src/components/YAxisUnitSelector/styles.scss new file mode 100644 index 000000000000..1091d9dc5e61 --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/styles.scss @@ -0,0 +1,5 @@ +.y-axis-unit-selector-component { + .ant-select { + width: 220px; + } +} diff --git a/frontend/src/components/YAxisUnitSelector/types.ts b/frontend/src/components/YAxisUnitSelector/types.ts new file mode 100644 index 000000000000..46ddc3d321ed --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/types.ts @@ -0,0 +1,365 @@ +export interface YAxisUnitSelectorProps { + value: string | undefined; + onChange: (value: UniversalYAxisUnit) => void; + placeholder?: string; + loading?: boolean; + disabled?: boolean; +} + +export enum UniversalYAxisUnit { + // Time + WEEKS = 'wk', + DAYS = 'd', + HOURS = 'h', + MINUTES = 'min', + SECONDS = 's', + MICROSECONDS = 'us', + MILLISECONDS = 'ms', + NANOSECONDS = 'ns', + + // Data + BYTES = 'By', + KILOBYTES = 'kBy', + MEGABYTES = 'MBy', + GIGABYTES = 'GBy', + TERABYTES = 'TBy', + PETABYTES = 'PBy', + EXABYTES = 'EBy', + ZETTABYTES = 'ZBy', + YOTTABYTES = 'YBy', + + // Data Rate + BYTES_SECOND = 'By/s', + KILOBYTES_SECOND = 'kBy/s', + MEGABYTES_SECOND = 'MBy/s', + GIGABYTES_SECOND = 'GBy/s', + TERABYTES_SECOND = 'TBy/s', + PETABYTES_SECOND = 'PBy/s', + EXABYTES_SECOND = 'EBy/s', + ZETTABYTES_SECOND = 'ZBy/s', + YOTTABYTES_SECOND = 'YBy/s', + + // Bits + BITS = 'bit', + KILOBITS = 'kbit', + MEGABITS = 'Mbit', + GIGABITS = 'Gbit', + TERABITS = 'Tbit', + PETABITS = 'Pbit', + EXABITS = 'Ebit', + ZETTABITS = 'Zbit', + YOTTABITS = 'Ybit', + + // Bit Rate + BITS_SECOND = 'bit/s', + KILOBITS_SECOND = 'kbit/s', + MEGABITS_SECOND = 'Mbit/s', + GIGABITS_SECOND = 'Gbit/s', + TERABITS_SECOND = 'Tbit/s', + PETABITS_SECOND = 'Pbit/s', + EXABITS_SECOND = 'Ebit/s', + ZETTABITS_SECOND = 'Zbit/s', + YOTTABITS_SECOND = 'Ybit/s', + + // Count + COUNT = '{count}', + COUNT_SECOND = '{count}/s', + COUNT_MINUTE = '{count}/min', + + // Operations + OPS_SECOND = '{ops}/s', + OPS_MINUTE = '{ops}/min', + + // Requests + REQUESTS_SECOND = '{req}/s', + REQUESTS_MINUTE = '{req}/min', + + // Reads/Writes + READS_SECOND = '{read}/s', + WRITES_SECOND = '{write}/s', + READS_MINUTE = '{read}/min', + WRITES_MINUTE = '{write}/min', + + // IO Operations + IOOPS_SECOND = '{iops}/s', + + // Percent + PERCENT = '%', + PERCENT_UNIT = 'percentunit', + NONE = '1', +} + +export enum YAxisUnit { + AWS_SECONDS = 'Seconds', + UCUM_SECONDS = 's', + OPEN_METRICS_SECONDS = 'seconds', + + AWS_MICROSECONDS = 'Microseconds', + UCUM_MICROSECONDS = 'us', + OPEN_METRICS_MICROSECONDS = 'microseconds', + + AWS_MILLISECONDS = 'Milliseconds', + UCUM_MILLISECONDS = 'ms', + OPEN_METRICS_MILLISECONDS = 'milliseconds', + + AWS_BYTES = 'Bytes', + UCUM_BYTES = 'By', + OPEN_METRICS_BYTES = 'bytes', + + AWS_KILOBYTES = 'Kilobytes', + UCUM_KILOBYTES = 'kBy', + OPEN_METRICS_KILOBYTES = 'kilobytes', + + AWS_MEGABYTES = 'Megabytes', + UCUM_MEGABYTES = 'MBy', + OPEN_METRICS_MEGABYTES = 'megabytes', + + AWS_GIGABYTES = 'Gigabytes', + UCUM_GIGABYTES = 'GBy', + OPEN_METRICS_GIGABYTES = 'gigabytes', + + AWS_TERABYTES = 'Terabytes', + UCUM_TERABYTES = 'TBy', + OPEN_METRICS_TERABYTES = 'terabytes', + + AWS_PETABYTES = 'Petabytes', + UCUM_PETABYTES = 'PBy', + OPEN_METRICS_PETABYTES = 'petabytes', + + AWS_EXABYTES = 'Exabytes', + UCUM_EXABYTES = 'EBy', + OPEN_METRICS_EXABYTES = 'exabytes', + + AWS_ZETTABYTES = 'Zettabytes', + UCUM_ZETTABYTES = 'ZBy', + OPEN_METRICS_ZETTABYTES = 'zettabytes', + + AWS_YOTTABYTES = 'Yottabytes', + UCUM_YOTTABYTES = 'YBy', + OPEN_METRICS_YOTTABYTES = 'yottabytes', + + AWS_BYTES_SECOND = 'Bytes/Second', + UCUM_BYTES_SECOND = 'By/s', + OPEN_METRICS_BYTES_SECOND = 'bytes_per_second', + + AWS_KILOBYTES_SECOND = 'Kilobytes/Second', + UCUM_KILOBYTES_SECOND = 'kBy/s', + OPEN_METRICS_KILOBYTES_SECOND = 'kilobytes_per_second', + + AWS_MEGABYTES_SECOND = 'Megabytes/Second', + UCUM_MEGABYTES_SECOND = 'MBy/s', + OPEN_METRICS_MEGABYTES_SECOND = 'megabytes_per_second', + + AWS_GIGABYTES_SECOND = 'Gigabytes/Second', + UCUM_GIGABYTES_SECOND = 'GBy/s', + OPEN_METRICS_GIGABYTES_SECOND = 'gigabytes_per_second', + + AWS_TERABYTES_SECOND = 'Terabytes/Second', + UCUM_TERABYTES_SECOND = 'TBy/s', + OPEN_METRICS_TERABYTES_SECOND = 'terabytes_per_second', + + AWS_PETABYTES_SECOND = 'Petabytes/Second', + UCUM_PETABYTES_SECOND = 'PBy/s', + OPEN_METRICS_PETABYTES_SECOND = 'petabytes_per_second', + + AWS_EXABYTES_SECOND = 'Exabytes/Second', + UCUM_EXABYTES_SECOND = 'EBy/s', + OPEN_METRICS_EXABYTES_SECOND = 'exabytes_per_second', + + AWS_ZETTABYTES_SECOND = 'Zettabytes/Second', + UCUM_ZETTABYTES_SECOND = 'ZBy/s', + OPEN_METRICS_ZETTABYTES_SECOND = 'zettabytes_per_second', + + AWS_YOTTABYTES_SECOND = 'Yottabytes/Second', + UCUM_YOTTABYTES_SECOND = 'YBy/s', + OPEN_METRICS_YOTTABYTES_SECOND = 'yottabytes_per_second', + + AWS_BITS = 'Bits', + UCUM_BITS = 'bit', + OPEN_METRICS_BITS = 'bits', + + AWS_KILOBITS = 'Kilobits', + UCUM_KILOBITS = 'kbit', + OPEN_METRICS_KILOBITS = 'kilobits', + + AWS_MEGABITS = 'Megabits', + UCUM_MEGABITS = 'Mbit', + OPEN_METRICS_MEGABITS = 'megabits', + + AWS_GIGABITS = 'Gigabits', + UCUM_GIGABITS = 'Gbit', + OPEN_METRICS_GIGABITS = 'gigabits', + + AWS_TERABITS = 'Terabits', + UCUM_TERABITS = 'Tbit', + OPEN_METRICS_TERABITS = 'terabits', + + AWS_PETABITS = 'Petabits', + UCUM_PETABITS = 'Pbit', + OPEN_METRICS_PETABITS = 'petabits', + + AWS_EXABITS = 'Exabits', + UCUM_EXABITS = 'Ebit', + OPEN_METRICS_EXABITS = 'exabits', + + AWS_ZETTABITS = 'Zettabits', + UCUM_ZETTABITS = 'Zbit', + OPEN_METRICS_ZETTABITS = 'zettabits', + + AWS_YOTTABITS = 'Yottabits', + UCUM_YOTTABITS = 'Ybit', + OPEN_METRICS_YOTTABITS = 'yottabits', + + AWS_BITS_SECOND = 'Bits/Second', + UCUM_BITS_SECOND = 'bit/s', + OPEN_METRICS_BITS_SECOND = 'bits_per_second', + + AWS_KILOBITS_SECOND = 'Kilobits/Second', + UCUM_KILOBITS_SECOND = 'kbit/s', + OPEN_METRICS_KILOBITS_SECOND = 'kilobits_per_second', + + AWS_MEGABITS_SECOND = 'Megabits/Second', + UCUM_MEGABITS_SECOND = 'Mbit/s', + OPEN_METRICS_MEGABITS_SECOND = 'megabits_per_second', + + AWS_GIGABITS_SECOND = 'Gigabits/Second', + UCUM_GIGABITS_SECOND = 'Gbit/s', + OPEN_METRICS_GIGABITS_SECOND = 'gigabits_per_second', + + AWS_TERABITS_SECOND = 'Terabits/Second', + UCUM_TERABITS_SECOND = 'Tbit/s', + OPEN_METRICS_TERABITS_SECOND = 'terabits_per_second', + + AWS_PETABITS_SECOND = 'Petabits/Second', + UCUM_PETABITS_SECOND = 'Pbit/s', + OPEN_METRICS_PETABITS_SECOND = 'petabits_per_second', + + AWS_EXABITS_SECOND = 'Exabits/Second', + UCUM_EXABITS_SECOND = 'Ebit/s', + OPEN_METRICS_EXABITS_SECOND = 'exabits_per_second', + + AWS_ZETTABITS_SECOND = 'Zettabits/Second', + UCUM_ZETTABITS_SECOND = 'Zbit/s', + OPEN_METRICS_ZETTABITS_SECOND = 'zettabits_per_second', + + AWS_YOTTABITS_SECOND = 'Yottabits/Second', + UCUM_YOTTABITS_SECOND = 'Ybit/s', + OPEN_METRICS_YOTTABITS_SECOND = 'yottabits_per_second', + + AWS_COUNT = 'Count', + UCUM_COUNT = '{count}', + OPEN_METRICS_COUNT = 'count', + + AWS_COUNT_SECOND = 'Count/Second', + UCUM_COUNT_SECOND = '{count}/s', + OPEN_METRICS_COUNT_SECOND = 'count_per_second', + + AWS_PERCENT = 'Percent', + UCUM_PERCENT = '%', + OPEN_METRICS_PERCENT = 'ratio', + + AWS_NONE = 'None', + UCUM_NONE = '1', + OPEN_METRICS_NONE = 'none', + + UCUM_NANOSECONDS = 'ns', + OPEN_METRICS_NANOSECONDS = 'nanoseconds', + + UCUM_MINUTES = 'min', + OPEN_METRICS_MINUTES = 'minutes', + + UCUM_HOURS = 'h', + OPEN_METRICS_HOURS = 'hours', + + UCUM_DAYS = 'd', + OPEN_METRICS_DAYS = 'days', + + UCUM_WEEKS = 'wk', + OPEN_METRICS_WEEKS = 'weeks', + + UCUM_KIBIBYTES = 'KiBy', + OPEN_METRICS_KIBIBYTES = 'kibibytes', + + UCUM_MEBIBYTES = 'MiBy', + OPEN_METRICS_MEBIBYTES = 'mebibytes', + + UCUM_GIBIBYTES = 'GiBy', + OPEN_METRICS_GIBIBYTES = 'gibibytes', + + UCUM_TEBIBYTES = 'TiBy', + OPEN_METRICS_TEBIBYTES = 'tebibytes', + + UCUM_PEBIBYTES = 'PiBy', + OPEN_METRICS_PEBIBYTES = 'pebibytes', + + UCUM_KIBIBYTES_SECOND = 'KiBy/s', + OPEN_METRICS_KIBIBYTES_SECOND = 'kibibytes_per_second', + + UCUM_KIBIBITS_SECOND = 'Kibit/s', + OPEN_METRICS_KIBIBITS_SECOND = 'kibibits_per_second', + + UCUM_MEBIBYTES_SECOND = 'MiBy/s', + OPEN_METRICS_MEBIBYTES_SECOND = 'mebibytes_per_second', + + UCUM_MEBIBITS_SECOND = 'Mibit/s', + OPEN_METRICS_MEBIBITS_SECOND = 'mebibits_per_second', + + UCUM_GIBIBYTES_SECOND = 'GiBy/s', + OPEN_METRICS_GIBIBYTES_SECOND = 'gibibytes_per_second', + + UCUM_GIBIBITS_SECOND = 'Gibit/s', + OPEN_METRICS_GIBIBITS_SECOND = 'gibibits_per_second', + + UCUM_TEBIBYTES_SECOND = 'TiBy/s', + OPEN_METRICS_TEBIBYTES_SECOND = 'tebibytes_per_second', + + UCUM_TEBIBITS_SECOND = 'Tibit/s', + OPEN_METRICS_TEBIBITS_SECOND = 'tebibits_per_second', + + UCUM_PEBIBYTES_SECOND = 'PiBy/s', + OPEN_METRICS_PEBIBYTES_SECOND = 'pebibytes_per_second', + + UCUM_PEBIBITS_SECOND = 'Pibit/s', + OPEN_METRICS_PEBIBITS_SECOND = 'pebibits_per_second', + + UCUM_TRUE_FALSE = '{bool}', + OPEN_METRICS_TRUE_FALSE = 'boolean_true_false', + + UCUM_YES_NO = '{bool}', + OPEN_METRICS_YES_NO = 'boolean_yes_no', + + UCUM_COUNTS_SECOND = '{count}/s', + OPEN_METRICS_COUNTS_SECOND = 'counts_per_second', + + UCUM_OPS_SECOND = '{ops}/s', + OPEN_METRICS_OPS_SECOND = 'ops_per_second', + + UCUM_REQUESTS_SECOND = '{requests}/s', + OPEN_METRICS_REQUESTS_SECOND = 'requests_per_second', + + UCUM_REQUESTS_MINUTE = '{requests}/min', + OPEN_METRICS_REQUESTS_MINUTE = 'requests_per_minute', + + UCUM_READS_SECOND = '{reads}/s', + OPEN_METRICS_READS_SECOND = 'reads_per_second', + + UCUM_WRITES_SECOND = '{writes}/s', + OPEN_METRICS_WRITES_SECOND = 'writes_per_second', + + UCUM_IOPS_SECOND = '{iops}/s', + OPEN_METRICS_IOPS_SECOND = 'io_ops_per_second', + + UCUM_COUNTS_MINUTE = '{count}/min', + OPEN_METRICS_COUNTS_MINUTE = 'counts_per_minute', + + UCUM_OPS_MINUTE = '{ops}/min', + OPEN_METRICS_OPS_MINUTE = 'ops_per_minute', + + UCUM_READS_MINUTE = '{reads}/min', + OPEN_METRICS_READS_MINUTE = 'reads_per_minute', + + UCUM_WRITES_MINUTE = '{writes}/min', + OPEN_METRICS_WRITES_MINUTE = 'writes_per_minute', + + OPEN_METRICS_PERCENT_UNIT = 'percentunit', +} diff --git a/frontend/src/components/YAxisUnitSelector/utils.tsx b/frontend/src/components/YAxisUnitSelector/utils.tsx new file mode 100644 index 000000000000..15836475288b --- /dev/null +++ b/frontend/src/components/YAxisUnitSelector/utils.tsx @@ -0,0 +1,33 @@ +import { UniversalYAxisUnitMappings, Y_AXIS_UNIT_NAMES } from './constants'; +import { UniversalYAxisUnit, YAxisUnit } from './types'; + +export const mapMetricUnitToUniversalUnit = ( + unit: string | undefined, +): UniversalYAxisUnit | null => { + if (!unit) { + return null; + } + + const universalUnit = Object.values(UniversalYAxisUnit).find( + (u) => UniversalYAxisUnitMappings[u].has(unit as YAxisUnit) || unit === u, + ); + + return universalUnit || (unit as UniversalYAxisUnit) || null; +}; + +export const getUniversalNameFromMetricUnit = ( + unit: string | undefined, +): string => { + if (!unit) { + return '-'; + } + + const universalUnit = mapMetricUnitToUniversalUnit(unit); + if (!universalUnit) { + return unit; + } + + const universalName = Y_AXIS_UNIT_NAMES[universalUnit]; + + return universalName || unit || '-'; +}; diff --git a/frontend/src/constants/antlrQueryConstants.ts b/frontend/src/constants/antlrQueryConstants.ts index 5f783c770a54..49c7c06c71ab 100644 --- a/frontend/src/constants/antlrQueryConstants.ts +++ b/frontend/src/constants/antlrQueryConstants.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + export const OPERATORS = { IN: 'IN', LIKE: 'LIKE', @@ -21,6 +23,44 @@ export const QUERY_BUILDER_FUNCTIONS = { HASALL: 'hasAll', }; +export function negateOperator(operatorOrFunction: string): string { + // Special cases for equals/not equals + if (operatorOrFunction === OPERATORS['=']) { + return OPERATORS['!=']; + } + if (operatorOrFunction === OPERATORS['!=']) { + return OPERATORS['=']; + } + // For all other operators and functions, add NOT in front + return `${OPERATORS.NOT} ${operatorOrFunction}`; +} + +export enum DEPRECATED_OPERATORS { + REGEX = 'regex', + NIN = 'nin', + NREGEX = 'nregex', + NLIKE = 'nlike', + NILIKE = 'nilike', + NEXTISTS = 'nexists', + NCONTAINS = 'ncontains', + NHAS = 'nhas', + NHASANY = 'nhasany', + NHASALL = 'nhasall', +} + +export const DEPRECATED_OPERATORS_MAP = { + [DEPRECATED_OPERATORS.REGEX]: OPERATORS.REGEXP, + [DEPRECATED_OPERATORS.NIN]: negateOperator(OPERATORS.IN), + [DEPRECATED_OPERATORS.NREGEX]: negateOperator(OPERATORS.REGEXP), + [DEPRECATED_OPERATORS.NLIKE]: negateOperator(OPERATORS.LIKE), + [DEPRECATED_OPERATORS.NILIKE]: negateOperator(OPERATORS.ILIKE), + [DEPRECATED_OPERATORS.NEXTISTS]: negateOperator(OPERATORS.EXISTS), + [DEPRECATED_OPERATORS.NCONTAINS]: negateOperator(OPERATORS.CONTAINS), + [DEPRECATED_OPERATORS.NHAS]: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS), + [DEPRECATED_OPERATORS.NHASANY]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASANY), + [DEPRECATED_OPERATORS.NHASALL]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASALL), +}; + export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS]; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -82,15 +122,3 @@ export const queryOperatorSuggestions = [ { label: OPERATORS.NOT, type: 'operator', info: 'Not' }, ...negationQueryOperatorSuggestions, ]; - -export function negateOperator(operatorOrFunction: string): string { - // Special cases for equals/not equals - if (operatorOrFunction === OPERATORS['=']) { - return OPERATORS['!=']; - } - if (operatorOrFunction === OPERATORS['!=']) { - return OPERATORS['=']; - } - // For all other operators and functions, add NOT in front - return `${OPERATORS.NOT} ${operatorOrFunction}`; -} diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index b5ec8a4d11d5..ab7e4ccaa52a 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -77,9 +77,9 @@ const ROUTES = { API_MONITORING: '/api-monitoring/explorer', METRICS_EXPLORER_BASE: '/metrics-explorer', WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted', - METER_EXPLORER_BASE: '/meter-explorer', - METER_EXPLORER: '/meter-explorer', - METER_EXPLORER_VIEWS: '/meter-explorer/views', + METER: '/meter', + METER_EXPLORER: '/meter/explorer', + METER_EXPLORER_VIEWS: '/meter/explorer/views', HOME_PAGE: '/', } as const; diff --git a/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx index a6aa27554010..a73e9cc0fd8a 100644 --- a/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx @@ -520,12 +520,6 @@ function ClusterDetails({ > Cluster Name - - Cluster Name -
@@ -533,9 +527,6 @@ function ClusterDetails({ {cluster.meta.k8s_cluster_name} - - {cluster.meta.k8s_cluster_name} -
diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx new file mode 100644 index 000000000000..058105fc3ea9 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx @@ -0,0 +1,351 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { Time } from 'container/TopNav/DateTimeSelectionV2/config'; +import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder'; +import * as appContextHooks from 'providers/App/App'; +import { LicenseEvent } from 'types/api/licensesV3/getActive'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +import EntityEvents from '../EntityEvents'; + +jest.mock('container/TopNav/DateTimeSelectionV2', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Date Time
+ ), +})); + +const mockUseQuery = jest.fn(); +jest.mock('react-query', () => ({ + useQuery: (queryKey: any, queryFn: any, options: any): any => + mockUseQuery(queryKey, queryFn, options), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: (): { pathname: string } => ({ + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}/`, + }), +})); + +jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({ + user: { + role: 'admin', + }, + activeLicenseV3: { + event_queue: { + created_at: '0', + event: LicenseEvent.NO_EVENT, + scheduled_at: '0', + status: '', + updated_at: '0', + }, + license: { + license_key: 'test-license-key', + license_type: 'trial', + org_id: 'test-org-id', + plan_id: 'test-plan-id', + plan_name: 'test-plan-name', + plan_type: 'trial', + plan_version: 'test-plan-version', + }, + }, +} as any); + +const mockUseQueryBuilderData = { + handleRunQuery: jest.fn(), + stagedQuery: initialQueriesMap[DataSource.METRICS], + updateAllQueriesOperators: jest.fn(), + currentQuery: initialQueriesMap[DataSource.METRICS], + resetQuery: jest.fn(), + redirectWithQueryBuilderData: jest.fn(), + isStagedQueryUpdated: jest.fn(), + handleSetQueryData: jest.fn(), + handleSetFormulaData: jest.fn(), + handleSetQueryItemData: jest.fn(), + handleSetConfig: jest.fn(), + removeQueryBuilderEntityByIndex: jest.fn(), + removeQueryTypeItemByIndex: jest.fn(), + isDefaultQuery: jest.fn(), +}; + +jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({ + ...mockUseQueryBuilderData, +} as any); + +const timeRange = { + startTime: 1718236800, + endTime: 1718236800, +}; + +const mockHandleChangeEventFilters = jest.fn(); + +const mockFilters: IBuilderQuery['filters'] = { + items: [ + { + id: 'pod-name', + key: { + id: 'pod-name', + dataType: DataTypes.String, + isColumn: true, + key: 'pod-name', + type: 'tag', + isJSON: false, + isIndexed: false, + }, + op: '=', + value: 'pod-1', + }, + ], + op: 'and', +}; + +const isModalTimeSelection = false; +const mockHandleTimeChange = jest.fn(); +const selectedInterval: Time = '1m'; +const category = K8sCategory.PODS; +const queryKey = 'pod-events'; + +const mockEventsData = { + payload: { + data: { + newResult: { + data: { + result: [ + { + list: [ + { + timestamp: '2024-01-15T10:00:00Z', + data: { + id: 'event-1', + severity_text: 'INFO', + body: 'Test event 1', + resources_string: { 'pod.name': 'test-pod-1' }, + attributes_string: { service: 'test-service' }, + }, + }, + { + timestamp: '2024-01-15T10:01:00Z', + data: { + id: 'event-2', + severity_text: 'WARN', + body: 'Test event 2', + resources_string: { 'pod.name': 'test-pod-2' }, + attributes_string: { service: 'test-service' }, + }, + }, + ], + }, + ], + }, + }, + }, + }, +}; + +const mockEmptyEventsData = { + payload: { + data: { + newResult: { + data: { + result: [ + { + list: [], + }, + ], + }, + }, + }, + }, +}; + +const createMockEvent = ( + id: string, + severity: string, + body: string, + podName: string, +): any => ({ + timestamp: `2024-01-15T10:${id.padStart(2, '0')}:00Z`, + data: { + id: `event-${id}`, + severity_text: severity, + body, + resources_string: { 'pod.name': podName }, + attributes_string: { service: 'test-service' }, + }, +}); + +const createMockMoreEventsData = (): any => ({ + payload: { + data: { + newResult: { + data: { + result: [ + { + list: Array.from({ length: 11 }, (_, i) => + createMockEvent( + String(i + 1), + ['INFO', 'WARN', 'ERROR', 'DEBUG'][i % 4], + `Test event ${i + 1}`, + `test-pod-${i + 1}`, + ), + ), + }, + ], + }, + }, + }, + }, +}); + +const renderEntityEvents = (overrides = {}): any => { + const defaultProps = { + timeRange, + handleChangeEventFilters: mockHandleChangeEventFilters, + filters: mockFilters, + isModalTimeSelection, + handleTimeChange: mockHandleTimeChange, + selectedInterval, + category, + queryKey, + ...overrides, + }; + + return render( + , + ); +}; + +describe('EntityEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: mockEventsData, + isLoading: false, + isError: false, + isFetching: false, + }); + }); + + it('should render events list with data', () => { + renderEntityEvents(); + expect(screen.getByText('Prev')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect(screen.getByText('Test event 1')).toBeInTheDocument(); + expect(screen.getByText('Test event 2')).toBeInTheDocument(); + expect(screen.getByText('INFO')).toBeInTheDocument(); + expect(screen.getByText('WARN')).toBeInTheDocument(); + }); + + it('renders empty state when no events are found', () => { + mockUseQuery.mockReturnValue({ + data: mockEmptyEventsData, + isLoading: false, + isError: false, + isFetching: false, + }); + + renderEntityEvents(); + expect(screen.getByText(/No events found for this pods/)).toBeInTheDocument(); + }); + + it('renders loader when fetching events', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + isFetching: true, + }); + + renderEntityEvents(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('shows pagination controls when events are present', () => { + renderEntityEvents(); + expect(screen.getByText('Prev')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('disables Prev button on first page', () => { + renderEntityEvents(); + const prevButton = screen.getByText('Prev').closest('button'); + expect(prevButton).toBeDisabled(); + }); + + it('enables Next button when more events are available', () => { + mockUseQuery.mockReturnValue({ + data: createMockMoreEventsData(), + isLoading: false, + isError: false, + isFetching: false, + }); + + renderEntityEvents(); + const nextButton = screen.getByText('Next').closest('button'); + expect(nextButton).not.toBeDisabled(); + }); + + it('navigates to next page when Next button is clicked', () => { + mockUseQuery.mockReturnValue({ + data: createMockMoreEventsData(), + isLoading: false, + isError: false, + isFetching: false, + }); + + renderEntityEvents(); + + const nextButton = screen.getByText('Next').closest('button'); + expect(nextButton).not.toBeNull(); + fireEvent.click(nextButton as Element); + + const { calls } = mockUseQuery.mock; + const hasPage2Call = calls.some((call) => { + const { queryKey: callQueryKey } = call[0] || {}; + return Array.isArray(callQueryKey) && callQueryKey.includes(2); + }); + expect(hasPage2Call).toBe(true); + }); + + it('navigates to previous page when Prev button is clicked', () => { + mockUseQuery.mockReturnValue({ + data: createMockMoreEventsData(), + isLoading: false, + isError: false, + isFetching: false, + }); + + renderEntityEvents(); + + const nextButton = screen.getByText('Next').closest('button'); + expect(nextButton).not.toBeNull(); + fireEvent.click(nextButton as Element); + + const prevButton = screen.getByText('Prev').closest('button'); + expect(prevButton).not.toBeNull(); + fireEvent.click(prevButton as Element); + + const { calls } = mockUseQuery.mock; + const hasPage1Call = calls.some((call) => { + const { queryKey: callQueryKey } = call[0] || {}; + return Array.isArray(callQueryKey) && callQueryKey.includes(1); + }); + expect(hasPage1Call).toBe(true); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx new file mode 100644 index 000000000000..ce0a1832bbd0 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { render, screen } from '@testing-library/react'; +import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { Time } from 'container/TopNav/DateTimeSelectionV2/config'; +import * as appContextHooks from 'providers/App/App'; +import { LicenseEvent } from 'types/api/licensesV3/getActive'; + +import EntityMetrics from '../EntityMetrics'; + +jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({ + getUPlotChartOptions: jest.fn().mockReturnValue({}), +})); + +jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({ + getUPlotChartData: jest.fn().mockReturnValue([]), +})); + +jest.mock('container/TopNav/DateTimeSelectionV2', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Date Time
+ ), +})); + +jest.mock('components/Uplot', () => ({ + __esModule: true, + default: (): JSX.Element =>
Uplot Chart
, +})); + +jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({ + __esModule: true, + getMetricsTableData: jest.fn().mockReturnValue([ + { + rows: [ + { data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } }, + { data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } }, + ], + columns: [ + { key: 'timestamp', label: 'Timestamp', isValueColumn: false }, + { key: 'value', label: 'Value', isValueColumn: true }, + ], + }, + ]), + MetricsTable: jest + .fn() + .mockImplementation( + (): JSX.Element =>
Metrics Table
, + ), +})); + +const mockUseQueries = jest.fn(); +jest.mock('react-query', () => ({ + useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs), +})); + +jest.mock('hooks/useDarkMode', () => ({ + useIsDarkMode: (): boolean => false, +})); + +jest.mock('hooks/useDimensions', () => ({ + useResizeObserver: (): { width: number; height: number } => ({ + width: 800, + height: 600, + }), +})); + +jest.mock('hooks/useMultiIntersectionObserver', () => ({ + useMultiIntersectionObserver: (count: number): any => ({ + visibilities: new Array(count).fill(true), + setElement: jest.fn(), + }), +})); + +jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({ + user: { + role: 'admin', + }, + activeLicenseV3: { + event_queue: { + created_at: '0', + event: LicenseEvent.NO_EVENT, + scheduled_at: '0', + status: '', + updated_at: '0', + }, + license: { + license_key: 'test-license-key', + license_type: 'trial', + org_id: 'test-org-id', + plan_id: 'test-plan-id', + plan_name: 'test-plan-name', + plan_type: 'trial', + plan_version: 'test-plan-version', + }, + }, + featureFlags: [ + { + name: 'DOT_METRICS_ENABLED', + active: false, + }, + ], +} as any); + +const mockEntity = { + id: 'test-entity-1', + name: 'test-entity', + type: 'pod', +}; + +const mockEntityWidgetInfo = [ + { + title: 'CPU Usage', + yAxisUnit: 'percentage', + }, + { + title: 'Memory Usage', + yAxisUnit: 'bytes', + }, +]; + +const mockGetEntityQueryPayload = jest.fn().mockReturnValue([ + { + query: 'cpu_usage', + start: 1705315200, + end: 1705318800, + }, + { + query: 'memory_usage', + start: 1705315200, + end: 1705318800, + }, +]); + +const mockTimeRange = { + startTime: 1705315200, + endTime: 1705318800, +}; + +const mockHandleTimeChange = jest.fn(); + +const mockQueries = [ + { + data: { + payload: { + data: { + result: [ + { + table: { + rows: [ + { data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } }, + { data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } }, + ], + columns: [ + { key: 'timestamp', label: 'Timestamp', isValueColumn: false }, + { key: 'value', label: 'Value', isValueColumn: true }, + ], + }, + }, + ], + }, + }, + params: { + compositeQuery: { + panelType: 'time_series', + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }, + { + data: { + payload: { + data: { + result: [ + { + table: { + rows: [ + { data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } }, + { data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } }, + ], + columns: [ + { key: 'timestamp', label: 'Timestamp', isValueColumn: false }, + { key: 'value', label: 'Value', isValueColumn: true }, + ], + }, + }, + ], + }, + }, + params: { + compositeQuery: { + panelType: 'table', + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }, +]; + +const mockLoadingQueries = [ + { + data: undefined, + isLoading: true, + isError: false, + error: null, + }, + { + data: undefined, + isLoading: true, + isError: false, + error: null, + }, +]; + +const mockErrorQueries = [ + { + data: undefined, + isLoading: false, + isError: true, + error: new Error('API Error'), + }, + { + data: undefined, + isLoading: false, + isError: true, + error: new Error('Network Error'), + }, +]; + +const mockEmptyQueries = [ + { + data: { + payload: { + data: { + result: [], + }, + }, + params: { + compositeQuery: { + panelType: 'time_series', + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }, + { + data: { + payload: { + data: { + result: [], + }, + }, + params: { + compositeQuery: { + panelType: 'table', + }, + }, + }, + isLoading: false, + isError: false, + error: null, + }, +]; + +const renderEntityMetrics = (overrides = {}): any => { + const defaultProps = { + timeRange: mockTimeRange, + isModalTimeSelection: false, + handleTimeChange: mockHandleTimeChange, + selectedInterval: '5m' as Time, + entity: mockEntity, + entityWidgetInfo: mockEntityWidgetInfo, + getEntityQueryPayload: mockGetEntityQueryPayload, + queryKey: 'test-query-key', + category: K8sCategory.PODS, + ...overrides, + }; + + return render( + , + ); +}; + +describe('EntityMetrics', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueries.mockReturnValue(mockQueries); + }); + + it('should render metrics with data', () => { + renderEntityMetrics(); + expect(screen.getByText('CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Usage')).toBeInTheDocument(); + expect(screen.getByTestId('date-time-selection')).toBeInTheDocument(); + expect(screen.getByTestId('uplot-chart')).toBeInTheDocument(); + expect(screen.getByTestId('metrics-table')).toBeInTheDocument(); + }); + + it('renders loading state when fetching metrics', () => { + mockUseQueries.mockReturnValue(mockLoadingQueries); + renderEntityMetrics(); + expect(screen.getAllByText('CPU Usage')).toHaveLength(1); + expect(screen.getAllByText('Memory Usage')).toHaveLength(1); + }); + + it('renders error state when query fails', () => { + mockUseQueries.mockReturnValue(mockErrorQueries); + renderEntityMetrics(); + expect(screen.getByText('API Error')).toBeInTheDocument(); + expect(screen.getByText('Network Error')).toBeInTheDocument(); + }); + + it('renders empty state when no metrics data', () => { + mockUseQueries.mockReturnValue(mockEmptyQueries); + renderEntityMetrics(); + expect(screen.getByTestId('uplot-chart')).toBeInTheDocument(); + expect(screen.getByTestId('metrics-table')).toBeInTheDocument(); + }); + + it('calls handleTimeChange when datetime selection changes', () => { + renderEntityMetrics(); + expect(screen.getByTestId('date-time-selection')).toBeInTheDocument(); + }); + + it('renders multiple metric widgets', () => { + renderEntityMetrics(); + expect(screen.getByText('CPU Usage')).toBeInTheDocument(); + expect(screen.getByText('Memory Usage')).toBeInTheDocument(); + }); + + it('handles different panel types correctly', () => { + renderEntityMetrics(); + expect(screen.getByTestId('uplot-chart')).toBeInTheDocument(); + expect(screen.getByTestId('metrics-table')).toBeInTheDocument(); + }); + + it('applies intersection observer for visibility', () => { + renderEntityMetrics(); + expect(mockUseQueries).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + }), + ]), + ); + }); + + it('generates correct query payloads', () => { + renderEntityMetrics(); + expect(mockGetEntityQueryPayload).toHaveBeenCalledWith( + mockEntity, + mockTimeRange.startTime, + mockTimeRange.endTime, + false, + ); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx new file mode 100644 index 000000000000..c553ce799111 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { render, screen } from '@testing-library/react'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { Time } from 'container/TopNav/DateTimeSelectionV2/config'; +import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder'; +import * as appContextHooks from 'providers/App/App'; +import { LicenseEvent } from 'types/api/licensesV3/getActive'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +import EntityTraces from '../EntityTraces'; + +jest.mock('container/TopNav/DateTimeSelectionV2', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Date Time
+ ), +})); + +const mockUseQuery = jest.fn(); +jest.mock('react-query', () => ({ + useQuery: (queryKey: any, queryFn: any, options: any): any => + mockUseQuery(queryKey, queryFn, options), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: (): { pathname: string } => ({ + pathname: '/test-path', + }), + useNavigate: (): jest.Mock => jest.fn(), +})); + +jest.mock('hooks/useSafeNavigate', () => ({ + useSafeNavigate: (): { safeNavigate: jest.Mock } => ({ + safeNavigate: jest.fn(), + }), +})); + +jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({ + user: { + role: 'admin', + }, + activeLicenseV3: { + event_queue: { + created_at: '0', + event: LicenseEvent.NO_EVENT, + scheduled_at: '0', + status: '', + updated_at: '0', + }, + license: { + license_key: 'test-license-key', + license_type: 'trial', + org_id: 'test-org-id', + plan_id: 'test-plan-id', + plan_name: 'test-plan-name', + plan_type: 'trial', + plan_version: 'test-plan-version', + }, + }, +} as any); + +const mockUseQueryBuilderData = { + handleRunQuery: jest.fn(), + stagedQuery: initialQueriesMap[DataSource.METRICS], + updateAllQueriesOperators: jest.fn(), + currentQuery: initialQueriesMap[DataSource.METRICS], + resetQuery: jest.fn(), + redirectWithQueryBuilderData: jest.fn(), + isStagedQueryUpdated: jest.fn(), + handleSetQueryData: jest.fn(), + handleSetFormulaData: jest.fn(), + handleSetQueryItemData: jest.fn(), + handleSetConfig: jest.fn(), + removeQueryBuilderEntityByIndex: jest.fn(), + removeQueryTypeItemByIndex: jest.fn(), + isDefaultQuery: jest.fn(), +}; + +jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({ + ...mockUseQueryBuilderData, +} as any); + +const timeRange = { + startTime: 1718236800, + endTime: 1718236800, +}; + +const mockHandleChangeTracesFilters = jest.fn(); + +const mockTracesFilters: IBuilderQuery['filters'] = { + items: [ + { + id: 'service-name', + key: { + id: 'service-name', + dataType: DataTypes.String, + isColumn: true, + key: 'service.name', + type: 'tag', + isJSON: false, + isIndexed: false, + }, + op: '=', + value: 'test-service', + }, + ], + op: 'and', +}; + +const isModalTimeSelection = false; +const mockHandleTimeChange = jest.fn(); +const selectedInterval: Time = '5m'; +const category = K8sCategory.PODS; +const queryKey = 'pod-traces'; +const queryKeyFilters = ['service.name']; + +const mockTracesData = { + payload: { + data: { + newResult: { + data: { + result: [ + { + list: [ + { + timestamp: '2024-01-15T10:00:00Z', + data: { + trace_id: 'trace-1', + span_id: 'span-1', + service_name: 'test-service-1', + operation_name: 'test-operation-1', + duration: 100, + status_code: 200, + }, + }, + { + timestamp: '2024-01-15T10:01:00Z', + data: { + trace_id: 'trace-2', + span_id: 'span-2', + service_name: 'test-service-2', + operation_name: 'test-operation-2', + duration: 150, + status_code: 500, + }, + }, + ], + }, + ], + }, + }, + }, + }, +}; + +const mockEmptyTracesData = { + payload: { + data: { + newResult: { + data: { + result: [ + { + list: [], + }, + ], + }, + }, + }, + }, +}; + +const renderEntityTraces = (overrides = {}): any => { + const defaultProps = { + timeRange, + isModalTimeSelection, + handleTimeChange: mockHandleTimeChange, + handleChangeTracesFilters: mockHandleChangeTracesFilters, + tracesFilters: mockTracesFilters, + selectedInterval, + queryKey, + category, + queryKeyFilters, + ...overrides, + }; + + return render( + , + ); +}; + +describe('EntityTraces', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: mockTracesData, + isLoading: false, + isError: false, + isFetching: false, + }); + }); + + it('should render traces list with data', () => { + renderEntityTraces(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect( + screen.getByText(/Search Filter : select options from suggested values/), + ).toBeInTheDocument(); + expect(screen.getByTestId('date-time-selection')).toBeInTheDocument(); + }); + + it('renders empty state when no traces are found', () => { + mockUseQuery.mockReturnValue({ + data: mockEmptyTracesData, + isLoading: false, + isError: false, + isFetching: false, + }); + + renderEntityTraces(); + expect(screen.getByText(/No traces yet./)).toBeInTheDocument(); + }); + + it('renders loader when fetching traces', () => { + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + isFetching: true, + }); + + renderEntityTraces(); + expect(screen.getByText('pending_data_placeholder')).toBeInTheDocument(); + }); + + it('shows error state when query fails', () => { + mockUseQuery.mockReturnValue({ + data: { error: 'API Error' }, + isLoading: false, + isError: true, + isFetching: false, + }); + + renderEntityTraces(); + expect(screen.getByText('API Error')).toBeInTheDocument(); + }); + + it('calls handleChangeTracesFilters when query builder search changes', () => { + renderEntityTraces(); + expect( + screen.getByText(/Search Filter : select options from suggested values/), + ).toBeInTheDocument(); + }); + + it('calls handleTimeChange when datetime selection changes', () => { + renderEntityTraces(); + expect(screen.getByTestId('date-time-selection')).toBeInTheDocument(); + }); + + it('shows pagination controls when traces are present', () => { + renderEntityTraces(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('disables pagination buttons when no more data', () => { + renderEntityTraces(); + const prevButton = screen.getByText('Previous').closest('button'); + const nextButton = screen.getByText('Next').closest('button'); + expect(prevButton).toBeDisabled(); + expect(nextButton).toBeDisabled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/LoadingContainer.tsx b/frontend/src/container/InfraMonitoringK8s/LoadingContainer.tsx index c9cfe564e1fa..745e1a275b00 100644 --- a/frontend/src/container/InfraMonitoringK8s/LoadingContainer.tsx +++ b/frontend/src/container/InfraMonitoringK8s/LoadingContainer.tsx @@ -4,7 +4,7 @@ import { Skeleton } from 'antd'; function LoadingContainer(): JSX.Element { return ( -
+
{ + const mockCluster = { + meta: { + k8s_cluster_name: 'test-cluster', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/DaemonSets/DaemonSetDetails/DaemonSetDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/DaemonSets/DaemonSetDetails/DaemonSetDetails.test.tsx new file mode 100644 index 000000000000..7ba3f131f48c --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/DaemonSets/DaemonSetDetails/DaemonSetDetails.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import DaemonSetDetails from 'container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('DaemonSetDetails', () => { + const mockDaemonSet = { + meta: { + k8s_daemonset_name: 'test-daemon-set', + k8s_cluster_name: 'test-cluster', + k8s_namespace_name: 'test-namespace', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const daemonSetNameElements = screen.getAllByText('test-daemon-set'); + expect(daemonSetNameElements.length).toBeGreaterThan(0); + expect(daemonSetNameElements[0]).toBeInTheDocument(); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Deployments/DeploymentDetails/DeploymentDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Deployments/DeploymentDetails/DeploymentDetails.test.tsx new file mode 100644 index 000000000000..ae5149d66a89 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Deployments/DeploymentDetails/DeploymentDetails.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import DeploymentDetails from 'container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('DeploymentDetails', () => { + const mockDeployment = { + meta: { + k8s_deployment_name: 'test-deployment', + k8s_cluster_name: 'test-cluster', + k8s_namespace_name: 'test-namespace', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const deploymentNameElements = screen.getAllByText('test-deployment'); + expect(deploymentNameElements.length).toBeGreaterThan(0); + expect(deploymentNameElements[0]).toBeInTheDocument(); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx new file mode 100644 index 000000000000..7b32c9226ada --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Jobs/JobDetails/JobDetails.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('JobDetails', () => { + const mockJob = { + meta: { + k8s_job_name: 'test-job', + k8s_namespace_name: 'test-namespace', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const jobNameElements = screen.getAllByText('test-job'); + expect(jobNameElements.length).toBeGreaterThan(0); + expect(jobNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Namespaces/NamespaceDetails/NamespaceDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Namespaces/NamespaceDetails/NamespaceDetails.test.tsx new file mode 100644 index 000000000000..0a9ce777fc7c --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Namespaces/NamespaceDetails/NamespaceDetails.test.tsx @@ -0,0 +1,136 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import NamespaceDetails from 'container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('NamespaceDetails', () => { + const mockNamespace = { + namespaceName: 'test-namespace', + meta: { + k8s_cluster_name: 'test-cluster', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Nodes/NodeDetails/NodeDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Nodes/NodeDetails/NodeDetails.test.tsx new file mode 100644 index 000000000000..619b7cd1b2f5 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Nodes/NodeDetails/NodeDetails.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import NodeDetails from 'container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('NodeDetails', () => { + const mockNode = { + meta: { + k8s_node_name: 'test-node', + k8s_cluster_name: 'test-cluster', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const nodeNameElements = screen.getAllByText('test-node'); + expect(nodeNameElements.length).toBeGreaterThan(0); + expect(nodeNameElements[0]).toBeInTheDocument(); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Pods/PodDetails/PodDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Pods/PodDetails/PodDetails.test.tsx new file mode 100644 index 000000000000..09481b563b62 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Pods/PodDetails/PodDetails.test.tsx @@ -0,0 +1,122 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import PodDetails from 'container/InfraMonitoringK8s/Pods/PodDetails/PodDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('PodDetails', () => { + const mockPod = { + podName: 'test-pod', + meta: { + k8s_cluster_name: 'test-cluster', + k8s_namespace_name: 'test-namespace', + k8s_node_name: 'test-node', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + + const nodeNameElements = screen.getAllByText('test-node'); + expect(nodeNameElements.length).toBeGreaterThan(0); + expect(nodeNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/StatefulSets/StatefulSetDetails/StatefulSetDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/StatefulSets/StatefulSetDetails/StatefulSetDetails.test.tsx new file mode 100644 index 000000000000..7b55787bdb7c --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/StatefulSets/StatefulSetDetails/StatefulSetDetails.test.tsx @@ -0,0 +1,136 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import StatefulSetDetails from 'container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('StatefulSetDetails', () => { + const mockStatefulSet = { + meta: { + k8s_namespace_name: 'test-namespace', + k8s_statefulset_name: 'test-stateful-set', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const statefulSetNameElements = screen.getAllByText('test-stateful-set'); + expect(statefulSetNameElements.length).toBeGreaterThan(0); + expect(statefulSetNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + }); + + it('should render modal with 4 tabs', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByText('Metrics'); + expect(metricsTab).toBeInTheDocument(); + + const eventsTab = screen.getByText('Events'); + expect(eventsTab).toBeInTheDocument(); + + const logsTab = screen.getByText('Logs'); + expect(logsTab).toBeInTheDocument(); + + const tracesTab = screen.getByText('Traces'); + expect(tracesTab).toBeInTheDocument(); + }); + + it('default tab should be metrics', () => { + render( + + + + + + + , + ); + + const metricsTab = screen.getByRole('radio', { name: 'Metrics' }); + expect(metricsTab).toBeChecked(); + }); + + it('should switch to events tab when events tab is clicked', () => { + render( + + + + + + + , + ); + + const eventsTab = screen.getByRole('radio', { name: 'Events' }); + expect(eventsTab).not.toBeChecked(); + fireEvent.click(eventsTab); + expect(eventsTab).toBeChecked(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/Volumes/VolumeDetails/VolumeDetails.test.tsx b/frontend/src/container/InfraMonitoringK8s/__tests__/Volumes/VolumeDetails/VolumeDetails.test.tsx new file mode 100644 index 000000000000..24becbe6fec9 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/Volumes/VolumeDetails/VolumeDetails.test.tsx @@ -0,0 +1,73 @@ +/* eslint-disable import/first */ +// eslint-disable-next-line import/order +import setupCommonMocks from '../../commonMocks'; + +setupCommonMocks(); + +import { fireEvent, render, screen } from '@testing-library/react'; +import VolumeDetails from 'container/InfraMonitoringK8s/Volumes/VolumeDetails/VolumeDetails'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import store from 'store'; + +const queryClient = new QueryClient(); + +describe('VolumeDetails', () => { + const mockVolume = { + persistentVolumeClaimName: 'test-volume', + meta: { + k8s_cluster_name: 'test-cluster', + k8s_namespace_name: 'test-namespace', + }, + } as any; + const mockOnClose = jest.fn(); + + it('should render modal with relevant metadata', () => { + render( + + + + + + + , + ); + + const volumeNameElements = screen.getAllByText('test-volume'); + expect(volumeNameElements.length).toBeGreaterThan(0); + expect(volumeNameElements[0]).toBeInTheDocument(); + + const clusterNameElements = screen.getAllByText('test-cluster'); + expect(clusterNameElements.length).toBeGreaterThan(0); + expect(clusterNameElements[0]).toBeInTheDocument(); + + const namespaceNameElements = screen.getAllByText('test-namespace'); + expect(namespaceNameElements.length).toBeGreaterThan(0); + expect(namespaceNameElements[0]).toBeInTheDocument(); + }); + + it('should close modal when close button is clicked', () => { + render( + + + + + + + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts new file mode 100644 index 000000000000..41bd0782ace1 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8s/__tests__/commonMocks.ts @@ -0,0 +1,121 @@ +import * as appContextHooks from 'providers/App/App'; +import * as timezoneHooks from 'providers/Timezone'; +import { LicenseEvent } from 'types/api/licensesV3/getActive'; + +const setupCommonMocks = (): void => { + const createMockObserver = (): { + observe: jest.Mock; + unobserve: jest.Mock; + disconnect: jest.Mock; + } => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }); + + global.IntersectionObserver = jest.fn().mockImplementation(createMockObserver); + global.ResizeObserver = jest.fn().mockImplementation(createMockObserver); + + jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => ({ + globalTime: { + selectedTime: { + startTime: 1713734400000, + endTime: 1713738000000, + }, + maxTime: 1713738000000, + minTime: 1713734400000, + }, + })), + })); + + jest.mock('uplot', () => ({ + paths: { + spline: jest.fn(), + bars: jest.fn(), + }, + default: jest.fn(() => ({ + paths: { + spline: jest.fn(), + bars: jest.fn(), + }, + })), + })); + + jest.mock('react-router-dom-v5-compat', () => ({ + ...jest.requireActual('react-router-dom-v5-compat'), + useSearchParams: jest.fn().mockReturnValue([ + { + get: jest.fn(), + entries: jest.fn(() => []), + set: jest.fn(), + }, + jest.fn(), + ]), + useNavigationType: (): any => 'PUSH', + })); + + jest.mock('hooks/useUrlQuery', () => ({ + __esModule: true, + default: jest.fn(() => ({ + set: jest.fn(), + delete: jest.fn(), + get: jest.fn(), + has: jest.fn(), + entries: jest.fn(() => []), + append: jest.fn(), + toString: jest.fn(() => ''), + })), + })); + + jest.mock('lib/getMinMax', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + minTime: 1713734400000, + maxTime: 1713738000000, + })), + isValidTimeFormat: jest.fn().mockReturnValue(true), + })); + + jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({ + user: { + role: 'admin', + }, + activeLicenseV3: { + event_queue: { + created_at: '0', + event: LicenseEvent.NO_EVENT, + scheduled_at: '0', + status: '', + updated_at: '0', + }, + license: { + license_key: 'test-license-key', + license_type: 'trial', + org_id: 'test-org-id', + plan_id: 'test-plan-id', + plan_name: 'test-plan-name', + plan_type: 'trial', + plan_version: 'test-plan-version', + }, + }, + } as any); + + jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({ + timezone: { + offset: 0, + }, + browserTimezone: { + offset: 0, + }, + } as any); + + jest.mock('hooks/useSafeNavigate', () => ({ + useSafeNavigate: (): any => ({ + safeNavigate: jest.fn(), + }), + })); +}; + +export default setupCommonMocks; diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index ecfc80f1fe40..33214c9962c4 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -256,7 +256,6 @@ function LogsExplorerViewsContainer({ } = useGetExplorerQueryRange( listChartQuery, PANEL_TYPES.TIME_SERIES, - // ENTITY_VERSION_V4, ENTITY_VERSION_V5, { enabled: @@ -279,7 +278,6 @@ function LogsExplorerViewsContainer({ } = useGetExplorerQueryRange( requestData, panelType, - // ENTITY_VERSION_V4, ENTITY_VERSION_V5, { keepPreviousData: true, diff --git a/frontend/src/container/MeterExplorer/Breakdown/BreakDown.styles.scss b/frontend/src/container/MeterExplorer/Breakdown/BreakDown.styles.scss new file mode 100644 index 000000000000..e63870b0d1ec --- /dev/null +++ b/frontend/src/container/MeterExplorer/Breakdown/BreakDown.styles.scss @@ -0,0 +1,92 @@ +.meter-explorer-breakdown { + display: flex; + flex-direction: column; + + .meter-explorer-date-time { + display: flex; + min-height: 30px; + justify-content: end; + border-bottom: 1px solid var(--bg-slate-500); + padding: 10px 16px; + } + + .meter-explorer-graphs { + display: flex; + flex-direction: column; + padding: 20px; + gap: 36px; + + .meter-column-graph { + .row-card { + background-color: var(--bg-ink-400); + padding-left: 10px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + + .section-title { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.07px; + } + } + + .graph-description { + padding: 10px; + display: flex; + justify-content: center; + align-items: center; + } + + .meter-page-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + align-items: flex-start; + gap: 10px; + .meter-graph { + height: 400px; + padding: 10px; + width: 100%; + box-sizing: border-box; + } + } + } + + .total { + .meter-column-graph { + .meter-page-grid { + grid-template-columns: repeat(3, 1fr); + + .meter-graph { + height: 200px; + } + } + } + } + } +} + +.lightMode { + .meter-explorer-breakdown { + .meter-explorer-date-time { + border-bottom: none; + } + + .meter-explorer-graphs { + .meter-column-graph { + .row-card { + background-color: var(--bg-vanilla-300); + + .section-title { + color: var(--bg-ink-400); + } + } + } + } + } +} diff --git a/frontend/src/container/MeterExplorer/Breakdown/BreakDown.tsx b/frontend/src/container/MeterExplorer/Breakdown/BreakDown.tsx new file mode 100644 index 000000000000..1f8ce1d5ae63 --- /dev/null +++ b/frontend/src/container/MeterExplorer/Breakdown/BreakDown.tsx @@ -0,0 +1,200 @@ +import './BreakDown.styles.scss'; + +import { Typography } from 'antd'; +// import useFilterConfig from 'components/QuickFilters/hooks/useFilterConfig'; +// import { SignalType } from 'components/QuickFilters/types'; +import { QueryParams } from 'constants/query'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import GridCard from 'container/GridCardLayout/GridCard'; +import { Card, CardContainer } from 'container/GridCardLayout/styles'; +import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; +// import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { UpdateTimeInterval } from 'store/actions'; +import { Widgets } from 'types/api/dashboard/getAll'; +// import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +import { + getLogCountWidgetData, + getLogSizeWidgetData, + getMetricCountWidgetData, + getSpanCountWidgetData, + getSpanSizeWidgetData, + getTotalLogSizeWidgetData, + getTotalMetricDatapointCountWidgetData, + getTotalTraceSizeWidgetData, +} from './graphs'; + +type MetricSection = { + id: string; + title: string; + graphs: Widgets[]; +}; + +const sections: MetricSection[] = [ + { + id: uuid(), + title: 'Total', + graphs: [ + getTotalLogSizeWidgetData(), + getTotalTraceSizeWidgetData(), + getTotalMetricDatapointCountWidgetData(), + ], + }, + { + id: uuid(), + title: 'Logs', + graphs: [getLogCountWidgetData(), getLogSizeWidgetData()], + }, + { + id: uuid(), + title: 'Traces', + graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()], + }, + { + id: uuid(), + title: 'Metrics', + graphs: [getMetricCountWidgetData()], + }, +]; + +function Section(section: MetricSection): JSX.Element { + const isDarkMode = useIsDarkMode(); + const { title, graphs } = section; + const history = useHistory(); + const { pathname } = useLocation(); + const dispatch = useDispatch(); + const urlQuery = useUrlQuery(); + + const onDragSelect = useCallback( + (start: number, end: number) => { + const startTimestamp = Math.trunc(start); + const endTimestamp = Math.trunc(end); + + urlQuery.set(QueryParams.startTime, startTimestamp.toString()); + urlQuery.set(QueryParams.endTime, endTimestamp.toString()); + const generatedUrl = `${pathname}?${urlQuery.toString()}`; + history.push(generatedUrl); + + if (startTimestamp !== endTimestamp) { + dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp])); + } + }, + [dispatch, history, pathname, urlQuery], + ); + + return ( +
+ + {title} + +
+ {graphs.map((widget) => ( + + + + ))} +
+
+ ); +} + +// function FilterDropdown({ attrKey }: { attrKey: string }): JSX.Element { +// const { +// data: keyValueSuggestions, +// isLoading: isLoadingKeyValueSuggestions, +// } = useGetQueryKeyValueSuggestions({ +// key: attrKey, +// signal: DataSource.METRICS, +// signalSource: 'meter', +// options: { +// keepPreviousData: true, +// }, +// }); + +// const responseData = keyValueSuggestions?.data as any; +// const values = responseData?.data?.values || {}; +// const stringValues = values.stringValues || []; +// const numberValues = values.numberValues || []; + +// const stringOptions = stringValues.filter( +// (value: string | null | undefined): value is string => +// value !== null && value !== undefined && value !== '', +// ); + +// const numberOptions = numberValues +// .filter( +// (value: number | null | undefined): value is number => +// value !== null && value !== undefined, +// ) +// .map((value: number) => value.toString()); + +// const vals = [...stringOptions, ...numberOptions]; + +// return ( +//
+// {attrKey} +// - -
- )} - -
- {' '} - - - -
- {!precheck.sso && ( -
+ <>
- {' '} +
- {' '} +
-
+ )}
@@ -382,9 +345,9 @@ function SignUp(): JSX.Element { loading={loading} disabled={isValidForm()} className="periscope-btn primary next-btn" - icon={} + block > - Sign Up + Access My Workspace
diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index 9bde92a49a90..c07bfecd269e 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -61,6 +61,7 @@ import { QueryBuilderData, } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy'; import { v4 as uuid } from 'uuid'; export const QueryBuilderContext = createContext({ @@ -102,6 +103,12 @@ export function QueryBuilderProvider({ const currentPathnameRef = useRef(location.pathname); + // This is used to determine if the query was called from the handleRunQuery function - which means manual trigger from Stage and Run button + const [ + calledFromHandleRunQuery, + setCalledFromHandleRunQuery, + ] = useState(false); + const { maxTime, minTime } = useSelector( (state) => state.globalTime, ); @@ -184,6 +191,17 @@ export function QueryBuilderProvider({ } as BaseAutocompleteData, }; + // Explorer pages: sanitize stale orderBy before first query + const isExplorer = + location.pathname === ROUTES.LOGS_EXPLORER || + location.pathname === ROUTES.TRACES_EXPLORER; + if (isExplorer) { + const sanitizedOrderBy = sanitizeOrderByForExplorer(currentElement); + return calledFromHandleRunQuery + ? currentElement + : { ...currentElement, orderBy: sanitizedOrderBy }; + } + return currentElement; }); @@ -215,7 +233,7 @@ export function QueryBuilderProvider({ return nextQuery; }, - [initialDataSource], + [initialDataSource, location.pathname, calledFromHandleRunQuery], ); const initQueryBuilderData = useCallback( @@ -428,6 +446,7 @@ export function QueryBuilderProvider({ const newQuery: IBuilderQuery = { ...initialBuilderQuery, + source: queries?.[0]?.source || '', queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }), expression: createNewBuilderItemName({ existNames, @@ -522,6 +541,8 @@ export function QueryBuilderProvider({ setCurrentQuery((prevState) => { if (prevState.builder.queryData.length >= MAX_QUERIES) return prevState; + console.log('prevState', prevState.builder.queryData); + const newQuery = createNewBuilderQuery(prevState.builder.queryData); return { @@ -532,6 +553,7 @@ export function QueryBuilderProvider({ }, }; }); + // eslint-disable-next-line sonarjs/no-identical-functions setSupersetQuery((prevState) => { if (prevState.builder.queryData.length >= MAX_QUERIES) return prevState; @@ -867,6 +889,12 @@ export function QueryBuilderProvider({ const handleRunQuery = useCallback( (shallUpdateStepInterval?: boolean, newQBQuery?: boolean) => { + const isExplorer = + location.pathname === ROUTES.LOGS_EXPLORER || + location.pathname === ROUTES.TRACES_EXPLORER; + if (isExplorer) { + setCalledFromHandleRunQuery(true); + } let currentQueryData = currentQuery; if (newQBQuery) { @@ -911,7 +939,14 @@ export function QueryBuilderProvider({ queryType, }); }, - [currentQuery, queryType, maxTime, minTime, redirectWithQueryBuilderData], + [ + location.pathname, + currentQuery, + queryType, + maxTime, + minTime, + redirectWithQueryBuilderData, + ], ); useEffect(() => { @@ -921,6 +956,7 @@ export function QueryBuilderProvider({ setStagedQuery(null); // reset the last used query to 0 when navigating away from the page setLastUsedQuery(0); + setCalledFromHandleRunQuery(false); } }, [location.pathname]); diff --git a/frontend/src/types/api/user/signup.ts b/frontend/src/types/api/user/signup.ts index 163f414b45bd..a420780516a7 100644 --- a/frontend/src/types/api/user/signup.ts +++ b/frontend/src/types/api/user/signup.ts @@ -1,5 +1,4 @@ export interface Props { - name: string; orgDisplayName: string; email: string; password: string; diff --git a/frontend/src/utils/__tests__/sanitizeOrderBy.test.ts b/frontend/src/utils/__tests__/sanitizeOrderBy.test.ts new file mode 100644 index 000000000000..633d9a7efc8f --- /dev/null +++ b/frontend/src/utils/__tests__/sanitizeOrderBy.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + OrderByPayload, +} from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter'; +import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy'; + +jest.mock('utils/aggregationConverter', () => ({ + getParsedAggregationOptionsForOrderBy: jest.fn(), +})); + +const buildQuery = (overrides: Partial = {}): IBuilderQuery => ({ + queryName: 'A', + dataSource: DataSource.TRACES, + aggregateOperator: '', + aggregateAttribute: undefined, + aggregations: [], + timeAggregation: '', + spaceAggregation: '', + temporality: '', + functions: [], + filter: { expression: '' } as any, + filters: { items: [], op: 'AND' } as any, + groupBy: [], + expression: '', + disabled: false, + having: [] as any, + limit: null, + stepInterval: 60 as any, + orderBy: [], + legend: '', + ...overrides, +}); + +describe('sanitizeOrderByForExplorer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps only orderBy items that are present in groupBy keys or aggregation keys (including alias)', () => { + (getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([ + { + key: 'count()', + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + }, + { + key: 'avg(duration)', + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + }, + { + key: 'latency', + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + }, + ]); + + const orderBy: OrderByPayload[] = [ + { columnName: 'service.name', order: 'asc' }, + { columnName: 'count()', order: 'desc' }, + { columnName: 'avg(duration)', order: 'asc' }, + { columnName: 'latency', order: 'asc' }, // alias + { columnName: 'not-allowed', order: 'desc' }, // invalid orderBy + { columnName: 'timestamp', order: 'desc' }, // invalid orderBy + ]; + + const query = buildQuery({ + groupBy: [ + { + key: 'service.name', + dataType: DataTypes.String, + isColumn: true, + type: 'resource', + isJSON: false, + }, + ] as any, + orderBy, + }); + + const result = sanitizeOrderByForExplorer(query); + + expect(result).toEqual([ + { columnName: 'service.name', order: 'asc' }, + { columnName: 'count()', order: 'desc' }, + { columnName: 'avg(duration)', order: 'asc' }, + { columnName: 'latency', order: 'asc' }, + ]); + }); + + it('returns empty when none of the orderBy items are allowed', () => { + (getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([ + { + key: 'count()', + dataType: DataTypes.Float64, + isColumn: false, + type: '', + isJSON: false, + }, + ]); + + const query = buildQuery({ + groupBy: [], + orderBy: [ + { columnName: 'foo', order: 'asc' }, + { columnName: 'bar', order: 'desc' }, + ], + }); + + const result = sanitizeOrderByForExplorer(query); + expect(result).toEqual([]); + }); + + it('handles missing orderBy by returning an empty array', () => { + (getParsedAggregationOptionsForOrderBy as jest.Mock).mockReturnValue([]); + + const query = buildQuery({ orderBy: [] }); + const result = sanitizeOrderByForExplorer(query); + expect(result).toEqual([]); + }); +}); diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 8721a47e0c90..5aeea203af31 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -124,6 +124,6 @@ export const routePermission: Record = { API_MONITORING_BASE: ['ADMIN', 'EDITOR', 'VIEWER'], MESSAGING_QUEUES_BASE: ['ADMIN', 'EDITOR', 'VIEWER'], METER_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'], - METER_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'], + METER: ['ADMIN', 'EDITOR', 'VIEWER'], METER_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'], }; diff --git a/frontend/src/utils/sanitizeOrderBy.ts b/frontend/src/utils/sanitizeOrderBy.ts new file mode 100644 index 000000000000..098996e7d06b --- /dev/null +++ b/frontend/src/utils/sanitizeOrderBy.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/react'; +import { + IBuilderQuery, + OrderByPayload, +} from 'types/api/queryBuilder/queryBuilderData'; + +import { getParsedAggregationOptionsForOrderBy } from './aggregationConverter'; + +export function sanitizeOrderByForExplorer( + query: IBuilderQuery, +): OrderByPayload[] { + const allowed = new Set(); + (query.groupBy || []).forEach((g) => g?.key && allowed.add(g.key)); + getParsedAggregationOptionsForOrderBy(query).forEach((agg) => { + // agg.key is the expression or alias (e.g., count(), avg(quantity), 'alias') + if ((agg as any)?.key) allowed.add((agg as any).key as string); + }); + + const current = query.orderBy || []; + + const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName)); + + if (hasInvalidOrderBy) { + Sentry.captureEvent({ + message: `Invalid orderBy: current: ${JSON.stringify( + current, + )} - allowed: ${JSON.stringify(Array.from(allowed))}`, + level: 'warning', + }); + } + return current.filter((o) => allowed.has(o.columnName)); +} diff --git a/frontend/src/utils/tokenUtils.ts b/frontend/src/utils/tokenUtils.ts index 3f1eea4c7eea..96fc830c13d0 100644 --- a/frontend/src/utils/tokenUtils.ts +++ b/frontend/src/utils/tokenUtils.ts @@ -100,16 +100,36 @@ export function isFunctionOperator(operator: string): boolean { const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS); const sanitizedOperator = operator.trim(); - // Check if it's a direct function operator - if (functionOperators.includes(sanitizedOperator)) { + // Check if it's a direct function operator (case-insensitive) + if ( + functionOperators.some( + (func) => func.toLowerCase() === sanitizedOperator.toLowerCase(), + ) + ) { return true; } // Check if it's a NOT function operator (e.g., "NOT has") if (sanitizedOperator.toUpperCase().startsWith(OPERATORS.NOT)) { const operatorWithoutNot = sanitizedOperator.substring(4).toLowerCase(); - return functionOperators.includes(operatorWithoutNot); + return functionOperators.some( + (func) => func.toLowerCase() === operatorWithoutNot, + ); } return false; } + +export function isNonValueOperator(operator: string): boolean { + const upperOperator = operator.toUpperCase(); + // Check if it's a direct non-value operator + if (NON_VALUE_OPERATORS.includes(upperOperator)) { + return true; + } + // Check if it's a NOT non-value operator (e.g., "NOT EXISTS") + if (upperOperator.startsWith(OPERATORS.NOT)) { + const operatorWithoutNot = upperOperator.substring(4).trim(); // Remove "NOT " prefix + return NON_VALUE_OPERATORS.includes(operatorWithoutNot); + } + return false; +} diff --git a/pkg/querier/bucket_cache.go b/pkg/querier/bucket_cache.go index 5f37ec6a08e2..da6493a4206d 100644 --- a/pkg/querier/bucket_cache.go +++ b/pkg/querier/bucket_cache.go @@ -490,7 +490,6 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac key string } seriesMap := make(map[seriesKey]*qbtypes.TimeSeries, estimatedSeries) - var queryName string for _, bucket := range buckets { var tsData *qbtypes.TimeSeriesData @@ -499,11 +498,6 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac continue } - // Preserve the query name from the first bucket - if queryName == "" && tsData.QueryName != "" { - queryName = tsData.QueryName - } - for _, aggBucket := range tsData.Aggregations { for _, series := range aggBucket.Series { // Create series key from labels @@ -549,7 +543,6 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*cac // Convert map back to slice result := &qbtypes.TimeSeriesData{ - QueryName: queryName, Aggregations: make([]*qbtypes.AggregationBucket, 0, len(aggMap)), } @@ -738,9 +731,7 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun case qbtypes.RequestTypeTimeSeries: // Trim time series data if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil { - trimmedData := &qbtypes.TimeSeriesData{ - QueryName: tsData.QueryName, - } + trimmedData := &qbtypes.TimeSeriesData{} for _, aggBucket := range tsData.Aggregations { trimmedBucket := &qbtypes.AggregationBucket{ @@ -807,7 +798,6 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs, case qbtypes.RequestTypeTimeSeries: if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok { filteredData := &qbtypes.TimeSeriesData{ - QueryName: tsData.QueryName, Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)), } diff --git a/pkg/querier/bucket_cache_test.go b/pkg/querier/bucket_cache_test.go index 57d9a53aca2f..c52fdfb2e474 100644 --- a/pkg/querier/bucket_cache_test.go +++ b/pkg/querier/bucket_cache_test.go @@ -169,9 +169,8 @@ func TestBucketCache_Put_And_Get(t *testing.T) { assert.Equal(t, []string{"test warning"}, cached.Warnings) // Verify the time series data - tsData, ok := cached.Value.(*qbtypes.TimeSeriesData) + _, ok := cached.Value.(*qbtypes.TimeSeriesData) require.True(t, ok) - assert.Equal(t, "A", tsData.QueryName) } func TestBucketCache_PartialHit(t *testing.T) { @@ -1077,7 +1076,6 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) { // Verify the cached result only contains values within the requested range tsData, ok := cached.Value.(*qbtypes.TimeSeriesData) require.True(t, ok) - assert.Equal(t, "A", tsData.QueryName) require.Len(t, tsData.Aggregations, 1) require.Len(t, tsData.Aggregations[0].Series, 1) diff --git a/pkg/querier/querier.go b/pkg/querier/querier.go index 5150a9f54d38..849693c2a428 100644 --- a/pkg/querier/querier.go +++ b/pkg/querier/querier.go @@ -470,6 +470,15 @@ func (q *querier) run( if err != nil { return nil, err } + switch v := result.Value.(type) { + case *qbtypes.TimeSeriesData: + v.QueryName = name + case *qbtypes.ScalarData: + v.QueryName = name + case *qbtypes.RawData: + v.QueryName = name + } + results[name] = result.Value warnings = append(warnings, result.Warnings...) warningsDocURL = result.WarningsDocURL diff --git a/pkg/telemetrymeter/statement_builder.go b/pkg/telemetrymeter/statement_builder.go index 0b46415c1710..bee759c73e3b 100644 --- a/pkg/telemetrymeter/statement_builder.go +++ b/pkg/telemetrymeter/statement_builder.go @@ -75,8 +75,9 @@ func (b *meterQueryStatementBuilder) buildPipelineStatement( if b.metricsStatementBuilder.CanShortCircuitDelta(query) { // spatial_aggregation_cte directly for certain delta queries - frag, args := b.buildTemporalAggDeltaFastPath(ctx, start, end, query, keys, variables) - if frag != "" { + if frag, args, err := b.buildTemporalAggDeltaFastPath(ctx, start, end, query, keys, variables); err != nil { + return nil, err + } else if frag != "" { cteFragments = append(cteFragments, frag) cteArgs = append(cteArgs, args) } @@ -107,7 +108,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath( query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], keys map[string][]*telemetrytypes.TelemetryFieldKey, variables map[string]qbtypes.VariableItem, -) (string, []any) { +) (string, []any, error) { var filterWhere *querybuilder.PreparedWhereClause var err error stepSec := int64(query.StepInterval.Seconds()) @@ -121,7 +122,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath( for _, g := range query.GroupBy { col, err := b.fm.ColumnExpressionFor(ctx, &g.TelemetryFieldKey, keys) if err != nil { - return "", []any{} + return "", []any{}, err } sb.SelectMore(col) } @@ -149,7 +150,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath( Variables: variables, }) if err != nil { - return "", []any{} + return "", []any{}, err } } if filterWhere != nil { @@ -163,7 +164,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath( sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...) q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse) - return fmt.Sprintf("__spatial_aggregation_cte AS (%s)", q), args + return fmt.Sprintf("__spatial_aggregation_cte AS (%s)", q), args, nil } func (b *meterQueryStatementBuilder) buildTemporalAggregationCTE( diff --git a/pkg/telemetrymetrics/statement_builder.go b/pkg/telemetrymetrics/statement_builder.go index 7c92fb811ca0..55b086a7224d 100644 --- a/pkg/telemetrymetrics/statement_builder.go +++ b/pkg/telemetrymetrics/statement_builder.go @@ -114,6 +114,7 @@ func GetKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) keySelectors[idx].MetricContext = &telemetrytypes.MetricContext{ MetricName: query.Aggregations[0].MetricName, } + keySelectors[idx].Source = query.Source } return keySelectors }