diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 115c6a4a4a7..1b06632d1e2 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -8024,13 +8024,6 @@ components: - schemaVersion - spec type: object - ServiceaccounttypesDeprecatedPostableServiceAccountRole: - properties: - id: - type: string - required: - - id - type: object ServiceaccounttypesGettableFactorAPIKey: properties: createdAt: @@ -13262,117 +13255,6 @@ paths: summary: Gets service account roles tags: - serviceaccount - post: - deprecated: true - description: This endpoint assigns a role to a service account - operationId: CreateServiceAccountRoleDeprecated - parameters: - - in: path - name: id - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole' - responses: - "201": - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/TypesIdentifiable' - status: - type: string - required: - - status - - data - type: object - description: Created - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Bad Request - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - serviceaccount:attach - - role:attach - - tokenizer: - - serviceaccount:attach - - role:attach - summary: Create service account role - tags: - - serviceaccount - /api/v1/service_accounts/{id}/roles/{rid}: - delete: - deprecated: true - description: This endpoint revokes a role from service account - operationId: DeleteServiceAccountRoleDeprecated - parameters: - - in: path - name: id - required: true - schema: - type: string - - in: path - name: rid - required: true - schema: - type: string - responses: - "204": - description: No Content - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - serviceaccount:detach - - role:detach - - tokenizer: - - serviceaccount:detach - - role:detach - summary: Delete service account role - tags: - - serviceaccount /api/v1/service_accounts/me: get: deprecated: false @@ -14376,9 +14258,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:list - tokenizer: - - ADMIN + - auth-domain:list summary: List all auth domains tags: - authdomains @@ -14438,9 +14320,13 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:create + - auth-domain:attach + - role:attach - tokenizer: - - ADMIN + - auth-domain:create + - auth-domain:attach + - role:attach summary: Create auth domain tags: - authdomains @@ -14484,9 +14370,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:delete - tokenizer: - - ADMIN + - auth-domain:delete summary: Delete auth domain tags: - authdomains @@ -14541,9 +14427,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:read - tokenizer: - - ADMIN + - auth-domain:read summary: Get auth domain by ID tags: - authdomains @@ -14597,9 +14483,17 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:update + - auth-domain:attach + - auth-domain:detach + - role:attach + - role:detach - tokenizer: - - ADMIN + - auth-domain:update + - auth-domain:attach + - auth-domain:detach + - role:attach + - role:detach summary: Update auth domain tags: - authdomains diff --git a/frontend/src/api/generated/services/serviceaccount/index.ts b/frontend/src/api/generated/services/serviceaccount/index.ts index 93b128f9792..1bd6ca2a47f 100644 --- a/frontend/src/api/generated/services/serviceaccount/index.ts +++ b/frontend/src/api/generated/services/serviceaccount/index.ts @@ -22,10 +22,7 @@ import type { CreateServiceAccountKey201, CreateServiceAccountKeyPathParameters, CreateServiceAccountRole201, - CreateServiceAccountRoleDeprecated201, - CreateServiceAccountRoleDeprecatedPathParameters, DeleteServiceAccountPathParameters, - DeleteServiceAccountRoleDeprecatedPathParameters, DeleteServiceAccountRolePathParameters, GetMyServiceAccount200, GetServiceAccount200, @@ -39,7 +36,6 @@ import type { ListServiceAccounts200, RenderErrorResponseDTO, RevokeServiceAccountKeyPathParameters, - ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO, ServiceaccounttypesPostableFactorAPIKeyDTO, ServiceaccounttypesPostableServiceAccountDTO, ServiceaccounttypesPostableServiceAccountRoleDTO, @@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async ( return queryClient; }; -/** - * This endpoint assigns a role to a service account - * @deprecated - * @summary Create service account role - */ -export const createServiceAccountRoleDeprecated = ( - { id }: CreateServiceAccountRoleDeprecatedPathParameters, - serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/service_accounts/${id}/roles`, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO, - signal, - }); -}; - -export const getCreateServiceAccountRoleDeprecatedMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext -> => { - const mutationKey = ['createServiceAccountRoleDeprecated']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - } - > = (props) => { - const { pathParams, data } = props ?? {}; - - return createServiceAccountRoleDeprecated(pathParams, data); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable< - Awaited> ->; -export type CreateServiceAccountRoleDeprecatedMutationBody = - | BodyType - | undefined; -export type CreateServiceAccountRoleDeprecatedMutationError = - ErrorType; - -/** - * @deprecated - * @summary Create service account role - */ -export const useCreateServiceAccountRoleDeprecated = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext -> => { - return useMutation( - getCreateServiceAccountRoleDeprecatedMutationOptions(options), - ); -}; -/** - * This endpoint revokes a role from service account - * @deprecated - * @summary Delete service account role - */ -export const deleteServiceAccountRoleDeprecated = ( - { id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/service_accounts/${id}/roles/${rid}`, - method: 'DELETE', - signal, - }); -}; - -export const getDeleteServiceAccountRoleDeprecatedMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext -> => { - const mutationKey = ['deleteServiceAccountRoleDeprecated']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters } - > = (props) => { - const { pathParams } = props ?? {}; - - return deleteServiceAccountRoleDeprecated(pathParams); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable< - Awaited> ->; - -export type DeleteServiceAccountRoleDeprecatedMutationError = - ErrorType; - -/** - * @deprecated - * @summary Delete service account role - */ -export const useDeleteServiceAccountRoleDeprecated = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext -> => { - return useMutation( - getDeleteServiceAccountRoleDeprecatedMutationOptions(options), - ); -}; /** * This endpoint gets my service account * @summary Gets my service account diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 65ef281cb5b..331e7ffabbc 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -9092,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO { spec: SavedviewtypesSavedViewSpecDTO; } -export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO { - /** - * @type string - */ - id: string; -} - export interface ServiceaccounttypesGettableFactorAPIKeyDTO { /** * @type string @@ -11020,21 +11013,6 @@ export type GetServiceAccountRoles200 = { status: string; }; -export type CreateServiceAccountRoleDeprecatedPathParameters = { - id: string; -}; -export type CreateServiceAccountRoleDeprecated201 = { - data: TypesIdentifiableDTO; - /** - * @type string - */ - status: string; -}; - -export type DeleteServiceAccountRoleDeprecatedPathParameters = { - id: string; - rid: string; -}; export type GetMyServiceAccount200 = { data: ServiceaccounttypesServiceAccountWithRolesDTO; /** diff --git a/frontend/src/assets/Logos/azure.svg b/frontend/src/assets/Logos/azure.svg new file mode 100644 index 00000000000..fa4fb79256c --- /dev/null +++ b/frontend/src/assets/Logos/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logos/cloudnative-pg.svg b/frontend/src/assets/Logos/cloudnative-pg.svg new file mode 100644 index 00000000000..856041e8e77 --- /dev/null +++ b/frontend/src/assets/Logos/cloudnative-pg.svg @@ -0,0 +1 @@ +CloudNativePG Icon - Color diff --git a/frontend/src/assets/Logos/dify.svg b/frontend/src/assets/Logos/dify.svg new file mode 100644 index 00000000000..73ccf88eccc --- /dev/null +++ b/frontend/src/assets/Logos/dify.svg @@ -0,0 +1,5 @@ + + Dify + + + diff --git a/frontend/src/assets/Logos/from-log-file.svg b/frontend/src/assets/Logos/from-log-file.svg index e1aa82a96dd..4464747be13 100644 --- a/frontend/src/assets/Logos/from-log-file.svg +++ b/frontend/src/assets/Logos/from-log-file.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/assets/Logos/github-copilot.svg b/frontend/src/assets/Logos/github-copilot.svg new file mode 100644 index 00000000000..4671aed9b0f --- /dev/null +++ b/frontend/src/assets/Logos/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logos/keda.svg b/frontend/src/assets/Logos/keda.svg new file mode 100644 index 00000000000..a36aa275462 --- /dev/null +++ b/frontend/src/assets/Logos/keda.svg @@ -0,0 +1 @@ +keda-icon diff --git a/frontend/src/assets/Logos/kong.svg b/frontend/src/assets/Logos/kong.svg new file mode 100644 index 00000000000..d808c03e79e --- /dev/null +++ b/frontend/src/assets/Logos/kong.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Logos/neon.svg b/frontend/src/assets/Logos/neon.svg new file mode 100644 index 00000000000..10fbd24b161 --- /dev/null +++ b/frontend/src/assets/Logos/neon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss deleted file mode 100644 index bb1fa9678ce..00000000000 --- a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.client-side-qb-search { - .ant-select-selection-search { - width: max-content !important; - } -} diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx deleted file mode 100644 index 435188243be..00000000000 --- a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx +++ /dev/null @@ -1,661 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ - -import { - KeyboardEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Color } from '@signozhq/design-tokens'; -import { Select, Tooltip } from 'antd'; -import { - OPERATORS, - QUERY_BUILDER_OPERATORS_BY_TYPES, - QUERY_BUILDER_SEARCH_VALUES, -} from 'constants/queryBuilder'; -import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch'; -import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config'; -import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant'; -import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style'; -import { - checkCommaInValue, - getOperatorFromValue, - getOperatorValue, - getTagToken, - isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { - DropdownState, - ITag, - Option, -} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2'; -import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; -import { validationMapper } from 'hooks/queryBuilder/useIsValidTag'; -import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType'; -import { useIsDarkMode } from 'hooks/useDarkMode'; -import { isArray, isEmpty, isEqual, isObject } from 'lodash-es'; -import { ChevronDown, ChevronUp } from '@signozhq/icons'; -import type { BaseSelectRef } from 'rc-select'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { popupContainer } from 'utils/selectPopupContainer'; -import { v4 as uuid } from 'uuid'; - -import './ClientSideQBSearch.styles.scss'; -import { Badge } from '@signozhq/ui/badge'; - -export interface AttributeKey { - key: string; -} - -export interface AttributeValuesMap { - [key: string]: AttributeValue; -} - -interface ClientSideQBSearchProps { - filters: TagFilter; - onChange: (value: TagFilter) => void; - whereClauseConfig?: WhereClauseConfig; - placeholder?: string; - className?: string; - suffixIcon?: React.ReactNode; - attributeValuesMap?: AttributeValuesMap; - attributeKeys: AttributeKey[]; -} - -interface AttributeValue { - stringAttributeValues: string[] | []; - numberAttributeValues: number[] | []; - boolAttributeValues: boolean[] | []; -} - -function ClientSideQBSearch( - props: ClientSideQBSearchProps, -): React.ReactElement { - const { - onChange, - placeholder, - className, - suffixIcon, - whereClauseConfig, - attributeValuesMap, - attributeKeys, - filters, - } = props; - - const isDarkMode = useIsDarkMode(); - - const selectRef = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - - // create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync. - const [tags, setTags] = useState(filters.items as ITag[]); - - // this will maintain the current state of in process filter item - const [currentFilterItem, setCurrentFilterItem] = useState(); - - const [currentState, setCurrentState] = useState( - DropdownState.ATTRIBUTE_KEY, - ); - - // to maintain the current running state until the tokenization happens for the tag - const [searchValue, setSearchValue] = useState(''); - - const [dropdownOptions, setDropdownOptions] = useState([]); - - const attributeValues = useMemo(() => { - if (currentFilterItem?.key?.key) { - return attributeValuesMap?.[currentFilterItem.key.key]; - } - return { - stringAttributeValues: [], - numberAttributeValues: [], - boolAttributeValues: [], - }; - }, [attributeValuesMap, currentFilterItem?.key?.key]); - - const handleDropdownSelect = useCallback( - (value: string) => { - let parsedValue: BaseAutocompleteData | string; - - try { - parsedValue = JSON.parse(value); - } catch { - parsedValue = value; - } - if (currentState === DropdownState.ATTRIBUTE_KEY) { - setCurrentFilterItem((prev) => ({ - ...prev, - key: parsedValue as BaseAutocompleteData, - op: '', - value: '', - })); - setCurrentState(DropdownState.OPERATOR); - setSearchValue((parsedValue as BaseAutocompleteData)?.key); - } else if (currentState === DropdownState.OPERATOR) { - if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: value, - value: '', - } as ITag, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: value as string, - value: '', - })); - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - setSearchValue(`${currentFilterItem?.key?.key} ${value}`); - } - } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { - const operatorType = - operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; - const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY; - - if (isMulti) { - const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); - // this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value. - // not the best interaction but in sync with what we have today! - if (tagValue.includes(String(value))) { - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - setCurrentFilterItem(undefined); - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value: tagValue, - } as ITag, - ]); - return; - } - // this is for adding subsequent comma seperated values - const newSearch = [...tagValue]; - newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value; - const newSearchValue = newSearch.join(','); - setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`); - } else { - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - setCurrentFilterItem(undefined); - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value, - } as ITag, - ]); - } - } - }, - [currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue], - ); - - const handleSearch = useCallback((value: string) => { - setSearchValue(value); - }, []); - - const onInputKeyDownHandler = useCallback( - (event: KeyboardEvent): void => { - if (event.key === 'Backspace' && !searchValue) { - event.stopPropagation(); - setTags((prev) => prev.slice(0, -1)); - } - }, - [searchValue], - ); - - const handleOnBlur = useCallback((): void => { - if (searchValue) { - const operatorType = - operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; - // if key is added and operator is not present then convert to body CONTAINS key - if ( - currentFilterItem?.key && - isEmpty(currentFilterItem?.op) && - whereClauseConfig?.customKey === 'body' && - whereClauseConfig?.customOp === OPERATORS.CONTAINS - ) { - setTags((prev) => [ - ...prev, - { - key: { - key: 'body', - dataType: DataTypes.String, - type: '', - id: 'body--string----true', - }, - op: OPERATORS.CONTAINS, - value: currentFilterItem?.key?.key, - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if ( - currentFilterItem?.op === OPERATORS.EXISTS || - currentFilterItem?.op === OPERATORS.NOT_EXISTS - ) { - // is exists and not exists operator is present then convert directly to tag! no need of value here - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value: '', - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if ( - // if the current state is in sync with the kind of operator used then convert into a tag - validationMapper[operatorType]?.( - isArray(currentFilterItem?.value) - ? currentFilterItem?.value.length || 0 - : 1, - ) - ) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key as BaseAutocompleteData, - op: currentFilterItem?.op as string, - value: currentFilterItem?.value || '', - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } - } - }, [ - currentFilterItem?.key, - currentFilterItem?.op, - currentFilterItem?.value, - searchValue, - whereClauseConfig?.customKey, - whereClauseConfig?.customOp, - ]); - - // this useEffect takes care of tokenisation based on the search state - useEffect(() => { - // if there is no search value reset to the default state - if (!searchValue) { - setCurrentFilterItem(undefined); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } - - // split the current search value based on delimiters - const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); - - if ( - // Case 1 - if key is defined but the search text doesn't match with the set key, - // can happen when user selects from dropdown and then deletes a few characters - currentFilterItem?.key && - currentFilterItem?.key?.key !== tagKey.split(' ')[0] - ) { - setCurrentFilterItem(undefined); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if (tagOperator && isEmpty(currentFilterItem?.op)) { - // Case 2 -> key is set and now typing for the operator - if ( - tagOperator === OPERATORS.EXISTS || - tagOperator === OPERATORS.NOT_EXISTS - ) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: tagOperator, - value: '', - } as ITag, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: tagOperator, - value: '', - })); - - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - } - } else if ( - // Case 3 -> selected operator from dropdown and then erased a part of it - !isEmpty(currentFilterItem?.op) && - tagOperator !== currentFilterItem?.op - ) { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: '', - value: '', - })); - setCurrentState(DropdownState.OPERATOR); - } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { - // Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either - // dropdown click or blur event - const currentValue = { - key: currentFilterItem?.key as BaseAutocompleteData, - op: currentFilterItem?.op as string, - value: tagValue, - }; - if (!isEqual(currentValue, currentFilterItem)) { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: prev?.op as string, - value: tagValue, - })); - } - } - }, [ - currentFilterItem, - currentFilterItem?.key, - currentFilterItem?.op, - searchValue, - currentState, - ]); - - // the useEffect takes care of setting the dropdown values correctly on change of the current state - useEffect(() => { - if (currentState === DropdownState.ATTRIBUTE_KEY) { - const filteredAttributeKeys = attributeKeys.filter((key) => - key.key.startsWith(searchValue), - ); - setDropdownOptions( - filteredAttributeKeys?.map( - (key) => - ({ - label: key.key, - value: key, - }) as Option, - ) || [], - ); - } - if (currentState === DropdownState.OPERATOR) { - const keyOperator = searchValue.split(' '); - const partialOperator = keyOperator?.[1]; - const strippedKey = keyOperator?.[0]; - - let operatorOptions; - if (currentFilterItem?.key?.dataType) { - operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[ - currentFilterItem.key - .dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES - ].map((operator) => ({ - label: operator, - value: operator, - })); - - if (partialOperator) { - operatorOptions = operatorOptions.filter((op) => - op.label.startsWith(partialOperator.toLocaleUpperCase()), - ); - } - setDropdownOptions(operatorOptions); - } else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) { - operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({ - label: operator, - value: operator, - })); - setDropdownOptions(operatorOptions); - } else { - operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map( - (operator) => ({ - label: operator, - value: operator, - }), - ); - - if (partialOperator) { - operatorOptions = operatorOptions.filter((op) => - op.label.startsWith(partialOperator.toLocaleUpperCase()), - ); - } - setDropdownOptions(operatorOptions); - } - } - - if (currentState === DropdownState.ATTRIBUTE_VALUE) { - const values: Array = []; - const { tagValue } = getTagToken(searchValue); - if (isArray(tagValue)) { - if (!isEmpty(tagValue[tagValue.length - 1])) { - values.push(tagValue[tagValue.length - 1]); - } - } else if (!isEmpty(tagValue)) { - values.push(tagValue); - } - - const currentAttributeValues = - attributeValues?.stringAttributeValues || - attributeValues?.numberAttributeValues || - attributeValues?.boolAttributeValues || - []; - - values.push(...currentAttributeValues); - - if (attributeValuesMap) { - setDropdownOptions( - values.map( - (val) => - ({ - label: checkCommaInValue(String(val)), - value: val, - }) as Option, - ), - ); - } else { - // If attributeValuesMap is not provided, don't set dropdown options - setDropdownOptions([]); - } - } - }, [ - attributeValues, - currentFilterItem?.key?.dataType, - currentState, - attributeKeys, - searchValue, - attributeValuesMap, - ]); - - useEffect(() => { - const filterTags: IBuilderQuery['filters'] = { - op: 'AND', - items: [], - }; - tags.forEach((tag) => { - const computedTagValue = - tag.value && - Array.isArray(tag.value) && - tag.value[tag.value.length - 1] === '' - ? tag.value?.slice(0, -1) - : (tag.value ?? ''); - filterTags.items.push({ - id: tag.id || uuid().slice(0, 8), - key: tag.key, - op: getOperatorValue(tag.op), - value: computedTagValue, - }); - }); - - if (!isEqual(filters, filterTags)) { - onChange(filterTags); - setTags( - filterTags.items.map((tag) => ({ - ...tag, - op: getOperatorFromValue(tag.op), - })) as ITag[], - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tags]); - - const queryTags = useMemo( - () => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`), - [tags], - ); - - const onTagRender = ({ - value, - closable, - onClose, - }: CustomTagProps): React.ReactElement => { - const { tagOperator } = getTagToken(value); - const isInNin = isInNInOperator(tagOperator); - const chipValue = isInNin - ? value?.trim()?.replace(/,\s*$/, '') - : value?.trim(); - - const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value)); - const tagDetails = tags[indexInQueryTags]; - - const onCloseHandler = (): void => { - onClose(); - setSearchValue(''); - setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); - }; - - const tagEditHandler = (value: string): void => { - setCurrentFilterItem(tagDetails); - setSearchValue(value); - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); - }; - - const isDisabled = !!searchValue; - - return ( - - { - e.preventDefault(); - onCloseHandler(); - }} - > - - { - if (!isDisabled) { - tagEditHandler(value); - } - }} - > - {chipValue} - - - - - ); - }; - - const suffixIconContent = useMemo(() => { - if (suffixIcon) { - return suffixIcon; - } - return isOpen ? ( - - ) : ( - - ); - }, [isDarkMode, isOpen, suffixIcon]); - - return ( -
- -
- ); -} - -ClientSideQBSearch.defaultProps = { - placeholder: PLACEHOLDER, - className: '', - suffixIcon: null, - whereClauseConfig: {}, - attributeValuesMap: {}, -}; - -export default ClientSideQBSearch; diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 4d2a01cde9f..a5fee05f9c1 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -8,7 +8,7 @@ import { OPERATORS, QUERY_BUILDER_FUNCTIONS, } from 'constants/antlrQueryConstants'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import FilterQueryLexer from 'parser/FilterQueryLexer'; import FilterQueryParser, { AndExpressionContext, diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts index 9ff528c58dd..1e855557fb5 100644 --- a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts @@ -5,7 +5,7 @@ import { QuickFiltersSource, } from 'components/QuickFilters/types'; import { OPERATORS } from 'constants/antlrQueryConstants'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { cloneDeep, isArray } from 'lodash-es'; import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { v4 as uuid } from 'uuid'; diff --git a/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts b/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts new file mode 100644 index 00000000000..9ecd4a89730 --- /dev/null +++ b/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts @@ -0,0 +1,280 @@ +import { renderHook } from '@testing-library/react'; + +import { + useRecoverFromEmptyPage, + UseRecoverFromEmptyPageParams, +} from '../useRecoverFromEmptyPage'; + +const REPLACE = { history: 'replace' }; + +function renderRecovery( + overrides: Partial = {}, +): { setPage: jest.Mock; rerender: (next?: unknown) => void } { + const setPage = jest.fn(); + const props: UseRecoverFromEmptyPageParams = { + page: 1, + pageSize: 10, + rowCount: 10, + total: 100, + isFetching: false, + setPage, + ...overrides, + }; + + const { rerender } = renderHook( + (next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next), + { initialProps: props }, + ); + + return { + setPage, + rerender: (next?: unknown): void => + rerender({ ...props, ...(next as Partial) }), + }; +} + +describe('useRecoverFromEmptyPage', () => { + it('leaves the page alone while it still holds rows', () => { + const { setPage } = renderRecovery({ page: 3, rowCount: 10 }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('leaves the page alone on page 1 with no rows at all', () => { + const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('jumps to the last page that holds data when the page is out of range', () => { + const { setPage } = renderRecovery({ + page: 7, + pageSize: 10, + rowCount: 0, + total: 25, + }); + + expect(setPage).toHaveBeenCalledWith(3, REPLACE); + }); + + it('replaces the history entry so the back button does not return to the empty page', () => { + const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('falls back to page 1 when the total is unknown', () => { + const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('steps back one page when the total claims the page should have data', () => { + // total says 100 rows exist, yet page 5 came back empty — step back rather + // than stall on a page the query cannot actually serve. + const { setPage } = renderRecovery({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledWith(4, REPLACE); + }); + + it('clamps a page below the first one', () => { + const { setPage } = renderRecovery({ page: 0, rowCount: 10 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('falls back to page 1 when pageSize is zero', () => { + const { setPage } = renderRecovery({ + page: 5, + pageSize: 0, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('waits for the request to settle before moving the user', () => { + const { setPage, rerender } = renderRecovery({ + page: 3, + rowCount: 0, + total: 10, + isFetching: true, + }); + + expect(setPage).not.toHaveBeenCalled(); + + rerender({ page: 3, rowCount: 0, total: 10, isFetching: false }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('clamps a page below the first one even when the query failed', () => { + // A negative offset is what made the request fail (400 "offset cannot be + // negative"), so retrying the same page loops forever — clamp regardless. + const { setPage } = renderRecovery({ + page: 0, + rowCount: 0, + total: 0, + isDisabled: true, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('clamps a page below the first one while the query is still in flight', () => { + const { setPage } = renderRecovery({ + page: -2, + rowCount: 0, + isFetching: true, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('keeps the page when the query failed so a retry lands where the user was', () => { + const { setPage } = renderRecovery({ + page: 3, + rowCount: 0, + total: 0, + isDisabled: true, + }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('clamps a page below the first one exactly once while the request settles', () => { + // The clamp runs ahead of both gates, so a request settling underneath an + // uncorrected page must not re-issue the same history rewrite. + const { setPage, rerender } = renderRecovery({ + page: 0, + rowCount: 0, + total: 0, + isFetching: true, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + + rerender({ page: 0, rowCount: 0, total: 0, isFetching: false }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('stops correcting once the corrected page comes back with rows', () => { + const { setPage, rerender } = renderRecovery({ + page: 7, + pageSize: 10, + rowCount: 0, + total: 25, + }); + + expect(setPage).toHaveBeenCalledWith(3, REPLACE); + + // The correction lands: the query refetches, then resolves with the rows page 3 holds. + rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true }); + rerender({ + page: 3, + pageSize: 10, + rowCount: 5, + total: 25, + isFetching: false, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('does not correct again while the same page is still being observed', () => { + const { setPage, rerender } = renderRecovery({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + + // A refetch cycle that leaves the page untouched — the correction is already in flight. + rerender({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + isFetching: true, + }); + rerender({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + isFetching: false, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => { + // `total` claims 400 rows exist, but every page comes back empty. Walking back one + // page at a time would cost a request per hop, so bail out to page 1 instead. + const { setPage, rerender } = renderRecovery({ + page: 40, + pageSize: 10, + rowCount: 0, + total: 400, + }); + + expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE); + + rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 }); + + expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE); + + rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 }); + + expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE); + expect(setPage).toHaveBeenCalledTimes(3); + }); + + it('corrects again when the user returns to a page that is still empty', () => { + const { setPage, rerender } = renderRecovery({ + page: 3, + pageSize: 10, + rowCount: 0, + total: 10, + }); + + expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE); + + rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 }); + rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 }); + + expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE); + }); + + it('does not re-run the correction when setPage is a fresh function each render', () => { + // The hook reads setPage through a ref, so an inline arrow must not turn the + // ungated `page < 1` clamp into a per-render history rewrite. + const setPage = jest.fn(); + const { rerender } = renderHook( + () => + useRecoverFromEmptyPage({ + page: 0, + pageSize: 10, + rowCount: 0, + total: 0, + isFetching: false, + setPage: (nextPage, options): void => setPage(nextPage, options), + }), + { initialProps: undefined }, + ); + + rerender(undefined); + rerender(undefined); + + expect(setPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts b/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts new file mode 100644 index 00000000000..f0ae2efd843 --- /dev/null +++ b/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; + +import { + useStableTotalCount, + UseStableTotalCountParams, +} from '../useStableTotalCount'; + +function renderStableTotalCount(initial: UseStableTotalCountParams): { + result: { current: number }; + rerender: (next: UseStableTotalCountParams) => void; +} { + const { result, rerender } = renderHook( + (params: UseStableTotalCountParams) => useStableTotalCount(params), + { initialProps: initial }, + ); + + return { result, rerender }; +} + +describe('useStableTotalCount', () => { + it('reports the total once the load settles', () => { + const { result } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + expect(result.current).toBe(100); + }); + + it('keeps the last count while the same list refetches', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'pods' }); + + expect(result.current).toBe(100); + }); + + it('forgets the count when the list changes', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'nodes' }); + + expect(result.current).toBe(0); + }); + + it('reports the new list total once it arrives', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'nodes' }); + rerender({ total: 25, isLoading: false, resetKey: 'nodes' }); + + expect(result.current).toBe(25); + }); + + it('reports zero for a list that is genuinely empty', () => { + const { result } = renderStableTotalCount({ + total: 0, + isLoading: false, + resetKey: 'pods', + }); + + expect(result.current).toBe(0); + }); + + it('holds nothing back when no reset key is given', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: undefined, + }); + + // Without a key there is no context to compare, so the cache behaves as it + // always did: it survives the refetch. + rerender({ total: 0, isLoading: true, resetKey: undefined }); + + expect(result.current).toBe(100); + }); +}); diff --git a/frontend/src/components/TanStackTableView/index.tsx b/frontend/src/components/TanStackTableView/index.tsx index 2f69ef2be87..995f77a6adf 100644 --- a/frontend/src/components/TanStackTableView/index.tsx +++ b/frontend/src/components/TanStackTableView/index.tsx @@ -9,6 +9,7 @@ export * from './useCalculatedPageSize'; export * from './useColumnState'; export * from './useColumnStore'; export * from './usePreferredPageSize.store'; +export * from './useRecoverFromEmptyPage'; export * from './useTableParams'; /** @@ -285,6 +286,51 @@ export * from './useTableParams'; * * **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users * to reset to auto-calculated size. + * + * **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the + * current history entry instead of pushing a new one. Use `replace` for corrections the user + * did not ask for — otherwise the back button walks straight back into the state that was just + * corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it. + * + * @example useRecoverFromEmptyPage — send the user back to a page that has data + * + * When rows disappear underneath the current page (filters narrowed, time range moved, items + * deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook + * watches the fetched result and corrects the page with `history: 'replace'`, so the back + * button does not return to the empty page. + * + * Correction rules: + * - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a + * negative offset the API rejects (400 `offset cannot be negative`), so the response can never + * confirm the page is empty — deferring to it would strand the user on a permanent error. + * - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total` + * is trustworthy that lands on the last page holding data; when `total` is unknown or zero it + * lands on page 1; and when `total` claims this page should have had rows it steps back a + * single page. Repeated step-backs give up and jump to page 1 after the second one, so a + * badly inflated `total` cannot walk the user down one request at a time. + * - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there + * is genuinely nothing to show). + * + * Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed + * request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp. + * + * ```tsx + * import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView'; + * + * const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 }); + * const { data, isLoading, isFetching, isError } = useListQuery({ page, limit }); + * + * useRecoverFromEmptyPage({ + * page, + * pageSize: limit, + * rowCount: data?.rows.length ?? 0, + * total: data?.total ?? 0, + * isFetching: isLoading || isFetching, + * // Skip correction on errors — no rows there means "request failed", not "page is empty". + * isDisabled: isError, + * setPage, + * }); + * ``` */ const TanStackTable = Object.assign(TanStackTableBase, { Text: TanStackTableText, diff --git a/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts b/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts new file mode 100644 index 00000000000..78d0c7db8b7 --- /dev/null +++ b/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; + +import { SetPageOptions } from './useTableParams'; + +const FIRST_PAGE = 1; +const REPLACE_HISTORY: SetPageOptions = { history: 'replace' }; + +/** + * How many single-page step-backs to attempt before giving up and going to page 1. + * + * A step-back only happens when `total` claims the current page should hold data but the + * response came back empty. Each hop costs a request, so an inflated `total` on a high page + * number would otherwise walk the user down one page at a time behind a spinner. + */ +const MAX_STEP_BACKS = 2; + +type Correction = { + from: number; + to: number; +}; + +export type UseRecoverFromEmptyPageParams = { + page: number; + pageSize: number; + rowCount: number; + total: number; + isFetching: boolean; + isDisabled?: boolean; + setPage: (page: number, options?: SetPageOptions) => void; +}; + +export function useRecoverFromEmptyPage({ + page, + pageSize, + rowCount, + total, + isFetching, + isDisabled = false, + setPage, +}: UseRecoverFromEmptyPageParams): void { + const setPageRef = useRef(setPage); + const lastCorrectionRef = useRef(null); + const stepBacksRef = useRef(0); + + useEffect(() => { + setPageRef.current = setPage; + }); + + useEffect(() => { + if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) { + lastCorrectionRef.current = null; + } + + const correctTo = (nextPage: number): boolean => { + if (lastCorrectionRef.current?.to === nextPage) { + return false; + } + + lastCorrectionRef.current = { from: page, to: nextPage }; + setPageRef.current(nextPage, REPLACE_HISTORY); + return true; + }; + + // A page below the first one is invalid on its own terms — it usually maps to a + // negative offset the API rejects outright, so waiting for a response that will + // never arrive (or trusting a failed one) would strand the user for good. + if (page < FIRST_PAGE) { + stepBacksRef.current = 0; + void correctTo(FIRST_PAGE); + return; + } + + if (isFetching || isDisabled) { + return; + } + + // The page has data, or there is genuinely nothing to show anywhere. + if (rowCount > 0 || page === FIRST_PAGE) { + stepBacksRef.current = 0; + return; + } + + const currentPage = Math.floor(page); + const lastPageWithData = + pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE; + const nextPage = Math.max( + FIRST_PAGE, + Math.min(lastPageWithData, currentPage - 1), + ); + + // `total` disagrees with the response: it says this page should have rows, so the + // only safe move is one page back. Cap how often that repeats — every hop is a + // request, and a badly inflated `total` would otherwise crawl down from page 40. + const isStepBack = nextPage === currentPage - 1; + + if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) { + if (correctTo(FIRST_PAGE)) { + stepBacksRef.current = 0; + } + return; + } + + if (correctTo(nextPage) && isStepBack) { + stepBacksRef.current += 1; + } + }, [isFetching, isDisabled, page, pageSize, rowCount, total]); +} diff --git a/frontend/src/components/TanStackTableView/useStableTotalCount.ts b/frontend/src/components/TanStackTableView/useStableTotalCount.ts new file mode 100644 index 00000000000..560975fee4c --- /dev/null +++ b/frontend/src/components/TanStackTableView/useStableTotalCount.ts @@ -0,0 +1,35 @@ +import { useRef } from 'react'; + +export type UseStableTotalCountParams = { + total: number | undefined; + isLoading: boolean; + /** + * Identifies the list being counted. When it changes, the cached count is + * dropped so the previous list's page count cannot outlive it. + */ + resetKey: string | undefined; +}; + +/** + * Holds on to the last non-zero total so the pagination does not flash while the + * same list refetches, and forgets it as soon as `resetKey` moves to another list. + */ +export function useStableTotalCount({ + total, + isLoading, + resetKey, +}: UseStableTotalCountParams): number { + const prevTotalRef = useRef(total || 0); + const prevResetKeyRef = useRef(resetKey); + + if (prevResetKeyRef.current !== resetKey) { + prevResetKeyRef.current = resetKey; + prevTotalRef.current = 0; + } + + if (total && total > 0) { + prevTotalRef.current = total; + } + + return isLoading ? prevTotalRef.current : total || 0; +} diff --git a/frontend/src/components/TanStackTableView/useTableParams.ts b/frontend/src/components/TanStackTableView/useTableParams.ts index 9b93e74b7ca..b3fe4223f78 100644 --- a/frontend/src/components/TanStackTableView/useTableParams.ts +++ b/frontend/src/components/TanStackTableView/useTableParams.ts @@ -29,12 +29,16 @@ type Defaults = { cleanupOnUnmount?: boolean; }; +export type SetPageOptions = { + history?: 'push' | 'replace'; +}; + export type TableParamsResult = { page: number; limit: number; orderBy: SortState | null; expanded: ExpandedState; - setPage: (p: number) => void; + setPage: (p: number, options?: SetPageOptions) => void; setLimit: (l: number) => void; setOrderBy: (s: SortState | null) => void; setExpanded: (updaterOrValue: Updater) => void; @@ -249,6 +253,17 @@ export function useTableParams( [], ); + const setUrlPageWithOptions = useCallback( + (page: number, options?: SetPageOptions): void => { + void setUrlPage(page, options); + }, + [setUrlPage], + ); + + const setLocalPageValue = useCallback((page: number): void => { + setLocalPage(page); + }, []); + const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`; const prevOrderByRef = useRef(null); @@ -303,7 +318,7 @@ export function useTableParams( limit: useUrlForLimit ? urlLimit : localLimit, orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null, expanded: useUrlForExpanded ? urlExpanded : localExpanded, - setPage: useUrlForPage ? setUrlPage : setLocalPage, + setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue, setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist, setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy, setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded, diff --git a/frontend/src/container/ExplorerOrderBy/index.tsx b/frontend/src/container/ExplorerOrderBy/index.tsx index ebd77d49028..b0360813b03 100644 --- a/frontend/src/container/ExplorerOrderBy/index.tsx +++ b/frontend/src/container/ExplorerOrderBy/index.tsx @@ -2,7 +2,7 @@ import { memo, useMemo } from 'react'; import { Select, Spin } from 'antd'; import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces'; import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter'; -import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config'; +import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { StringOperators } from 'types/common/queryBuilder'; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx index 0d58ed5c361..82e65cdc19d 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx @@ -7,6 +7,7 @@ import TanStackTable, { TableColumnDef, useCalculatedPageSize, useHiddenColumnIds, + useRecoverFromEmptyPage, useTableParams, } from 'components/TanStackTableView'; import { InfraMonitoringEvents } from 'constants/events'; @@ -136,6 +137,7 @@ export function K8sBaseList< page: currentPage, limit: currentPageSize, setLimit, + setPage, } = useTableParams( { page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, @@ -243,6 +245,16 @@ export function K8sBaseList< const totalCount = data?.total || 0; const hasFilters = !!expression?.trim(); + useRecoverFromEmptyPage({ + page: currentPage, + pageSize: currentPageSize, + rowCount: pageData.length, + total: totalCount, + isFetching: isLoading || isFetching, + isDisabled: isError || Boolean(data?.error), + setPage, + }); + const getGroupKeyFn = useCallback( (item: T) => getGroupedByMeta(item, groupBy), [groupBy], diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx index c7314ec03e3..fa91a2bf2f6 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx @@ -591,12 +591,14 @@ describe('K8sBaseList', () => { }); describe('with empty data', () => { + const onUrlUpdateMock = jest.fn(); const fetchListDataMock = jest.fn< ReturnType['fetchListData']>>, Parameters['fetchListData']>> >(); beforeEach(() => { + onUrlUpdateMock.mockClear(); fetchListDataMock.mockClear(); fetchListDataMock.mockResolvedValue({ data: [], @@ -605,6 +607,7 @@ describe('K8sBaseList', () => { }); renderComponent({ + onUrlUpdate: onUrlUpdateMock, entity: InfraMonitoringEntity.PODS, eventCategory: InfraMonitoringEvents.Pod, fetchListData: fetchListDataMock, @@ -625,6 +628,177 @@ describe('K8sBaseList', () => { expect(fetchListDataMock).toHaveBeenCalled(); }); }); + + it('should not rewrite the page when already on the first page', async () => { + await waitFor(() => { + expect(fetchListDataMock).toHaveBeenCalled(); + }); + + const pageUpdates = onUrlUpdateMock.mock.calls + .map((call) => call[0].searchParams.get('page')) + .filter(Boolean); + + expect(pageUpdates).toHaveLength(0); + }); + }); + + describe('with a page beyond the end of the list', () => { + const onUrlUpdateMock = jest.fn(); + const fetchListDataMock = jest.fn< + ReturnType['fetchListData']>>, + Parameters['fetchListData']>> + >(); + + // 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty. + const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({ + id: `pod-${index + 1}`, + })); + + beforeEach(() => { + onUrlUpdateMock.mockClear(); + fetchListDataMock.mockClear(); + // Offset-aware on purpose: a mock that answers empty for every offset would let + // the assertions pass against a page the recovery has already moved on from. + fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({ + data: rows.slice(offset, offset + limit), + total: rows.length, + error: null, + })); + + renderComponent({ + onUrlUpdate: onUrlUpdateMock, + entity: InfraMonitoringEntity.PODS, + eventCategory: InfraMonitoringEvents.Pod, + fetchListData: fetchListDataMock, + queryParams: { page: '7', pageSize: '10' }, + tableColumns: createTestColumns(), + getRowKey: (row): string => row.id, + getItemKey: (row): string => row.id, + }); + }); + + it('should send the user back to the last page holding data', async () => { + // The rows of page 3 on screen are what proves the recovery settled there, + // rather than passing through on its way somewhere else. + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + const pageUpdates = onUrlUpdateMock.mock.calls + .map((call) => call[0].searchParams.get('page')) + .filter(Boolean); + + expect(pageUpdates).toStrictEqual(['3']); + }); + + it('should correct the page in a single hop', async () => { + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + // Only the original out-of-range page and the corrected one are requested. + expect( + fetchListDataMock.mock.calls.map((call) => call[0].offset), + ).toStrictEqual([60, 20]); + }); + + it('should replace the history entry instead of pushing the correction', async () => { + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === '3', + ); + + expect(pageCorrection?.[0].options.history).toBe('replace'); + }); + }); + + describe('with a page below the first one', () => { + const onUrlUpdateMock = jest.fn(); + const fetchListDataMock = jest.fn< + ReturnType['fetchListData']>>, + Parameters['fetchListData']>> + >(); + + beforeEach(() => { + onUrlUpdateMock.mockClear(); + fetchListDataMock.mockClear(); + // page=0 turns into offset=-10, which the API rejects outright — the list + // can only recover by clamping the page, never by reading the response. + fetchListDataMock.mockImplementation(async ({ offset = 0 }) => { + if (offset < 0) { + throw new APIError({ + httpStatusCode: 400, + error: { + code: 'invalid_input', + message: 'offset cannot be negative', + url: '', + errors: [], + }, + }); + } + + return { data: [{ id: 'pod-1' }], total: 1, error: null }; + }); + + renderComponent({ + onUrlUpdate: onUrlUpdateMock, + entity: InfraMonitoringEntity.PODS, + eventCategory: InfraMonitoringEvents.Pod, + fetchListData: fetchListDataMock, + queryParams: { page: '0', pageSize: '10' }, + tableColumns: createTestColumns(), + getRowKey: (row): string => row.id, + getItemKey: (row): string => row.id, + }); + }); + + it('should reject the request that carried the negative offset', async () => { + await waitFor(() => { + expect( + fetchListDataMock.mock.calls.some((call) => call[0].offset === -10), + ).toBe(true); + }); + + await expect( + fetchListDataMock.mock.results[0].value as Promise, + ).rejects.toThrow('offset cannot be negative'); + }); + + it('should clamp the page to the first one even though the request failed', async () => { + await waitFor(() => { + expect(onUrlUpdateMock).toHaveBeenCalled(); + }); + + // Page 1 is the default, so the correction drops the param rather than + // writing `page=1`. + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === null, + ); + + expect(pageCorrection).toBeDefined(); + expect(pageCorrection?.[0].queryString).toBe('?pageSize=10'); + }); + + it('should replace the history entry instead of pushing the correction', async () => { + await waitFor(() => { + expect(onUrlUpdateMock).toHaveBeenCalled(); + }); + + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === null, + ); + + expect(pageCorrection?.[0].options.history).toBe('replace'); + }); + + it('should refetch with a non-negative offset after clamping', async () => { + await waitFor(() => { + expect( + fetchListDataMock.mock.calls.some((call) => call[0].offset === 0), + ).toBe(true); + }); + + await waitFor(() => { + expect(screen.getByText('pod-1')).toBeInTheDocument(); + }); + }); }); describe('with error response', () => { diff --git a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx index 53353ce583c..94e3b9fada1 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx @@ -47,6 +47,7 @@ import { useInfraMonitoringCategory, useInfraMonitoringGroupBy, useInfraMonitoringOrderBy, + useInfraMonitoringPageListing, useInfraMonitoringSelectedItemParams, } from './hooks'; @@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element { const [, setGroupBy] = useInfraMonitoringGroupBy(); const [, setOrderBy] = useInfraMonitoringOrderBy(); const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams(); + const [, setCurrentPage] = useInfraMonitoringPageListing(); const compositeQuery = useGetCompositeQueryParam(); const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); @@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element { void setSelectedCategory(key as string); void setOrderBy(null); void setGroupBy(null); + void setCurrentPage(null); setSelectedItemParams(null); redirectWithQueryBuilderData({ ...currentQuery, diff --git a/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx new file mode 100644 index 00000000000..55d183768b8 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +// eslint-disable-next-line no-restricted-imports +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat'; +import { TooltipProvider } from '@signozhq/ui/tooltip'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing'; +import { AppProvider } from 'providers/App/App'; +import { QueryBuilderProvider } from 'providers/QueryBuilder'; +import TimezoneProvider from 'providers/Timezone'; +import store from 'store'; + +import { K8sCategories } from '../constants'; +import InfraMonitoringK8s from '../InfraMonitoringK8s'; + +// Quick filters fire their own field APIs and are irrelevant to pagination. +jest.mock('components/QuickFilters/QuickFilters', () => ({ + __esModule: true, + default: (): JSX.Element =>
, +})); + +// The list owns its own page recovery; stubbing it keeps the page param under the +// sole control of the category handler being tested here. +jest.mock('../Base/K8sDynamicList', () => ({ + __esModule: true, + K8sDynamicList: (): JSX.Element =>
, + default: (): JSX.Element =>
, +})); + +// Analytics only; jsdom lacks the Performance navigation entries it reads. +jest.mock('lib/navigation', () => ({ + getNavigationReferrer: (): string => 'direct', +})); + +function renderPage( + queryParams: Record, + onUrlUpdate: jest.Mock, +): void { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + + + + + + + + + + + + + + + , + ); +} + +describe('InfraMonitoringK8s', () => { + describe('when the category changes from a page other than the first', () => { + const onUrlUpdateMock = jest.fn(); + + beforeEach(async () => { + onUrlUpdateMock.mockClear(); + + renderPage( + { category: K8sCategories.PODS, page: '3', pageSize: '10' }, + onUrlUpdateMock, + ); + + await screen.findByTestId(`category-${K8sCategories.NODES}`); + }); + + it('should drop the page so the new category starts at the first one', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`)); + + // Page 3 of pods says nothing about nodes — keeping it asks the new entity + // for an offset it may not have. The param is cleared rather than set to 1, + // since an absent page already means the first one. + await waitFor(() => { + const categorySwitch = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('category') === K8sCategories.NODES, + ); + + expect(categorySwitch).toBeDefined(); + expect(categorySwitch?.[0].searchParams.get('page')).toBeNull(); + }); + }); + + it('should keep the page size, which is not category specific', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`)); + + await waitFor(() => { + const categorySwitch = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('category') === K8sCategories.NODES, + ); + + expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10'); + }); + }); + + it('should leave the page alone when the same category is clicked again', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`)); + + await waitFor(() => { + expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument(); + }); + + const droppedPage = onUrlUpdateMock.mock.calls.some( + (call) => !call[0].searchParams.has('page'), + ); + + expect(droppedPage).toBe(false); + }); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts index d4e08041377..33e4bbd0ba1 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts @@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn< > => useQueryState( INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, + // do not use .withDefault here, this can cause bugs when + // two hooks of nuqs define default twice, this is also + // defined at useTableParams parseAsInteger.withOptions(defaultNuqsOptions), ); diff --git a/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts b/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts index 36a3d05b93a..17556e16bf4 100644 --- a/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts +++ b/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts @@ -10,7 +10,7 @@ import { RESTRICTED_SELECTED_FIELDS, } from 'container/LogsFilters/config'; import { MetricsType } from 'container/MetricsApplication/constant'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; import { BaseAutocompleteData, diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx index 14511cfa1e5..12bfa555bde 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx @@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({ })); jest.mock( - 'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer', + 'container/QueryBuilder/filters/OptionRenderer/OptionRenderer', () => ({ __esModule: true, default: ({ value }: { value: string }): JSX.Element => {value}, diff --git a/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts b/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts index e820c53d102..2e8d4dccd68 100644 --- a/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts +++ b/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts @@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg'; import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg'; import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg'; import azureVmUrl from '@/assets/Logos/azure-vm.svg'; +import azureUrl from '@/assets/Logos/azure.svg'; import basetenUrl from '@/assets/Logos/baseten.svg'; import cassandraUrl from '@/assets/Logos/cassandra.svg'; import celeryUrl from '@/assets/Logos/celery.svg'; @@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg'; import claudeCodeUrl from '@/assets/Logos/claude-code.svg'; import clickhouseUrl from '@/assets/Logos/clickhouse.svg'; import cloudflareUrl from '@/assets/Logos/cloudflare.svg'; +import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg'; import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg'; import cohereUrl from '@/assets/Logos/cohere.svg'; import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg'; @@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg'; import dbosUrl from '@/assets/Logos/dbos.svg'; import deepseekUrl from '@/assets/Logos/deepseek.svg'; import denoUrl from '@/assets/Logos/deno.svg'; +import difyUrl from '@/assets/Logos/dify.svg'; import dockerUrl from '@/assets/Logos/docker.svg'; import documentLoadUrl from '@/assets/Logos/document-load.svg'; import dotnetUrl from '@/assets/Logos/dotnet.svg'; @@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg'; import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg'; import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg'; import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg'; +import gcpUrl from '@/assets/Logos/gcp.svg'; +import githubCopilotUrl from '@/assets/Logos/github-copilot.svg'; import githubUrl from '@/assets/Logos/github.svg'; import goUrl from '@/assets/Logos/go.svg'; import googleAdkUrl from '@/assets/Logos/google-adk.svg'; @@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg'; import jbossUrl from '@/assets/Logos/jboss.svg'; import jenkinsUrl from '@/assets/Logos/jenkins.svg'; import kafkaUrl from '@/assets/Logos/kafka.svg'; +import kedaUrl from '@/assets/Logos/keda.svg'; +import kongUrl from '@/assets/Logos/kong.svg'; import kubernetesUrl from '@/assets/Logos/kubernetes.svg'; import lambdaUrl from '@/assets/Logos/lambda.svg'; import langchainUrl from '@/assets/Logos/langchain.svg'; @@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg'; import mistralUrl from '@/assets/Logos/mistral.svg'; import mongoUrl from '@/assets/Logos/mongo.svg'; import n8nUrl from '@/assets/Logos/n8n.svg'; +import neonUrl from '@/assets/Logos/neon.svg'; import newrelicUrl from '@/assets/Logos/newrelic.svg'; import nextjsUrl from '@/assets/Logos/nextjs.svg'; import nginxUrl from '@/assets/Logos/nginx.svg'; @@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [ label: 'Traces', imgUrl: lambdaUrl, link: '/docs/aws-monitoring/lambda/lambda-traces/', + question: { + desc: 'Which runtime does your Lambda function use?', + helpText: + 'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.', + options: [ + { + key: 'aws-lambda-traces-auto', + label: 'Python, Node.js, Java, Ruby', + imgUrl: lambdaUrl, + link: '/docs/aws-monitoring/lambda/lambda-traces/', + }, + { + key: 'aws-lambda-traces-golang', + label: 'Go', + imgUrl: goUrl, + link: '/docs/aws-monitoring/lambda/lambda-traces-golang/', + }, + ], + }, }, { key: 'aws-lambda-metrics', @@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [ module: 'metrics', relatedSearchKeywords: [ 'integrations', + 'logs', 'metrics', 'supabase', + 'supabase logs', 'supabase metrics', 'supabase monitoring', 'supabase observability', @@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [ label: 'Traefik', imgUrl: opentelemetryUrl, tags: ['infrastructure monitoring'], - module: 'infrastructure', + module: 'apm', relatedSearchKeywords: [ 'infrastructure', 'traefik', - 'traefik access logs', - 'traefik logs', 'traefik metrics', 'traefik monitoring', 'traefik observability', 'traefik tracing', ], link: '/docs/tutorial/traefik-observability/', - question: { - desc: 'Which Traefik signals do you want to send to SigNoz?', - type: 'select', - options: [ - { - key: 'traefik-metrics-traces', - label: 'Metrics & Traces', - imgUrl: opentelemetryUrl, - link: '/docs/tutorial/traefik-observability/', - }, - { - key: 'traefik-logs', - label: 'Access Logs', - imgUrl: opentelemetryUrl, - link: '/docs/integrations/opentelemetry-traefik/', - }, - ], - }, }, { dataSource: 'mongodb-atlas', label: 'MongoDB (Atlas)', imgUrl: mongoUrl, tags: ['database'], - module: 'database', + module: 'metrics', relatedSearchKeywords: [ 'atlas', 'atlas metrics', @@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [ label: 'MySQL', imgUrl: opentelemetryUrl, tags: ['database'], - module: 'database', + module: 'metrics', relatedSearchKeywords: [ 'database', 'mysql', - 'mysql error log', - 'mysql logs', 'mysql metrics', 'mysql monitoring', 'mysql observability', - 'mysql slow query log', ], link: '/docs/metrics-management/mysql-metrics/', - question: { - desc: 'Which MySQL signals do you want to send to SigNoz?', - type: 'select', - options: [ - { - key: 'mysql-metrics', - label: 'Metrics', - imgUrl: opentelemetryUrl, - link: '/docs/metrics-management/mysql-metrics/', - }, - { - key: 'mysql-logs', - label: 'Logs', - imgUrl: opentelemetryUrl, - link: '/docs/integrations/opentelemetry-mysql/', - }, - ], - }, }, { dataSource: 'jmx', @@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [ id: 'dspy', link: '/docs/dspy-observability/', }, + { + dataSource: 'grok-build', + label: 'Grok Build', + imgUrl: grokUrl, + tags: ['LLM Monitoring'], + module: 'metrics', + relatedSearchKeywords: [ + 'coding agent', + 'grok build', + 'grok build events', + 'grok build logs', + 'grok build metrics', + 'grok build monitoring', + 'grok build observability', + 'llm', + 'llm monitoring', + 'metrics', + 'monitoring', + 'observability', + 'otel grok build integration', + 'terminal coding agent', + 'token usage', + 'xai', + ], + id: 'grok-build', + link: '/docs/grok-build-observability/', + }, + { + dataSource: 'neon', + label: 'Neon', + imgUrl: neonUrl, + tags: ['database'], + module: 'metrics', + relatedSearchKeywords: [ + 'database', + 'neon', + 'neon database', + 'neon db', + 'neon logs', + 'neon metrics', + 'neon monitoring', + 'neon observability', + 'neondb', + 'opentelemetry neon', + 'postgres', + 'postgresql', + 'serverless postgres', + ], + id: 'neon', + link: '/docs/integrations/opentelemetry-neondb/', + }, + { + dataSource: 'dify', + label: 'Dify', + imgUrl: difyUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'ai agent', + 'dify', + 'dify monitoring', + 'dify observability', + 'dify traces', + 'llm', + 'llm monitoring', + 'metrics', + 'no code ai', + 'observability', + 'opentelemetry dify', + 'traces', + ], + id: 'dify', + link: '/docs/dify-observability/', + }, + { + dataSource: 'firecrawl', + label: 'Firecrawl', + imgUrl: llmMonitoringUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'crawl', + 'firecrawl', + 'firecrawl metrics', + 'firecrawl monitoring', + 'firecrawl observability', + 'firecrawl traces', + 'llm', + 'llm monitoring', + 'opentelemetry firecrawl', + 'scrape', + 'traces', + 'web scraping', + ], + id: 'firecrawl', + link: '/docs/firecrawl-monitoring/', + }, + { + dataSource: 'keda', + label: 'KEDA', + imgUrl: kedaUrl, + tags: ['infrastructure monitoring', 'metrics'], + module: 'metrics', + relatedSearchKeywords: [ + 'autoscaling', + 'event driven autoscaling', + 'keda', + 'keda metrics', + 'keda monitoring', + 'kubernetes', + 'kubernetes autoscaling', + 'metrics', + 'opentelemetry keda', + 'scaled jobs', + 'scaled objects', + ], + id: 'keda', + link: '/docs/metrics-management/keda-metrics/', + }, + { + dataSource: 'opentelemetry-collector-metrics', + label: 'OpenTelemetry Collector Metrics', + imgUrl: opentelemetryUrl, + tags: ['infrastructure monitoring', 'metrics'], + module: 'metrics', + relatedSearchKeywords: [ + 'collector health', + 'collector internal metrics', + 'collector metrics', + 'metrics', + 'opentelemetry', + 'opentelemetry collector', + 'otel collector', + 'otelcol metrics', + 'pipeline health', + ], + id: 'opentelemetry-collector-metrics', + link: '/docs/metrics-management/opentelemetry-collector-metrics/', + }, + { + dataSource: 'cloudnative-pg', + label: 'CloudNativePG', + imgUrl: cloudnativePgUrl, + tags: ['database'], + module: 'metrics', + relatedSearchKeywords: [ + 'cloud native postgres', + 'cloudnativepg', + 'cnpg', + 'cnpg metrics', + 'database', + 'kubernetes postgres', + 'metrics', + 'opentelemetry cloudnativepg', + 'postgres', + 'postgresql', + ], + id: 'cloudnative-pg', + link: '/docs/metrics-management/opentelemetry-cloudnative-pg/', + }, + { + dataSource: 'kong-gateway', + label: 'Kong Gateway', + imgUrl: kongUrl, + tags: ['infrastructure monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'api gateway', + 'kong', + 'kong gateway', + 'kong logs', + 'kong metrics', + 'kong monitoring', + 'kong observability', + 'kong traces', + 'opentelemetry kong', + 'proxy', + 'traces', + ], + id: 'kong-gateway', + link: '/docs/integrations/kong-gateway/', + }, + { + dataSource: 'github-copilot', + label: 'GitHub Copilot', + imgUrl: githubCopilotUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'coding agent', + 'copilot', + 'copilot chat', + 'github copilot', + 'github copilot metrics', + 'github copilot monitoring', + 'github copilot observability', + 'github copilot traces', + 'llm', + 'llm monitoring', + 'monitoring', + 'observability', + 'otel github copilot integration', + 'traces', + 'tracing', + 'vs code', + ], + id: 'github-copilot', + link: '/docs/github-copilot-monitoring/', + }, + { + dataSource: 'serilog', + label: 'Serilog', + imgUrl: dotnetUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + '.net', + 'asp.net', + 'c#', + 'csharp', + 'dotnet', + 'dotnet logs', + 'logging', + 'logs', + 'monitoring', + 'observability', + 'otel serilog integration', + 'serilog', + 'serilog logs', + 'serilog sink', + 'structured logging', + ], + id: 'serilog', + link: '/docs/logs-management/send-logs/serilog-to-signoz/', + }, + { + dataSource: 'gcp-integration', + label: 'GCP Integration', + imgUrl: gcpUrl, + tags: ['GCP'], + module: 'metrics', + relatedSearchKeywords: [ + 'cloud monitoring metrics', + 'connect gcp account', + 'gcp', + 'gcp integration', + 'gcp manual setup', + 'gcp metrics', + 'gcp monitoring', + 'gcp observability', + 'gcp service account', + 'google cloud', + 'google cloud integration', + 'metrics', + 'monitoring', + 'observability', + 'opentelemetry collector gcp', + ], + id: 'gcp-integration', + link: '/docs/integrations/gcp/gcp-integration/', + }, + { + dataSource: 'azure-cosmos-db', + label: 'Azure Cosmos DB', + imgUrl: azureUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure cosmos db', + 'azure integration', + 'cosmos db', + 'cosmosdb', + 'database', + 'metrics', + 'monitoring', + 'nosql', + 'observability', + 'one click azure', + 'request units', + ], + id: 'azure-cosmos-db', + link: '/integrations/azure?service=cosmosdb', + internalRedirect: true, + }, + { + dataSource: 'azure-mongodb', + label: 'Azure MongoDB vCore', + imgUrl: mongoUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure mongodb', + 'azure mongodb vcore', + 'database', + 'metrics', + 'mongodb', + 'mongodb vcore', + 'monitoring', + 'nosql', + 'observability', + 'one click azure', + ], + id: 'azure-mongodb', + link: '/integrations/azure?service=mongodb', + internalRedirect: true, + }, + { + dataSource: 'azure-postgresql-flexible-server', + label: 'Azure PostgreSQL Flexible Server', + imgUrl: postgresqlUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure postgresql', + 'azure postgresql flexible server', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'postgres', + 'postgresql', + 'postgresql flexible server', + ], + id: 'azure-postgresql-flexible-server', + link: '/integrations/azure?service=postgresqlflexibleserver', + internalRedirect: true, + }, + { + dataSource: 'azure-cache-redis', + label: 'Azure Cache for Redis', + imgUrl: redisUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure cache for redis', + 'azure integration', + 'azure redis', + 'cache', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'redis', + ], + id: 'azure-cache-redis', + link: '/integrations/azure?service=redis', + internalRedirect: true, + }, + { + dataSource: 'azure-sql-managed-instance', + label: 'Azure SQL Managed Instance', + imgUrl: azureSqlDatabaseMetricsUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure sql', + 'azure sql managed instance', + 'database', + 'managed instance', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'sql server', + ], + id: 'azure-sql-managed-instance', + link: '/integrations/azure?service=sqldatabasemi', + internalRedirect: true, + }, + { + dataSource: 'azure-cassandra-db', + label: 'Azure Managed Instance for Apache Cassandra', + imgUrl: cassandraUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'apache cassandra', + 'azure', + 'azure cassandra', + 'azure integration', + 'azure managed instance for apache cassandra', + 'cassandra', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + ], + id: 'azure-cassandra-db', + link: '/integrations/azure?service=cassandradb', + internalRedirect: true, + }, + { + dataSource: 'gcp-cloud-sql-postgresql', + label: 'GCP Cloud SQL for PostgreSQL', + imgUrl: gcpCloudSqlUrl, + tags: ['GCP'], + module: 'dashboards', + relatedSearchKeywords: [ + 'cloud sql', + 'cloud sql for postgresql', + 'database', + 'gcp', + 'gcp integration', + 'google cloud', + 'metrics', + 'monitoring', + 'observability', + 'postgres', + 'postgresql', + ], + id: 'gcp-cloud-sql-postgresql', + link: '/integrations/gcp?service=cloudsql_postgres', + internalRedirect: true, + }, + { + dataSource: 'gcp-memorystore-redis', + label: 'GCP Memorystore for Redis', + imgUrl: redisUrl, + tags: ['GCP'], + module: 'dashboards', + relatedSearchKeywords: [ + 'cache', + 'database', + 'gcp', + 'gcp integration', + 'google cloud', + 'memorystore', + 'memorystore for redis', + 'metrics', + 'monitoring', + 'observability', + 'redis', + ], + id: 'gcp-memorystore-redis', + link: '/integrations/gcp?service=memorystore_redis', + internalRedirect: true, + }, + { + dataSource: 'supabase-logs', + label: 'Supabase Logs', + imgUrl: supabaseUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'database', + 'logging', + 'logs', + 'postgres', + 'postgresql', + 'send supabase logs', + 'supabase', + 'supabase log drains', + 'supabase logs', + 'supabase observability', + ], + id: 'supabase-logs', + link: '/docs/logs-management/send-logs/supabase-logs/', + }, + { + dataSource: 'traefik-logs', + label: 'Traefik Access Logs', + imgUrl: opentelemetryUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'access logs', + 'logging', + 'logs', + 'opentelemetry traefik', + 'proxy', + 'reverse proxy', + 'traefik', + 'traefik access logs', + 'traefik logs', + ], + id: 'traefik-logs', + link: '/docs/integrations/opentelemetry-traefik/', + }, + { + dataSource: 'mysql-logs', + label: 'MySQL Logs', + imgUrl: opentelemetryUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'database', + 'logging', + 'logs', + 'mysql', + 'mysql error log', + 'mysql general query log', + 'mysql logs', + 'mysql slow query log', + 'opentelemetry mysql', + ], + id: 'mysql-logs', + link: '/docs/integrations/opentelemetry-mysql/', + }, ]; export default onboardingConfigWithLinks; diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts b/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts index 8acfb391f3e..fc486cbfd2b 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts +++ b/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts @@ -1,11 +1,15 @@ import { ReactNode } from 'react'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces'; +export type WhereClauseConfig = { + customKey: string; + customOp: string; +}; + type FilterConfigs = { [Key in keyof Omit]: { isHidden: boolean; diff --git a/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx b/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx deleted file mode 100644 index f905e0422bf..00000000000 --- a/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from 'react'; -import { InputNumber, InputNumberProps } from 'antd'; -import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { selectStyle } from '../QueryBuilderSearch/config'; - -function AggregateEveryFilter({ - onChange, - query, - disabled, -}: AggregateEveryFilterProps): JSX.Element { - const isMetricsDataSource = useMemo( - () => query.dataSource === DataSource.METRICS, - [query.dataSource], - ); - - const onChangeHandler: InputNumberProps['onChange'] = (event) => { - if (event && event >= 0) { - onChange(event); - } - }; - - const isDisabled = - (isMetricsDataSource && !query.aggregateAttribute?.key) || disabled; - - return ( - - ); -} - -interface AggregateEveryFilterProps { - onChange: (values: number) => void; - query: IBuilderQuery; - disabled: boolean; -} - -export default AggregateEveryFilter; diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx index 83923a556a7..89c5a7bc9ad 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx @@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder'; import { ExtendedSelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; // ** Types import { AgregatorFilterProps } from './AggregatorFilter.intefaces'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx b/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx index b279ff587a8..33dc8f6387a 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx @@ -1,6 +1,6 @@ import { InputNumber } from 'antd'; -import { selectStyle } from '../../QueryBuilderSearch/config'; +import { selectStyle } from '../../QueryBuilderSearchV2/config'; import { handleKeyDownLimitFilter } from '../../utils'; import { LimitFilterProps } from './types'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx index b127520b834..ea843e3dd53 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx @@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { MetricAggregateOperator } from 'types/common/queryBuilder'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../../QueryBuilderSearch/config'; +import { selectStyle } from '../../QueryBuilderSearchV2/config'; import { OrderByProps } from './types'; import { useOrderByFormulaFilter } from './useOrderByFormulaFilter'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx index 0f71e6560c0..1c8d1e23ea2 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx @@ -16,7 +16,7 @@ import { mapLabelValuePairs, orderByValueDelimiter, } from '../../OrderByFilter/utils'; -import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils'; import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils'; import { IOrderByFormulaFilterProps } from './types'; import { transformToOrderByStringValuesByFormula } from './utils'; diff --git a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx index 239725ce822..bd60fe0db65 100644 --- a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx @@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder'; import { SelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; import { GroupByFilterProps } from './GroupByFilter.interfaces'; export const GroupByFilter = memo(function GroupByFilter({ diff --git a/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx b/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx deleted file mode 100644 index d87c6d49076..00000000000 --- a/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { InputNumber } from 'antd'; -import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { selectStyle } from '../QueryBuilderSearch/config'; -import { handleKeyDownLimitFilter } from '../utils'; - -function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element { - const isMetricsDataSource = query.dataSource === DataSource.METRICS; - - const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key; - - return ( - - ); -} - -interface LimitFilterProps { - onChange: (values: number | null) => void; - query: IBuilderQuery; -} - -export default LimitFilter; diff --git a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx index e9c229f70ed..1470a3969ac 100644 --- a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx +++ b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx @@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({ default: (value: T): T => value, })); -jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({ +jest.mock('../OptionRenderer/OptionRenderer', () => ({ __esModule: true, default: ({ value }: { value: string }): JSX.Element => {value}, })); diff --git a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx index 0cbe8d4b082..e3231d54c4f 100644 --- a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx +++ b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx @@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange'; import { ExtendedSelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; import './MetricNameSelector.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx b/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx index c7027fee0a2..b924c83edc9 100644 --- a/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx +++ b/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { Select } from 'antd'; // ** Types -import { selectStyle } from '../QueryBuilderSearch/config'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; import { OperatorsSelectProps } from './OperatorsSelect.interfaces'; export const OperatorsSelect = memo(function OperatorsSelect({ diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.styles.scss b/frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.styles.scss similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.styles.scss rename to frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.styles.scss diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx b/frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.tsx similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx rename to frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.tsx diff --git a/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts b/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts new file mode 100644 index 00000000000..6bbdfbda0cc --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts @@ -0,0 +1,22 @@ +import { Badge } from '@signozhq/ui/badge'; +import styled from 'styled-components'; + +export const TagContainer = styled(Badge).attrs({ + color: 'secondary', + variant: 'outline', +})` + &&& { + display: flex; + font-weight: 300; + font-size: 0.6rem; + } +`; + +export const TagLabel = styled.span` + font-weight: 400; +`; + +export const TagValue = styled.span` + text-transform: capitalize; + font-weight: 400; +`; diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx index 2f5b18080dc..2167c469b59 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx @@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder'; import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; import { OrderByFilterProps } from './OrderByFilter.interfaces'; import { useOrderByFilter } from './useOrderByFilter'; diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts b/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts index 3bc220e6c75..a5bac28e463 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts @@ -7,7 +7,7 @@ import { parse } from 'papaparse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData'; -import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils'; import { getUniqueOrderByValues, getValidOrderByResult } from '../utils'; import { ORDERBY_FILTERS } from './config'; import { SIGNOZ_VALUE } from './constants'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx deleted file mode 100644 index 392982a007e..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Color } from '@signozhq/design-tokens'; -import { Tooltip } from 'antd'; -import { Typography } from '@signozhq/ui/typography'; -import cx from 'classnames'; -import { Zap } from '@signozhq/icons'; - -import { getOptionType } from './utils'; - -import './QueryBuilderSearch.styles.scss'; - -function OptionRendererForLogs({ - label, - value, - dataType, - isIndexed, - setDynamicPlaceholder, -}: OptionRendererProps): JSX.Element { - const optionType = getOptionType(label); - - return ( - setDynamicPlaceholder(value)} - onFocus={(): void => setDynamicPlaceholder(value)} - > - {optionType ? ( - -
-
- {isIndexed ? ( - - ) : ( -
- )} - - {value} - -
-
-
{dataType}
-
-
- {optionType} -
-
-
-
- ) : ( - -
-
- - {label} - -
- - )} - - ); -} - -interface OptionRendererProps { - label: string; - value: string; - dataType: string; - isIndexed: boolean; - setDynamicPlaceholder: React.Dispatch>; -} - -export default OptionRendererForLogs; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss deleted file mode 100644 index c0573415fe5..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss +++ /dev/null @@ -1,298 +0,0 @@ -.query-builder-search-container { - position: relative; - display: flex; - align-items: center; - gap: 12px; -} - -.logs-popup { - &.hide-scroll { - .rc-virtual-list-holder { - height: 100px; - } - } -} - -.logs-explorer-popup { - padding: 0px; - .ant-select-item-group { - padding: 12px 14px 8px 14px; - color: var(--muted-foreground); - font-family: Inter; - font-size: 11px; - font-style: normal; - font-weight: 500; - line-height: 18px; /* 163.636% */ - letter-spacing: 0.88px; - text-transform: uppercase; - } - - .show-all-filter-props { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 13px; - width: 100%; - cursor: pointer; - - .content { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - - .left-section { - display: flex; - align-items: center; - gap: 4px; - - .text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .text:hover { - color: var(--l1-foreground); - } - } - .right-section { - display: flex; - align-items: center; - gap: 4px; - .keyboard-shortcut-slash { - width: 16px; - height: 16px; - flex-shrink: 0; - border-radius: 2.286px; - border-top: 1.143px solid var(--l1-border); - border-right: 1.143px solid var(--l1-border); - border-bottom: 2.286px solid var(--l1-border); - border-left: 1.143px solid var(--l1-border); - background: var(--l2-background); - } - } - } - } - - .show-all-filter-props:hover { - background: color-mix( - in srgb, - var(--l1-foreground) 4%, - transparent - ) !important; - } - - .example-queries { - cursor: default; - .heading { - padding: 12px 14px 8px 14px; - color: var(--muted-foreground); - font-family: Inter; - font-size: 11px; - font-style: normal; - font-weight: 500; - line-height: 18px; /* 163.636% */ - letter-spacing: 0.88px; - text-transform: uppercase; - } - - .query-container { - display: flex; - flex-direction: column; - gap: 12px; - padding: 0px 12px 12px 12px; - cursor: pointer; - - .example-query { - display: flex; - padding: 4px 8px; - justify-content: center; - align-items: center; - gap: 10px; - border-radius: 2px; - background: var(--l3-background); - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: normal; - letter-spacing: -0.07px; - width: fit-content; - } - - .example-query:hover { - color: var(--l1-foreground); - } - } - } - - .ant-select-item-option-grouped { - padding-inline-start: 0px; - padding: 7px 13px; - } - - .keyboard-shortcuts { - display: flex; - align-items: center; - border-radius: 0px 0px 4px 4px; - border: 1px solid var(--l1-border); - background: var(--l2-background); - padding: 11px 16px; - cursor: default; - - .icons { - width: 16px; - height: 16px; - flex-shrink: 0; - border-radius: 2.286px; - border-top: 1.143px solid var(--l3-background); - border-right: 1.143px solid var(--l3-background); - border-bottom: 2.286px solid var(--l3-background); - border-left: 1.143px solid var(--l3-background); - background: var(--l2-background); - } - - .keyboard-text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 18px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .navigate { - display: flex; - align-items: center; - padding-right: 12px; - gap: 4px; - border-right: 1px solid var(--l1-border); - } - - .update-query { - display: flex; - align-items: center; - margin-left: 12px; - gap: 4px; - } - } - - .without-option-type { - display: flex; - gap: 8px; - align-items: center; - .dot { - height: 5px; - width: 5px; - border-radius: 50%; - background-color: var(--l3-background); - } - } - - .logs-options-select { - display: flex; - align-items: center; - justify-content: space-between; - - .text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .tags { - display: flex; - height: 20px; - padding: 4px 8px; - justify-content: center; - align-items: center; - gap: 4px; - border-radius: 20px; - } - - .dot { - height: 5px; - width: 5px; - border-radius: 50%; - flex-shrink: 0; - } - - .left-section { - display: flex; - align-items: center; - gap: 8px; - width: 90%; - - .dot { - background-color: var(--l3-background); - } - - .value { - width: 100%; - } - } - - .right-section { - display: flex; - align-items: center; - gap: 4px; - - .data-type-tag { - background: color-mix(in srgb, var(--l1-foreground) 8%, transparent); - } - - .option-type-tag { - display: flex; - gap: 4px; - align-items: center; - padding: 0px 6px; - text-transform: capitalize; - } - - .tag { - border-radius: 50px; - background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent); - color: var(--bg-sienna-400); - - .dot { - background-color: var(--bg-sienna-400); - } - } - - .resource { - border-radius: 50px; - background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent); - color: var(--bg-sakura-400); - - .dot { - background-color: var(--bg-sakura-400); - } - } - } - } - - .ant-select-item-option-active { - .logs-options-select { - .left-section { - .value { - color: var(--l1-foreground); - } - } - } - } -} - -.span-scope-selector { - width: 160px; -} diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx deleted file mode 100644 index 9c65df022b3..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx +++ /dev/null @@ -1,558 +0,0 @@ -import { - KeyboardEvent, - ReactElement, - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useLocation } from 'react-router-dom'; -import { Button, Select, Spin, Tooltip } from 'antd'; -import { Badge } from '@signozhq/ui/badge'; -import { Typography } from '@signozhq/ui/typography'; -import cx from 'classnames'; -import { - INFRA_LONG_TO_SHORT_OPERATOR_MAP, - OPERATORS, -} from 'constants/queryBuilder'; -import ROUTES from 'constants/routes'; -import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts'; -import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'; -import { getDataTypes } from 'container/LogDetailedView/utils'; -import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; -import { - useAutoComplete, - WhereClauseConfig, -} from 'hooks/queryBuilder/useAutoComplete'; -import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues'; -import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; -import { isEqual, isUndefined } from 'lodash-es'; -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronUp, - Command, - CornerDownLeft, - Filter, - Slash, -} from '@signozhq/icons'; -import type { BaseSelectRef } from 'rc-select'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; -import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; -import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { v4 as uuid } from 'uuid'; - -import { selectStyle } from './config'; -import { PLACEHOLDER } from './constant'; -import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs'; -import OptionRenderer from './OptionRenderer'; -import OptionRendererForLogs from './OptionRendererForLogs'; -import { StyledCheckOutlined, TypographyText } from './style'; -import { - convertExampleQueriesToOptions, - getOperatorValue, - getRemovePrefixFromKey, - getTagToken, - isExistsNotExistsOperator, - isInNInOperator, -} from './utils'; - -import './QueryBuilderSearch.styles.scss'; - -function getOperatorValueForContext( - op: string, - isInfraMonitoring?: boolean, -): string { - const mappedOp = - isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op] - ? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op] - : op; - return getOperatorValue(mappedOp); -} - -function QueryBuilderSearch({ - query, - onChange, - whereClauseConfig, - className, - placeholder, - suffixIcon, - isInfraMonitoring, - isMetricsExplorer, - disableNavigationShortcuts, - entity, -}: QueryBuilderSearchProps): JSX.Element { - const getPopupContainer = useSelectPopupContainer(); - const { pathname } = useLocation(); - const isLogsExplorerPage = useMemo( - () => pathname === ROUTES.LOGS_EXPLORER, - [pathname], - ); - - const [isEditingTag, setIsEditingTag] = useState(false); - - const { - updateTag, - handleClearTag, - handleKeyDown, - handleOnBlur, - handleSearch, - handleSelect, - tags, - options, - searchValue, - isMulti, - isFetching, - setSearchKey, - setSearchValue, - searchKey, - key, - exampleQueries, - } = useAutoComplete( - query, - whereClauseConfig, - isLogsExplorerPage, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const [isOpen, setIsOpen] = useState(false); - const [showAllFilters, setShowAllFilters] = useState(false); - const [dynamicPlacholder, setDynamicPlaceholder] = useState( - placeholder || '', - ); - const selectRef = useRef(null); - - const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues( - searchValue, - query, - searchKey, - isLogsExplorerPage, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); - - const { handleRunQuery, currentQuery } = useQueryBuilder(); - - const toggleEditMode = useCallback( - (value: boolean) => { - // Editing mode is required only in infra monitoring or metrics explorer - if (isInfraMonitoring || isMetricsExplorer) { - setIsEditingTag(value); - } - }, - [isInfraMonitoring, isMetricsExplorer], - ); - - const onTagRender = ({ - value, - closable, - onClose, - }: CustomTagProps): ReactElement => { - const { tagOperator } = getTagToken(value); - const isInNin = isInNInOperator(tagOperator); - const chipValue = isInNin - ? value?.trim()?.replace(/,\s*$/, '') - : value?.trim(); - - const onCloseHandler = (): void => { - onClose(); - // Editing is done after closing a tag - toggleEditMode(false); - handleSearch(''); - setSearchKey(''); - }; - - const tagEditHandler = (value: string): void => { - updateTag(value); - // Editing starts - toggleEditMode(true); - if (isInfraMonitoring || isMetricsExplorer) { - setSearchValue(value); - } else { - handleSearch(value); - } - }; - - const isDisabled = !!searchValue; - - return ( - { - e.preventDefault(); - onCloseHandler(); - }} - > - - { - if (!isDisabled) { - tagEditHandler(value); - } - }} - > - {chipValue} - - - - ); - }; - - const onChangeHandler = (value: string[]): void => { - if (!isMulti) { - handleSearch(value[value.length - 1]); - } - }; - - const onInputKeyDownHandler = (event: KeyboardEvent): void => { - if (isMulti || event.key === 'Backspace') { - handleKeyDown(event); - } - if (isExistsNotExistsOperator(searchValue)) { - handleKeyDown(event); - } - - // Editing is done after enter key press - if (event.key === 'Enter') { - toggleEditMode(false); - } - - if ( - !disableNavigationShortcuts && - (event.ctrlKey || event.metaKey) && - event.key === 'Enter' - ) { - event.preventDefault(); - event.stopPropagation(); - handleRunQuery(); - setIsOpen(false); - } - - if ( - !disableNavigationShortcuts && - (event.ctrlKey || event.metaKey) && - event.key === '/' - ) { - event.preventDefault(); - event.stopPropagation(); - setShowAllFilters((prev) => !prev); - } - }; - - const handleDeselect = useCallback( - (deselectedItem: string) => { - handleClearTag(deselectedItem); - handleRemoveSourceKey(deselectedItem); - }, - [handleClearTag, handleRemoveSourceKey], - ); - - const isMetricsDataSource = useMemo( - () => - query.dataSource === DataSource.METRICS && - !isInfraMonitoring && - !isMetricsExplorer, - [query.dataSource, isInfraMonitoring, isMetricsExplorer], - ); - - const fetchValueDataType = (value: unknown, operator: string): DataTypes => { - if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) { - return getDataTypes([value]); - } - - return DataTypes.EMPTY; - }; - - const queryTags = useMemo(() => { - if (!query.aggregateAttribute?.key && isMetricsDataSource) { - return []; - } - return tags; - }, [isMetricsDataSource, query.aggregateAttribute?.key, tags]); - - useEffect(() => { - const initialTagFilters: TagFilter = { items: [], op: 'AND' }; - const initialSourceKeys = query.filters?.items?.map( - (item) => item.key as BaseAutocompleteData, - ); - - initialTagFilters.items = tags.map((tag) => { - const { tagKey, tagOperator, tagValue } = getTagToken(tag); - - const filterAttribute = [ - ...(initialSourceKeys || []), - ...(sourceKeys || []), - ].find((key) => key?.key === getRemovePrefixFromKey(tagKey)); - - const computedTagValue = - tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === '' - ? tagValue?.slice(0, -1) - : (tagValue ?? ''); - - return { - id: uuid().slice(0, 8), - key: filterAttribute ?? { - key: tagKey, - dataType: fetchValueDataType(computedTagValue, tagOperator), - type: '', - }, - op: getOperatorValueForContext(tagOperator, isInfraMonitoring), - value: computedTagValue, - }; - }); - - // If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished. - if (isInfraMonitoring || isMetricsExplorer) { - if (!isEditingTag) { - onChange(initialTagFilters); - } - } else { - onChange(initialTagFilters); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sourceKeys]); - - const isLastQuery = useMemo( - () => - isEqual( - currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1], - query, - ), - [currentQuery, query], - ); - - useEffect(() => { - if (isLastQuery && !disableNavigationShortcuts) { - registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => { - // set timeout is needed here else the select treats the hotkey as input value - setTimeout(() => { - selectRef.current?.focus(); - }, 0); - }); - } - - return (): void => - deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar); - }, [ - deregisterShortcut, - disableNavigationShortcuts, - isLastQuery, - registerShortcut, - ]); - - useEffect(() => { - if (!isOpen) { - setDynamicPlaceholder(placeholder || ''); - } - }, [isOpen, placeholder]); - - const userOs = getUserOperatingSystem(); - - // conditional changes here to use a seperate component to render the example queries based on the option group label - const customRendererForLogsExplorer = options.map((option) => ( - - - {option.selected && } - - )); - - return ( -
- -
- ); -} - -interface QueryBuilderSearchProps { - query: IBuilderQuery; - onChange: (value: TagFilter) => void; - whereClauseConfig?: WhereClauseConfig; - className?: string; - placeholder?: string; - suffixIcon?: React.ReactNode; - isInfraMonitoring?: boolean; - disableNavigationShortcuts?: boolean; - // TODO: Remove the dependency of InfraMonitoring from this code - entity?: InfraMonitoringEntity | null; - isMetricsExplorer?: boolean; -} - -QueryBuilderSearch.defaultProps = { - whereClauseConfig: undefined, - className: '', - placeholder: PLACEHOLDER, - suffixIcon: undefined, - isInfraMonitoring: false, - disableNavigationShortcuts: false, - entity: null, - isMetricsExplorer: false, -}; - -export interface CustomTagProps { - label: ReactNode; - value: string; - disabled: boolean; - onClose: () => void; - closable: boolean; -} - -export default QueryBuilderSearch; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx similarity index 92% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx index 3aeaec4f1eb..b30f8628f23 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx @@ -1,7 +1,5 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; -import './QueryBuilderSearch.styles.scss'; - function ExampleQueriesRendererForLogs({ label, value, diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx index 37302663a07..c1b997f92d2 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx @@ -11,8 +11,8 @@ import { import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; -import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs'; -import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils'; +import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs'; +import { convertExampleQueriesToOptions } from './utils'; import { ITag, Option } from './QueryBuilderSearchV2'; import './QueryBuilderSearchV2.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 0af46293592..8ac8debf8f1 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -19,10 +19,10 @@ import { QUERY_BUILDER_SEARCH_VALUES, } from 'constants/queryBuilder'; import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; +import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces'; import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts'; import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType'; import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions'; @@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; import { v4 as uuid } from 'uuid'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import { PLACEHOLDER } from '../QueryBuilderSearch/constant'; -import { TypographyText } from '../QueryBuilderSearch/style'; +import { selectStyle } from './config'; +import { PLACEHOLDER } from './constant'; +import { TypographyText } from './style'; import { checkCommaInValue, getOperatorFromValue, getOperatorValue, getTagToken, isInNInOperator, -} from '../QueryBuilderSearch/utils'; +} from './utils'; import { filterByOperatorConfig } from '../utils'; import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown'; import SpanScopeSelector from './SpanScopeSelector'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx index 263da7b4018..111954a4b97 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx @@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es'; import { Check, Zap } from '@signozhq/icons'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { getTagToken } from '../QueryBuilderSearch/utils'; +import { getTagToken } from './utils'; import { DropdownState } from './QueryBuilderSearchV2'; import './Suggestions.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/config.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/config.ts similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/config.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/config.ts diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/constant.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/constant.ts similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/constant.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/constant.ts diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts similarity index 53% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts index 782846116a2..80557e0502a 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts @@ -1,5 +1,3 @@ -import { Check } from '@signozhq/icons'; -import { Badge } from '@signozhq/ui/badge'; import styled from 'styled-components'; export const TypographyText = styled.span<{ @@ -17,27 +15,3 @@ export const TypographyText = styled.span<{ padding-left: 8px; ${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')} `; - -export const StyledCheckOutlined = styled(Check)` - float: right; -`; - -export const TagContainer = styled(Badge).attrs({ - color: 'secondary', - variant: 'outline', -})` - &&& { - display: flex; - font-weight: 300; - font-size: 0.6rem; - } -`; - -export const TagLabel = styled.span` - font-weight: 400; -`; - -export const TagValue = styled.span` - text-transform: capitalize; - font-weight: 400; -`; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts similarity index 92% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts index 0be5afc7a5e..fcdd6279b1b 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts @@ -1,8 +1,7 @@ import { OPERATORS } from 'constants/queryBuilder'; -import { MetricsType } from 'container/MetricsApplication/constant'; import { queryFilterTags } from 'hooks/queryBuilder/useTag'; import { parse } from 'papaparse'; -import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { orderByValueDelimiter } from '../OrderByFilter/utils'; @@ -191,18 +190,6 @@ export function getRemoveOrderFromValue(tag: string): string { return tag; } -export function getOptionType(label: string): MetricsType | undefined { - let optionType; - - if (label.startsWith('tag_')) { - optionType = MetricsType.Tag; - } else if (label.startsWith('resource_')) { - optionType = MetricsType.Resource; - } - - return optionType; -} - /** * * @param exampleQueries the example queries based on recommendation engine diff --git a/frontend/src/container/QueryBuilder/filters/utils.ts b/frontend/src/container/QueryBuilder/filters/utils.ts index ea6fa0ea96b..1950e95f464 100644 --- a/frontend/src/container/QueryBuilder/filters/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/utils.ts @@ -1,4 +1,3 @@ -import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch'; import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder'; import { HAVING_FILTER_REGEXP } from 'constants/regExp'; import { IOption } from 'hooks/useResourceAttribute/types'; @@ -11,7 +10,7 @@ import { orderByValueDelimiter, splitOrderByFromString, } from './OrderByFilter/utils'; -import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils'; export const handleKeyDownLimitFilter: React.KeyboardEventHandler< HTMLInputElement @@ -101,23 +100,6 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] => return acc; }, []); -export const transformKeyValuesToAttributeValuesMap = ( - attributeValuesMap: Record, -): AttributeValuesMap => - Object.fromEntries( - Object.entries(attributeValuesMap || {}).map(([key, values]) => [ - key, - { - stringAttributeValues: - typeof values[0] === 'string' ? (values as string[]) : [], - numberAttributeValues: - typeof values[0] === 'number' ? (values as number[]) : [], - boolAttributeValues: - typeof values[0] === 'boolean' ? (values as boolean[]) : [], - }, - ]), - ); - export const filterByOperatorConfig = ( options: IOption[], key?: OperatorConfigKeys, diff --git a/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx b/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx index 728cde2b23c..6bb985e9792 100644 --- a/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx +++ b/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx @@ -3,7 +3,7 @@ import { formatValueForExpression, removeKeysFromExpression, } from 'components/QueryBuilderV2/utils'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { IQueryPair } from 'types/antlrQueryTypes'; import { extractQueryPairs } from 'utils/queryContextUtils'; import { isQuoted, unquote } from 'utils/stringUtils'; diff --git a/frontend/src/hooks/logs/useActiveLog.ts b/frontend/src/hooks/logs/useActiveLog.ts index f0ccc72cb12..1cc1f239070 100644 --- a/frontend/src/hooks/logs/useActiveLog.ts +++ b/frontend/src/hooks/logs/useActiveLog.ts @@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query'; import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import { MetricsType } from 'container/MetricsApplication/constant'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; diff --git a/frontend/src/hooks/queryBuilder/useAutoComplete.ts b/frontend/src/hooks/queryBuilder/useAutoComplete.ts deleted file mode 100644 index 604380856f1..00000000000 --- a/frontend/src/hooks/queryBuilder/useAutoComplete.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { KeyboardEvent, useCallback, useState } from 'react'; -import { OPERATORS } from 'constants/queryBuilder'; -import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'; -import { - getRemovePrefixFromKey, - getTagToken, - replaceStringWithMaxLength, - tagRegexp, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { Option } from 'container/QueryBuilder/type'; -import { parse } from 'papaparse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; - -import { useFetchKeysAndValues } from './useFetchKeysAndValues'; -import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions'; -import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator'; -import { useTag } from './useTag'; -import { useTagValidation } from './useTagValidation'; - -export type WhereClauseConfig = { - customKey: string; - customOp: string; -}; - -export const useAutoComplete = ( - query: IBuilderQuery, - whereClauseConfig?: WhereClauseConfig, - shouldUseSuggestions?: boolean, - isInfraMonitoring?: boolean, - entity?: InfraMonitoringEntity | null, - isMetricsExplorer?: boolean, -): IAutoComplete => { - const [searchValue, setSearchValue] = useState(''); - const [searchKey, setSearchKey] = useState(''); - - const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues( - searchValue, - query, - searchKey, - shouldUseSuggestions, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys); - - const handleSearch = (value: string): void => { - const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey); - setSearchValue(value); - setSearchKey(prefixFreeValue); - }; - - const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation( - operator, - result, - ); - - const { handleAddTag, handleClearTag, tags, updateTag } = useTag( - isValidTag, - handleSearch, - query, - setSearchKey, - whereClauseConfig, - isInfraMonitoring, - ); - - const handleSelect = useCallback( - (value: string): void => { - if (isMulti) { - setSearchValue((prev: string) => { - const matches = prev?.matchAll(tagRegexp); - const [match] = matches ? Array.from(matches) : []; - const [, , , matchTagValue] = match; - const data = parse(matchTagValue).data.flat(); - return replaceStringWithMaxLength(prev, data as string[], value); - }); - } - if (!isMulti) { - handleAddTag(value); - } - }, - [handleAddTag, isMulti], - ); - - const handleKeyDown = useCallback( - (event: KeyboardEvent): void => { - if ( - event.key === ' ' && - (searchValue.endsWith(' ') || searchValue.length === 0) - ) { - event.preventDefault(); - } - - if (event.key === 'Enter' && searchValue && isValidTag) { - if (isMulti) { - event.stopPropagation(); - } - event.preventDefault(); - handleAddTag(searchValue); - } - - if (event.key === 'Backspace' && !searchValue) { - event.stopPropagation(); - const last = tags[tags.length - 1]; - handleClearTag(last); - } - }, - [handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags], - ); - - const handleOnBlur = (event: React.FocusEvent): void => { - event.preventDefault(); - if (searchValue) { - if ( - key && - !operator && - whereClauseConfig?.customKey === 'body' && - whereClauseConfig.customOp === OPERATORS.CONTAINS - ) { - const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`; - handleAddTag(value); - return; - } - handleAddTag(searchValue); - } - }; - - const options = useOptions( - key, - keys, - operator, - searchValue, - isMulti, - isValidOperator, - isExist, - results, - result, - isFetching, - whereClauseConfig, - isInfraMonitoring, - ); - - return { - updateTag, - handleSearch, - handleClearTag, - handleSelect, - handleKeyDown, - handleOnBlur, - options, - tags, - searchValue, - isMulti, - isFetching, - setSearchKey, - setSearchValue, - searchKey, - key, - exampleQueries, - }; -}; - -interface IAutoComplete { - updateTag: (value: string) => void; - handleSearch: (value: string) => void; - handleClearTag: (value: string) => void; - handleSelect: (value: string) => void; - handleKeyDown: (event: React.KeyboardEvent) => void; - handleOnBlur: (event: React.FocusEvent) => void; - options: Option[]; - tags: string[]; - searchValue: string; - isMulti: boolean; - isFetching: boolean; - setSearchKey: (value: string) => void; - setSearchValue: (value: string) => void; - searchKey: string; - key: string; - exampleQueries: TagFilter[]; - isInfraMonitoring?: boolean; -} diff --git a/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts b/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts deleted file mode 100644 index 89d7124d4fb..00000000000 --- a/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts +++ /dev/null @@ -1,308 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDebounce } from 'react-use'; -import { getAttributesValues } from 'api/queryBuilder/getAttributesValues'; -import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder'; -import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; -import { - GetK8sEntityToAggregateAttribute, - InfraMonitoringEntity, -} from 'container/InfraMonitoringK8sV2/constants'; -import { - getRemovePrefixFromKey, - getTagToken, - isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import useDebounceValue from 'hooks/useDebounce'; -import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es'; -import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { useGetAggregateKeys } from './useGetAggregateKeys'; -import { useGetAttributeSuggestions } from './useGetAttributeSuggestions'; - -type IuseFetchKeysAndValues = { - keys: BaseAutocompleteData[]; - results: string[]; - isFetching: boolean; - sourceKeys: BaseAutocompleteData[]; - handleRemoveSourceKey: (newSourceKey: string) => void; - exampleQueries: TagFilter[]; -}; - -/** - * Custom hook to fetch attribute keys and values from an API - * @param searchValue - the search query value - * @param query - an object containing data for the query - * @returns an object containing the fetched attribute keys, results, and the status of the fetch - */ - -export const useFetchKeysAndValues = ( - searchValue: string, - query: IBuilderQuery, - searchKey: string, - shouldUseSuggestions?: boolean, - isInfraMonitoring?: boolean, - entity?: InfraMonitoringEntity | null, - isMetricsExplorer?: boolean, -): IuseFetchKeysAndValues => { - const [keys, setKeys] = useState([]); - const [exampleQueries, setExampleQueries] = useState([]); - const [sourceKeys, setSourceKeys] = useState([]); - const [results, setResults] = useState([]); - const [isAggregateFetching, setAggregateFetching] = useState(false); - - const memoizedSearchParams = useMemo( - () => [ - searchKey, - query.dataSource, - query.aggregateOperator, - query.aggregateAttribute?.key, - ], - [ - searchKey, - query.dataSource, - query.aggregateOperator, - query.aggregateAttribute?.key, - ], - ); - - const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY); - - const queryFiltersWithoutId = useMemo( - () => ({ - ...query.filters, - items: query.filters?.items?.map((item) => { - const filterWithoutId = cloneDeep(item); - unset(filterWithoutId, 'id'); - return filterWithoutId; - }), - }), - [query.filters], - ); - - const memoizedSuggestionsParams = useMemo( - () => [searchKey, query.dataSource, queryFiltersWithoutId], - [query.dataSource, queryFiltersWithoutId, searchKey], - ); - - const suggestionsParams = useDebounceValue( - memoizedSuggestionsParams, - DEBOUNCE_DELAY, - ); - - const isQueryEnabled = useMemo( - () => - query.dataSource === DataSource.METRICS && - !isInfraMonitoring && - !isMetricsExplorer - ? !!query.dataSource && !!query.aggregateAttribute?.dataType - : true, - [ - isInfraMonitoring, - isMetricsExplorer, - query.aggregateAttribute?.dataType, - query.dataSource, - ], - ); - - const { data, isFetching, status } = useGetAggregateKeys( - { - searchText: searchKey, - dataSource: query.dataSource, - aggregateOperator: query.aggregateOperator || '', - aggregateAttribute: - isInfraMonitoring && entity - ? GetK8sEntityToAggregateAttribute(entity) - : query.aggregateAttribute?.key || '', - tagType: query.aggregateAttribute?.type ?? null, - }, - { - queryKey: [searchParams], - enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions, - }, - isInfraMonitoring, // isInfraMonitoring - entity, // infraMonitoringEntity - ); - - const { - data: suggestionsData, - isFetching: isFetchingSuggestions, - status: fetchingSuggestionsStatus, - } = useGetAttributeSuggestions( - { - searchText: searchKey, - dataSource: query.dataSource, - filters: query.filters || { items: [], op: 'AND' }, - }, - { - queryKey: [suggestionsParams], - enabled: isQueryEnabled && shouldUseSuggestions, - }, - ); - - function isAttributeValuesResponse( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: any, - ): payload is IAttributeValuesResponse { - return ( - payload && - (Array.isArray(payload.stringAttributeValues) || - payload.stringAttributeValues === null || - Array.isArray(payload.numberAttributeValues) || - payload.numberAttributeValues === null || - Array.isArray(payload.boolAttributeValues) || - payload.boolAttributeValues === null) - ); - } - - /** - * Fetches the options to be displayed based on the selected value - * @param value - the selected value - * @param query - an object containing data for the query - */ - const handleFetchOption = async ( - value: string, - query: IBuilderQuery, - keys: BaseAutocompleteData[], - ): Promise => { - if (!value) { - return; - } - const { tagKey, tagOperator, tagValue } = getTagToken(value); - const filterAttributeKey = keys.find( - (item) => item.key === getRemovePrefixFromKey(tagKey), - ); - setResults([]); - - if (!tagKey || !tagOperator) { - return; - } - setAggregateFetching(true); - - try { - let payload; - if (isInfraMonitoring && entity) { - const response = await getAttributesValues({ - aggregateOperator: 'noop', - dataSource: query.dataSource, - aggregateAttribute: - GetK8sEntityToAggregateAttribute(entity) || - query.aggregateAttribute?.key || - '', - attributeKey: filterAttributeKey?.key ?? tagKey, - filterAttributeKeyDataType: - filterAttributeKey?.dataType ?? DataTypes.EMPTY, - tagType: filterAttributeKey?.type ?? '', - searchText: isInNInOperator(tagOperator) - ? (tagValue[tagValue.length - 1]?.toString() ?? '') - : (tagValue?.toString() ?? ''), - }); - payload = response.payload; - } else { - const response = await getAttributesValues({ - aggregateOperator: query.aggregateOperator || '', - dataSource: query.dataSource, - aggregateAttribute: query.aggregateAttribute?.key || '', - attributeKey: filterAttributeKey?.key ?? tagKey, - filterAttributeKeyDataType: - filterAttributeKey?.dataType ?? DataTypes.EMPTY, - tagType: filterAttributeKey?.type ?? '', - searchText: isInNInOperator(tagOperator) - ? (tagValue[tagValue.length - 1]?.toString() ?? '') - : (tagValue?.toString() ?? ''), - }); - payload = response.payload; - } - - if (payload && isAttributeValuesResponse(payload)) { - const dataType = filterAttributeKey?.dataType ?? DataTypes.String; - const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType]; - setResults(key ? payload[key] || [] : []); - return; - } - } catch (e) { - console.error(e); - } finally { - setAggregateFetching(false); - } - }; - - const handleRemoveSourceKey = useCallback((sourceKey: string) => { - setSourceKeys((prevState) => - prevState.filter((item) => item.key !== sourceKey), - ); - }, []); - - // creates a ref to the fetch function so that it doesn't change on every render - const clearFetcher = useRef(handleFetchOption).current; - - // debounces the fetch function to avoid excessive API calls - useDebounce(() => clearFetcher(searchValue, query, keys), 750, [ - clearFetcher, - searchValue, - query, - keys, - ]); - - // update the fetched keys when the fetch status changes - useEffect(() => { - if (status === 'success' && data?.payload?.attributeKeys) { - setKeys(data.payload.attributeKeys); - setSourceKeys((prevState) => - uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual), - ); - } else { - setKeys([]); - } - }, [data?.payload?.attributeKeys, status]); - - useEffect(() => { - if ( - fetchingSuggestionsStatus === 'success' && - suggestionsData?.payload?.attributes - ) { - if (!isInfraMonitoring) { - setKeys(suggestionsData.payload.attributes); - setSourceKeys((prevState) => - uniqWith( - [...(suggestionsData.payload.attributes ?? []), ...prevState], - isEqual, - ), - ); - } - } else { - setKeys([]); - } - if ( - fetchingSuggestionsStatus === 'success' && - suggestionsData?.payload?.example_queries - ) { - setExampleQueries(suggestionsData.payload.example_queries); - } else { - setExampleQueries([]); - } - }, [ - suggestionsData?.payload?.attributes, - fetchingSuggestionsStatus, - suggestionsData?.payload?.example_queries, - isInfraMonitoring, - ]); - - return { - keys, - results, - isFetching: isFetching || isAggregateFetching || isFetchingSuggestions, - sourceKeys, - handleRemoveSourceKey, - exampleQueries, - }; -}; diff --git a/frontend/src/hooks/queryBuilder/useOperators.ts b/frontend/src/hooks/queryBuilder/useOperators.ts deleted file mode 100644 index 9392a0ad643..00000000000 --- a/frontend/src/hooks/queryBuilder/useOperators.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react'; -import { - OPERATORS, - QUERY_BUILDER_OPERATORS_BY_TYPES, -} from 'constants/queryBuilder'; -import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -type IOperators = - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64 - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64; - -export const useOperators = ( - key: string, - keys: BaseAutocompleteData[], -): IOperators => - useMemo(() => { - const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key)); - const strippedKey = key.split(' ')[0]; - - return currentKey?.dataType - ? QUERY_BUILDER_OPERATORS_BY_TYPES[ - currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES - ] - : strippedKey.endsWith('[*]') && strippedKey.startsWith('body.') - ? [OPERATORS.HAS, OPERATORS.NHAS] - : QUERY_BUILDER_OPERATORS_BY_TYPES.universal; - }, [keys, key]); diff --git a/frontend/src/hooks/queryBuilder/useOptions.ts b/frontend/src/hooks/queryBuilder/useOptions.ts deleted file mode 100644 index 067b4b43cf6..00000000000 --- a/frontend/src/hooks/queryBuilder/useOptions.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder'; -import { - checkCommaInValue, - getTagToken, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { Option } from 'container/QueryBuilder/type'; -import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType'; -import { isEmpty } from 'lodash-es'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -import { WhereClauseConfig } from './useAutoComplete'; -import { useOperators } from './useOperators'; - -export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom'; - -export const useOptions = ( - key: string, - keys: BaseAutocompleteData[], - operator: string, - searchValue: string, - isMulti: boolean, - isValidOperator: boolean, - isExist: boolean, - results: string[], - result: string[], - isFetching: boolean, - whereClauseConfig?: WhereClauseConfig, - isInfraMonitoring?: boolean, - // eslint-disable-next-line sonarjs/cognitive-complexity -): Option[] => { - const [options, setOptions] = useState([]); - const operators = useOperators(key, keys); - - // get matching dynamic variables to suggest - const dashboardDynamicVariables = useDashboardVariablesByType( - 'DYNAMIC', - 'values', - ); - - const variableName = dashboardDynamicVariables?.find( - (variable) => variable?.dynamicVariablesAttribute === key, - )?.name; - - const variableAsValue = variableName ? `$${variableName}` : ''; - - const getLabel = useCallback( - (data: BaseAutocompleteData): Option['label'] => data?.key, - [], - ); - - const getOptionsFromKeys = useCallback( - (items: BaseAutocompleteData[]): Option[] => - items?.map((item) => ({ - label: `${getLabel(item)}`, - value: item.key, - dataType: item.dataType, - isIndexed: item?.isIndexed, - type: item?.type || '', - })), - [getLabel], - ); - - const getKeyOpValue = useCallback( - (items: string[]): Option[] => - items?.map((item) => ({ - label: `${key} ${operator} ${item}`, - value: `${key} ${operator} ${item}`, - })), - [key, operator], - ); - - const getOptionsWithValidOperator = useCallback( - (key: string, results: string[], searchValue: string) => { - const hasAllResults = results.every((value) => result.includes(value)); - - let newResults = results; - if (!isEmpty(variableAsValue)) { - newResults = [variableAsValue, ...newResults]; - } - - const values = getKeyOpValue(newResults); - - return hasAllResults - ? [ - { - label: searchValue, - value: searchValue, - }, - ] - : [ - { - label: searchValue, - value: searchValue, - }, - ...values, - ]; - }, - [getKeyOpValue, result, variableAsValue], - ); - - const getKeyOperatorOptions = useCallback( - (key: string) => { - const keyOperator = key.split(' '); - const partialOperator = keyOperator?.[1]; - const partialKey = keyOperator?.[0]; - const filteredOperators = !isEmpty(partialOperator) - ? operators?.filter((operator) => - operator.startsWith(partialOperator?.toUpperCase()), - ) - : operators; - const operatorsOptions = filteredOperators?.map((op) => { - const labelOp = - isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op] - ? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op] - : op; - return { - value: `${partialKey} ${op} `, - label: `${partialKey} ${labelOp} `, - }; - }); - if (whereClauseConfig) { - return [ - { - label: `${searchValue} `, - value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`, - }, - ...operatorsOptions, - ]; - } - return operatorsOptions; - }, - [isInfraMonitoring, operators, searchValue, whereClauseConfig], - ); - - useEffect(() => { - let newOptions: Option[] = []; - - if (!key) { - newOptions = searchValue - ? [ - { - label: `${searchValue} `, - value: `${searchValue} `, - }, - ...getOptionsFromKeys(keys), - ] - : getOptionsFromKeys(keys); - } else if (key && !operator) { - newOptions = getKeyOperatorOptions(key); - } else if (key && operator) { - if (isMulti) { - const resultsWithVariable = isEmpty(variableAsValue) - ? results - : [variableAsValue, ...results]; - newOptions = resultsWithVariable.map((item) => ({ - label: checkCommaInValue(String(item)), - value: String(item), - })); - } else if (isExist) { - newOptions = []; - } else if (isValidOperator) { - newOptions = getOptionsWithValidOperator(key, results, searchValue); - } - } - if (newOptions.length > 0) { - setOptions(newOptions); - } - if (isFetching) { - setOptions([]); - } - }, [ - whereClauseConfig, - getKeyOpValue, - getOptionsFromKeys, - isExist, - isMulti, - isValidOperator, - key, - keys, - operator, - operators, - result, - results, - searchValue, - getKeyOperatorOptions, - getOptionsWithValidOperator, - isFetching, - variableAsValue, - ]); - - return useMemo( - () => - ( - options.filter( - (option, index, self) => - index === - self.findIndex( - (o) => - o.label === option.label && - o.value === option.value && - (o.type || '') === (option.type || '') && - (o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType - ) && option.value !== '', - ) || [] - ).map((option) => { - const { tagValue } = getTagToken(searchValue); - if (isMulti) { - return { - ...option, - selected: tagValue - .filter((i) => i.trim().replace(/^\s+/, '') === option.value) - .includes(option.value), - }; - } - return option; - }), - [isMulti, options, searchValue], - ); -}; diff --git a/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts b/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts deleted file mode 100644 index 99c22968dd8..00000000000 --- a/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from 'react'; -import { - getRemovePrefixFromKey, - getTagToken, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -type ICurrentKeyAndOperator = [string, string, string[]]; - -export const useSetCurrentKeyAndOperator = ( - value: string, - keys: BaseAutocompleteData[], -): ICurrentKeyAndOperator => { - const [key, operator, result] = useMemo(() => { - let key = ''; - let operator = ''; - let result: string[] = []; - const { tagKey, tagOperator, tagValue } = getTagToken(value); - const isSuggestKey = keys?.some( - (el) => el?.key === getRemovePrefixFromKey(tagKey), - ); - if (isSuggestKey || keys.length === 0) { - key = tagKey || ''; - operator = tagOperator || ''; - result = tagValue || []; - } - - return [key, operator, result]; - }, [value, keys]); - - return [key, operator, result]; -}; diff --git a/frontend/src/hooks/queryBuilder/useTag.ts b/frontend/src/hooks/queryBuilder/useTag.ts index 55831d36998..482fd77a03c 100644 --- a/frontend/src/hooks/queryBuilder/useTag.ts +++ b/frontend/src/hooks/queryBuilder/useTag.ts @@ -1,18 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder'; import { getOperatorFromValue, - getTagToken, - isExistsNotExistsOperator, isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { unparse } from 'papaparse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; - -import { WhereClauseConfig } from './useAutoComplete'; +import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; /** * Helper for formatting a TagFilter object into filter item strings @@ -42,95 +34,3 @@ export function queryFilterTags( return `${ele.key?.key} ${displayOp} ${ele.value}`; }); } - -type IUseTag = { - handleAddTag: (value: string) => void; - handleClearTag: (value: string) => void; - tags: string[]; - updateTag: (value: string) => void; -}; - -/** - * A custom React hook for handling tags. - * @param {string} key - A string value to identify tags. - * @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid. - * @param {function} handleSearch - A callback function to handle search. - * @returns {IUseTag} The return object containing handlers and tags. - */ - -export const useTag = ( - isValidTag: boolean, - handleSearch: (value: string) => void, - query: IBuilderQuery, - setSearchKey: (value: string) => void, - whereClauseConfig?: WhereClauseConfig, - isInfraMonitoring?: boolean, -): IUseTag => { - const initTagsData = useMemo( - () => - queryFilterTags( - query?.filters || { items: [], op: 'AND' }, - isInfraMonitoring, - ), - [query?.filters, isInfraMonitoring], - ); - - const [tags, setTags] = useState(initTagsData); - - const updateTag = (value: string): void => { - const newTags = tags?.filter((item: string) => item !== value); - setTags(newTags); - }; - - /** - * Adds a new tag to the tag list. - * @param {string} value - The tag value to be added. - */ - - const handleAddTag = useCallback( - (value: string): void => { - const { tagKey } = getTagToken(value); - const parts = tagKey.split('-'); - // this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world` - let id = parts[parts.length - 1]; - let key = parts.slice(0, -1).join('-'); - if (parts.length === 1) { - id = ''; - [key] = parts; - } - - if (id === 'custom') { - const customValue = whereClauseConfig - ? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}` - : ''; - setTags((prevTags) => - prevTags.includes(customValue) ? prevTags : [...prevTags, customValue], - ); - handleSearch(''); - setSearchKey(''); - return; - } - - if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) { - setTags((prevTags) => [...prevTags, value]); - handleSearch(''); - setSearchKey(''); - } - }, - [whereClauseConfig, isValidTag, handleSearch, setSearchKey], - ); - - /** - * Removes a tag from the tag list. - * @param {string} value - The tag value to be removed. - */ - const handleClearTag = useCallback((value: string): void => { - setTags((prevTags) => prevTags.filter((v) => v !== value)); - }, []); - - useEffect(() => { - setTags(initTagsData); - }, [initTagsData]); - - return { handleAddTag, handleClearTag, tags, updateTag }; -}; diff --git a/frontend/src/hooks/trace/useTraceActions.ts b/frontend/src/hooks/trace/useTraceActions.ts index 172c5b566cc..f9c26faca93 100644 --- a/frontend/src/hooks/trace/useTraceActions.ts +++ b/frontend/src/hooks/trace/useTraceActions.ts @@ -6,7 +6,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useNotifications } from 'hooks/useNotifications'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx index 8b7e5aa7a5a..b52c88f3785 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx @@ -16,7 +16,7 @@ import LogsError from 'container/LogsError/LogsError'; import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils'; import { LogsLoading } from 'container/LogsLoading/LogsLoading'; import { FontSize } from 'container/OptionsMenu/types'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import createQueryParams from 'lib/createQueryParams'; import { Compass } from '@signozhq/icons'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts index b6db739fed5..96485842819 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts @@ -4,7 +4,7 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils'; import { ENTITY_VERSION_V5 } from 'constants/app'; import { OPERATORS } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { ILog } from 'types/api/logs/log'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts index 3e9666c9a4d..12ff2d5cd76 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts @@ -7,7 +7,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui import { SOMETHING_WENT_WRONG } from 'constants/api'; import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons'; diff --git a/pkg/apiserver/signozapiserver/authdomain.go b/pkg/apiserver/signozapiserver/authdomain.go index 690bfb8b344..fbbd61774e4 100644 --- a/pkg/apiserver/signozapiserver/authdomain.go +++ b/pkg/apiserver/signozapiserver/authdomain.go @@ -1,99 +1,269 @@ package signozapiserver import ( + "encoding/json" "net/http" + "slices" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/handler" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/gorilla/mux" + "github.com/tidwall/gjson" ) func (provider *provider) addAuthDomainRoutes(router *mux.Router) error { - if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{ - ID: "ListAuthDomains", - Tags: []string{"authdomains"}, - Summary: "List all auth domains", - Description: "This endpoint lists all auth domains", - Request: nil, - RequestContentType: "", - Response: make([]*authtypes.GettableAuthDomain, 0), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodGet).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.List, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "ListAuthDomains", + Tags: []string{"authdomains"}, + Summary: "List all auth domains", + Description: "This endpoint lists all auth domains", + Request: nil, + RequestContentType: "", + Response: make([]*authtypes.GettableAuthDomain, 0), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbList)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbList, + Category: coretypes.ActionCategoryAccessControl, + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{ - ID: "CreateAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Create auth domain", - Description: "This endpoint creates an auth domain", - Request: new(authtypes.PostableAuthDomain), - RequestContentType: "application/json", - Response: new(types.Identifiable), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodPost).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Create, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "CreateAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Create auth domain", + Description: "This endpoint creates an auth domain", + Request: new(authtypes.PostableAuthDomain), + RequestContentType: "application/json", + Response: new(types.Identifiable), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusCreated, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{ + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbCreate), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach), + coretypes.ResourceRole.Scope(coretypes.VerbAttach), + }), + }, + handler.WithResourceDefs( + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbCreate, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.ResponseJSONPath("data.id"), + Selector: coretypes.WildcardSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbAttach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")), + SourceSelector: coretypes.WildcardSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: authDomainRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + ), + )).Methods(http.MethodPost).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{ - ID: "GetAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Get auth domain by ID", - Description: "This endpoint returns an auth domain by ID", - Request: nil, - RequestContentType: "", - Response: new(authtypes.GettableAuthDomain), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{http.StatusNotFound}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodGet).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Get, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "GetAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Get auth domain by ID", + Description: "This endpoint returns an auth domain by ID", + Request: nil, + RequestContentType: "", + Response: new(authtypes.GettableAuthDomain), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbRead)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbRead, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{ - ID: "UpdateAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Update auth domain", - Description: "This endpoint updates an auth domain", - Request: new(authtypes.UpdatableAuthDomain), - RequestContentType: "application/json", - Response: nil, - ResponseContentType: "", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodPut).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Update, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "UpdateAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Update auth domain", + Description: "This endpoint updates an auth domain", + Request: new(authtypes.UpdatableAuthDomain), + RequestContentType: "application/json", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{ + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbUpdate), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDetach), + coretypes.ResourceRole.Scope(coretypes.VerbAttach), + coretypes.ResourceRole.Scope(coretypes.VerbDetach), + }), + }, + handler.WithResourceDefs( + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbUpdate, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbAttach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.PathParam("id")), + SourceSelector: coretypes.IDSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: authDomainRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbDetach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.PathParam("id")), + SourceSelector: coretypes.IDSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: provider.authDomainStoredRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + ), + )).Methods(http.MethodPut).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{ - ID: "DeleteAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Delete auth domain", - Description: "This endpoint deletes an auth domain", - Request: nil, - RequestContentType: "", - Response: nil, - ResponseContentType: "", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{http.StatusBadRequest}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodDelete).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Delete, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "DeleteAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Delete auth domain", + Description: "This endpoint deletes an auth domain", + Request: nil, + RequestContentType: "", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDelete)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbDelete, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodDelete).GetError(); err != nil { return err } return nil } + +// The extracted names are the roles the request body's mapping grants at SSO +// login — see authDomainEffectiveRoleNames. +func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor { + return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) { + roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping") + if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null { + return authDomainEffectiveRoleNames(nil), nil + } + + roleMapping := new(authtypes.RoleMapping) + if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err) + } + + return authDomainEffectiveRoleNames(roleMapping), nil + }} +} + +// The extracted names are the roles the stored domain's mapping grants at SSO +// login — an update replaces that mapping, so the caller must be able to detach +// them. +func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor { + return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) { + if ec.Request == nil { + return nil, nil + } + + claims, err := authtypes.ClaimsFromContext(ec.Request.Context()) + if err != nil { + return nil, err + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + return nil, err + } + + id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"]) + if err != nil { + return nil, err + } + + authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id) + if err != nil { + return nil, err + } + + return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil + }} +} + +// The effective names are the roles a domain grants at SSO login: the mapped +// roles plus the default (signoz-viewer when unset), or every role when the IDP +// role attribute is trusted. Never empty — a check with no selectors is forbidden. +func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string { + if roleMapping == nil { + return []string{authtypes.SigNozViewerRoleName} + } + + if roleMapping.UseRoleAttribute { + return []string{coretypes.WildCardSelectorString} + } + + roleNames := roleMapping.RoleNames() + if !slices.Contains(roleNames, roleMapping.DefaultRoleName()) { + roleNames = append(roleNames, roleMapping.DefaultRoleName()) + } + + return roleNames +} diff --git a/pkg/apiserver/signozapiserver/provider.go b/pkg/apiserver/signozapiserver/provider.go index 4854bf01854..c3b79b858bc 100644 --- a/pkg/apiserver/signozapiserver/provider.go +++ b/pkg/apiserver/signozapiserver/provider.go @@ -51,6 +51,7 @@ type provider struct { userHandler user.Handler sessionHandler session.Handler authDomainHandler authdomain.Handler + authDomainModule authdomain.Module preferenceHandler preference.Handler globalHandler global.Handler promoteHandler promote.Handler @@ -88,6 +89,7 @@ func NewFactory( userHandler user.Handler, sessionHandler session.Handler, authDomainHandler authdomain.Handler, + authDomainModule authdomain.Module, preferenceHandler preference.Handler, globalHandler global.Handler, promoteHandler promote.Handler, @@ -128,6 +130,7 @@ func NewFactory( userHandler, sessionHandler, authDomainHandler, + authDomainModule, preferenceHandler, globalHandler, promoteHandler, @@ -170,6 +173,7 @@ func newProvider( userHandler user.Handler, sessionHandler session.Handler, authDomainHandler authdomain.Handler, + authDomainModule authdomain.Module, preferenceHandler preference.Handler, globalHandler global.Handler, promoteHandler promote.Handler, @@ -211,6 +215,7 @@ func newProvider( authzService: authzService, sessionHandler: sessionHandler, authDomainHandler: authDomainHandler, + authDomainModule: authDomainModule, preferenceHandler: preferenceHandler, globalHandler: globalHandler, promoteHandler: promoteHandler, diff --git a/pkg/apiserver/signozapiserver/serviceaccount.go b/pkg/apiserver/signozapiserver/serviceaccount.go index 62671599fec..561caabae5c 100644 --- a/pkg/apiserver/signozapiserver/serviceaccount.go +++ b/pkg/apiserver/signozapiserver/serviceaccount.go @@ -138,66 +138,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error { return err } - if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New( - provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName), - handler.OpenAPIDef{ - ID: "CreateServiceAccountRoleDeprecated", - Tags: []string{"serviceaccount"}, - Summary: "Create service account role", - Description: "This endpoint assigns a role to a service account", - Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole), - RequestContentType: "", - Response: new(types.Identifiable), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{http.StatusBadRequest}, - Deprecated: true, - SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}), - }, - handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{ - Verb: coretypes.VerbAttach, - Category: coretypes.ActionCategoryAccessControl, - SourceResource: coretypes.ResourceServiceAccount, - SourceIDs: coretypes.OneID(coretypes.PathParam("id")), - SourceSelector: coretypes.IDSelector, - TargetResource: coretypes.ResourceRole, - TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")), - TargetSelector: provider.roleSelector, - }), - )).Methods(http.MethodPost).GetError(); err != nil { - return err - } - - if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New( - provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName), - handler.OpenAPIDef{ - ID: "DeleteServiceAccountRoleDeprecated", - Tags: []string{"serviceaccount"}, - Summary: "Delete service account role", - Description: "This endpoint revokes a role from service account", - Request: nil, - RequestContentType: "", - Response: nil, - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{}, - Deprecated: true, - SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}), - }, - handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{ - Verb: coretypes.VerbDetach, - Category: coretypes.ActionCategoryAccessControl, - SourceResource: coretypes.ResourceServiceAccount, - SourceIDs: coretypes.OneID(coretypes.PathParam("id")), - SourceSelector: coretypes.IDSelector, - TargetResource: coretypes.ResourceRole, - TargetIDs: coretypes.OneID(coretypes.PathParam("rid")), - TargetSelector: provider.roleSelector, - }), - )).Methods(http.MethodDelete).GetError(); err != nil { - return err - } - if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{ ID: "UpdateMyServiceAccount", Tags: []string{"serviceaccount"}, diff --git a/pkg/modules/serviceaccount/implserviceaccount/handler.go b/pkg/modules/serviceaccount/implserviceaccount/handler.go index 37cb444dbe8..9c1dfc74679 100644 --- a/pkg/modules/serviceaccount/implserviceaccount/handler.go +++ b/pkg/modules/serviceaccount/implserviceaccount/handler.go @@ -214,64 +214,6 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) { render.Success(rw, http.StatusNoContent, nil) } -func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(rw, err) - return - } - - id, err := valuer.NewUUID(mux.Vars(r)["id"]) - if err != nil { - render.Error(rw, err) - return - } - - req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole) - if err := binding.JSON.BindBody(r.Body, req); err != nil { - render.Error(rw, err) - return - } - - _, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusNoContent, nil) -} - -func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(rw, err) - return - } - - id, err := valuer.NewUUID(mux.Vars(r)["id"]) - if err != nil { - render.Error(rw, err) - return - } - - roleID, err := valuer.NewUUID(mux.Vars(r)["rid"]) - if err != nil { - render.Error(rw, err) - return - } - - err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), id, roleID) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusNoContent, nil) -} - func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() claims, err := authtypes.ClaimsFromContext(ctx) diff --git a/pkg/modules/serviceaccount/serviceaccount.go b/pkg/modules/serviceaccount/serviceaccount.go index 828a570eba5..b9bc8381890 100644 --- a/pkg/modules/serviceaccount/serviceaccount.go +++ b/pkg/modules/serviceaccount/serviceaccount.go @@ -94,10 +94,6 @@ type Handler interface { UpdateMe(http.ResponseWriter, *http.Request) - SetRole(http.ResponseWriter, *http.Request) - - DeleteRole(http.ResponseWriter, *http.Request) - CreateServiceAccountRole(http.ResponseWriter, *http.Request) GetServiceAccountRole(http.ResponseWriter, *http.Request) diff --git a/pkg/signoz/openapi.go b/pkg/signoz/openapi.go index 064cc56abeb..52a325f132c 100644 --- a/pkg/signoz/openapi.go +++ b/pkg/signoz/openapi.go @@ -64,6 +64,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta struct{ user.Handler }{}, struct{ session.Handler }{}, struct{ authdomain.Handler }{}, + struct{ authdomain.Module }{}, struct{ preference.Handler }{}, struct{ global.Handler }{}, struct{ promote.Handler }{}, diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 267c8b7112f..52d80b2e194 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -243,6 +243,7 @@ func NewSQLMigrationProviderFactories( sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore), sqlmigration.NewDeleteOrphanUserRolesFactory(), sqlmigration.NewMigrateLambdaDashboardsFactory(), + sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore), ) } @@ -317,6 +318,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au impluser.NewHandler(modules.UserSetter, modules.UserGetter), implsession.NewHandler(modules.Session, globalConfig), implauthdomain.NewHandler(modules.AuthDomain), + modules.AuthDomain, implpreference.NewHandler(modules.Preference), handlers.Global, implpromote.NewHandler(modules.Promote), diff --git a/pkg/sqlmigration/117_add_auth_domain_tuples.go b/pkg/sqlmigration/117_add_auth_domain_tuples.go new file mode 100644 index 00000000000..3ebb2faa3c3 --- /dev/null +++ b/pkg/sqlmigration/117_add_auth_domain_tuples.go @@ -0,0 +1,167 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/oklog/ulid/v2" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" + "github.com/uptrace/bun/migrate" +) + +type addAuthDomainTuples struct { + sqlstore sqlstore.SQLStore +} + +func NewAddAuthDomainTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_auth_domain_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return &addAuthDomainTuples{sqlstore: sqlstore}, nil + }) +} + +func (migration *addAuthDomainTuples) Register(migrations *migrate.Migrations) error { + return migrations.Register(migration.Up, migration.Down) +} + +func (migration *addAuthDomainTuples) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var storeID string + err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID) + if err != nil { + return err + } + + var orgIDs []string + err = tx.NewSelect(). + Table("organizations"). + Column("id"). + Scan(ctx, &orgIDs) + if err != nil && err != sql.ErrNoRows { + return err + } + + isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG + + // auth-domain moved from the legacy AdminAccess role gate to CheckResources, + // which on enterprise requires real tuples -- existing orgs never had these + // written, only new orgs get them from the registry at bootstrap. + tuples := []migrationTuple{ + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "create"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "read"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "update"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "delete"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "list"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "attach"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "detach"}, + } + + for _, orgID := range orgIDs { + for _, tuple := range tuples { + entropy := ulid.DefaultEntropy() + now := time.Now().UTC() + tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String() + + objectID := "organization/" + orgID + "/" + tuple.objectName + "/*" + roleSubject := "organization/" + orgID + "/role/" + tuple.roleName + + if isPG { + user := "role:" + roleSubject + "#assignee" + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now, + ) + if err != nil { + return err + } + } else { + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now, + ) + if err != nil { + return err + } + } + } + } + + // The auth-domain attach/detach transactions are new to the registry, so the + // managed-role transaction groups stored per org (served by the roles API) + // must be re-synced from it, like 105_update_role_transaction_groups did. + managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions)) + for roleName, transactions := range coretypes.ManagedRoleToTransactions { + data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions)) + if err != nil { + return err + } + managedRoleGroups[roleName] = string(data) + } + + for _, orgID := range orgIDs { + for roleName, data := range managedRoleGroups { + if _, err := tx.NewUpdate(). + Model(new(roles)). + Set("transaction_groups = ?", data). + Where("org_id = ?", orgID). + Where("type = ?", authtypes.RoleTypeManaged.StringValue()). + Where("name = ?", roleName). + Exec(ctx); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (migration *addAuthDomainTuples) Down(context.Context, *bun.DB) error { + return nil +} diff --git a/pkg/types/coretypes/registry_managed_role.go b/pkg/types/coretypes/registry_managed_role.go index 7c2ced052eb..3cb0e6d823a 100644 --- a/pkg/types/coretypes/registry_managed_role.go +++ b/pkg/types/coretypes/registry_managed_role.go @@ -33,6 +33,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{ {Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, {Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, {Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, + {Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, + {Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, // cloud-integration — admin only {Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)}, {Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)}, diff --git a/pkg/types/serviceaccounttypes/service_acccount.go b/pkg/types/serviceaccounttypes/service_acccount.go index c1c03ff8f2d..5809d64af19 100644 --- a/pkg/types/serviceaccounttypes/service_acccount.go +++ b/pkg/types/serviceaccounttypes/service_acccount.go @@ -69,10 +69,6 @@ type PostableServiceAccount struct { Name string `json:"name" required:"true"` } -type DeprecatedPostableServiceAccountRole struct { - ID valuer.UUID `json:"id" required:"true"` -} - type PostableServiceAccountRole struct { ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"` RoleID valuer.UUID `json:"roleId" required:"true"` diff --git a/tests/integration/testdata/role/managed_role_transactions.json b/tests/integration/testdata/role/managed_role_transactions.json index 14b7a9f0922..ce196598273 100644 --- a/tests/integration/testdata/role/managed_role_transactions.json +++ b/tests/integration/testdata/role/managed_role_transactions.json @@ -47,7 +47,9 @@ "read", "update", "delete", - "list" + "list", + "attach", + "detach" ] }, { diff --git a/tests/integration/tests/callbackauthn/05_authz.py b/tests/integration/tests/callbackauthn/05_authz.py new file mode 100644 index 00000000000..29439c57006 --- /dev/null +++ b/tests/integration/tests/callbackauthn/05_authz.py @@ -0,0 +1,511 @@ +from collections.abc import Callable +from http import HTTPStatus + +import requests +from wiremock.resources.mappings import Mapping + +from fixtures import types +from fixtures.auth import ( + USER_ADMIN_EMAIL, + USER_ADMIN_PASSWORD, + add_license, + change_user_role, + create_active_user, +) +from fixtures.role import find_role_by_name, transaction_group + +_EDITOR_EMAIL = "editor+authdomainauthz@integration.test" +_EDITOR_PASSWORD = "password123Z$" +_VIEWER_EMAIL = "viewer+authdomainauthz@integration.test" +_VIEWER_PASSWORD = "password123Z$" + +_ACTOR_ROLE_NAME = "auth-domain-fga-actor" +_ACTOR_EMAIL = "customrole+authdomainauthz@integration.test" +_ACTOR_PASSWORD = "password123Z$" + +# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden. +_TARGET_A = "target-a-authdomain.integration.test" +_TARGET_B = "target-b-authdomain.integration.test" +_ADMIN_DOMAIN = "admin-crud-authdomain.integration.test" +_ACTOR_DOMAIN = "actor-crud-authdomain.integration.test" + +_SAML_CONFIG = { + "kind": "saml", + "spec": { + "entityId": "saml-entity", + "location": "saml-idp", + "certificate": "saml-cert", + }, +} + + +def test_setup_managed_role_users_and_targets( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + for email, role, password, name in ( + (_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "auth domain authz editor"), + (_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "auth domain authz viewer"), + ): + create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name) + + for name in (_TARGET_A, _TARGET_B): + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": name, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + + +def test_admin_can_crud( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ADMIN_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + domain_id = response.json()["data"]["id"] + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert _ADMIN_DOMAIN in {domain["name"] for domain in response.json()["data"]} + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_editor_and_viewer_forbidden( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A) + + for email, password in ((_EDITOR_EMAIL, _EDITOR_PASSWORD), (_VIEWER_EMAIL, _VIEWER_PASSWORD)): + token = get_token(email, password) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} list: expected 403, got {response.status_code}: {response.text}" + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} create: expected 403, got {response.status_code}: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} get: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} update: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} delete: expected 403, got {response.status_code}: {response.text}" + + +def test_apply_license( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None], + get_token: Callable[[str, str], str], +) -> None: + add_license(signoz, make_http_mocks, get_token) + + +def test_setup_actor( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], + create_role: Callable[..., str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + create_role(admin_token, _ACTOR_ROLE_NAME) + + user_id = create_active_user( + signoz, + admin_token, + email=_ACTOR_EMAIL, + role="signoz-viewer", + password=_ACTOR_PASSWORD, + name="auth-domain-fga-test-user", + ) + change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME) + + +def test_actor_without_grants_forbidden( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"list without grant: expected 403, got {response.status_code}: {response.text}" + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"create without grant: expected 403, got {response.status_code}: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"read without grant: expected 403, got {response.status_code}: {response.text}" + + +def test_create_requires_attach_on_mapped_roles( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("create", "metaresource", "auth-domain", ["*"]), + transaction_group("delete", "metaresource", "auth-domain", ["*"]), + transaction_group("list", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + # No roleMapping means SSO users get signoz-viewer, which is granted. + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, f"create with default mapping: {response.text}" + domain_id = response.json()["data"]["id"] + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR"}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"map to unattachable role: expected 403, got {response.status_code}: {response.text}" + + # Trusting the IDP role attribute can grant any role, so it needs attach on role "*". + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "VIEWER", "useRoleAttribute": True}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"role attribute without wildcard attach: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("create", "metaresource", "auth-domain", ["*"]), + transaction_group("delete", "metaresource", "auth-domain", ["*"]), + transaction_group("list", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "role", "role", ["*"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR", "groupMappings": {"platform-team": "ADMIN"}}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, f"create with wildcard role attach: {response.text}" + domain_id = response.json()["data"]["id"] + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_update_requires_detach_on_stored_roles( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR"}, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + domain_id = response.json()["data"]["id"] + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("update", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "metaresource", "auth-domain", [domain_id]), + transaction_group("detach", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + # Dropping the mapping detaches the stored signoz-editor grant, which the + # actor cannot detach. + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"drop mapping without detach on stored role: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("update", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "metaresource", "auth-domain", [domain_id]), + transaction_group("detach", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-editor"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"drop mapping with detach on stored role: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_instance_verbs_scoped_to_granted_domain( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + ids = {domain["name"]: domain["id"] for domain in response.json()["data"]} + a_id, b_id = ids[_TARGET_A], ids[_TARGET_B] + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("read", "metaresource", "auth-domain", [a_id]), + transaction_group("update", "metaresource", "auth-domain", [a_id]), + transaction_group("delete", "metaresource", "auth-domain", [a_id]), + transaction_group("attach", "metaresource", "auth-domain", [a_id]), + transaction_group("detach", "metaresource", "auth-domain", [a_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, f"read granted domain: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"read other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"update granted domain: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"update other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"delete other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"delete granted domain: {response.text}"