diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss new file mode 100644 index 0000000000..bb1fa9678c --- /dev/null +++ b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss @@ -0,0 +1,5 @@ +.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 new file mode 100644 index 0000000000..1a6d501ff6 --- /dev/null +++ b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx @@ -0,0 +1,654 @@ +/* eslint-disable sonarjs/cognitive-complexity */ + +import './ClientSideQBSearch.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Select, Tag, 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 'lucide-react'; +import type { BaseSelectRef } from 'rc-select'; +import { + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +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'; + +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: '', + isColumn: true, + isJSON: false, + 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 ( + + + + { + 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/container/AlertHistory/Timeline/Table/Table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss index 26e2266ef6..5a143dd1b8 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss @@ -109,8 +109,8 @@ } .alert-rule { - &-value, - &-created-at { + &__value, + &__created-at { color: var(--text-ink-400); } } diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx index f3144b88e6..be5c7ae9c3 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -1,16 +1,20 @@ import './Table.styles.scss'; import { Table } from 'antd'; +import { initialFilters } from 'constants/queryBuilder'; import { useGetAlertRuleDetailsTimelineTable, useTimelineTable, } from 'pages/AlertDetails/hooks'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { timelineTableColumns } from './useTimelineTable'; function TimelineTable(): JSX.Element { + const [filters, setFilters] = useState(initialFilters); + const { isLoading, isRefetching, @@ -18,13 +22,14 @@ function TimelineTable(): JSX.Element { data, isValidRuleId, ruleId, - } = useGetAlertRuleDetailsTimelineTable(); + } = useGetAlertRuleDetailsTimelineTable({ filters }); - const { timelineData, totalItems } = useMemo(() => { + const { timelineData, totalItems, labels } = useMemo(() => { const response = data?.payload?.data; return { timelineData: response?.items, totalItems: response?.total, + labels: response?.labels, }; }, [data?.payload?.data]); @@ -42,7 +47,11 @@ function TimelineTable(): JSX.Element {
`${row.fingerprint}-${row.value}-${row.unixMilli}`} - columns={timelineTableColumns()} + columns={timelineTableColumns({ + filters, + labels: labels ?? {}, + setFilters, + })} dataSource={timelineData} pagination={paginationConfig} size="middle" diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx index 1eb43fc417..5c67caa984 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -1,13 +1,84 @@ import { EllipsisOutlined } from '@ant-design/icons'; +import { Color } from '@signozhq/design-tokens'; import { Button } from 'antd'; import { ColumnsType } from 'antd/es/table'; +import ClientSideQBSearch, { + AttributeKey, +} from 'components/ClientSideQBSearch/ClientSideQBSearch'; import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; -import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { Search } from 'lucide-react'; +import AlertLabels, { + AlertLabelsProps, +} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { useMemo } from 'react'; import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { formatEpochTimestamp } from 'utils/timeUtils'; -export const timelineTableColumns = (): ColumnsType => [ +const transformLabelsToQbKeys = ( + labels: AlertRuleTimelineTableResponse['labels'], +): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]); + +function LabelFilter({ + filters, + setFilters, + labels, +}: { + setFilters: (filters: TagFilter) => void; + filters: TagFilter; + labels: AlertLabelsProps['labels']; +}): JSX.Element | null { + const isDarkMode = useIsDarkMode(); + + const { transformedKeys, attributesMap } = useMemo( + () => ({ + transformedKeys: transformLabelsToQbKeys(labels || {}), + attributesMap: transformKeyValuesToAttributeValuesMap(labels), + }), + [labels], + ); + + const handleSearch = (tagFilters: TagFilter): void => { + const tagFiltersLength = tagFilters.items.length; + + if ( + (!tagFiltersLength && (!filters || !filters.items.length)) || + tagFiltersLength === filters?.items.length + ) { + return; + } + setFilters(tagFilters); + }; + + return ( + + } + /> + ); +} + +export const timelineTableColumns = ({ + filters, + labels, + setFilters, +}: { + filters: TagFilter; + labels: AlertLabelsProps['labels']; + setFilters: (filters: TagFilter) => void; +}): ColumnsType => [ { title: 'STATE', dataIndex: 'state', @@ -20,7 +91,9 @@ export const timelineTableColumns = (): ColumnsType + ), dataIndex: 'labels', render: (labels): JSX.Element => (
diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss index 624546fed5..7aee4f9414 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss @@ -334,8 +334,8 @@ .qb-search-bar-tokenised-tags { .ant-tag { - border: 1px solid var(--bg-slate-100); background: var(--bg-vanilla-300); + border: 1px solid var(--bg-slate-100); box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); .ant-typography { diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss index 153f32e5ee..1b434316e5 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss @@ -13,6 +13,7 @@ width: 5px; border-radius: 50%; background-color: var(--bg-slate-300); + flex-shrink: 0; } .option { @@ -207,6 +208,10 @@ background: var(--bg-vanilla-300); } } + + .value { + color: var(--bg-ink-100); + } } } .option:hover { diff --git a/frontend/src/container/QueryBuilder/filters/utils.ts b/frontend/src/container/QueryBuilder/filters/utils.ts index 7e4c12d9d0..ff52e7cb70 100644 --- a/frontend/src/container/QueryBuilder/filters/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/utils.ts @@ -1,3 +1,4 @@ +import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch'; import { HAVING_FILTER_REGEXP } from 'constants/regExp'; import { IOption } from 'hooks/useResourceAttribute/types'; import uniqWith from 'lodash-es/unionWith'; @@ -92,3 +93,20 @@ 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[]) : [], + }, + ]), + ); diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx index bdc5eaa019..4386417219 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -4,7 +4,7 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel'; import SeeMore from 'periscope/components/SeeMore'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AlertLabelsProps = { +export type AlertLabelsProps = { labels: Record; initialCount?: number; }; diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index da02f10b40..8a630a6374 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -43,6 +43,7 @@ import { AlertRuleTopContributorsPayload, } from 'types/api/alerts/def'; import { PayloadProps } from 'types/api/alerts/get'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { nanoToMilli } from 'utils/timeUtils'; export const useAlertHistoryQueryParams = (): { @@ -249,7 +250,11 @@ type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { | undefined; }; -export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { +export const useGetAlertRuleDetailsTimelineTable = ({ + filters, +}: { + filters: TagFilter; +}): GetAlertRuleDetailsTimelineTableProps => { const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams(); const { updatedOrder, offset } = useMemo( () => ({ @@ -273,6 +278,7 @@ export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimeli timelineFilter, updatedOrder, offset, + JSON.stringify(filters.items), ], { queryFn: () => @@ -283,7 +289,7 @@ export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimeli limit: TIMELINE_TABLE_PAGE_SIZE, order: updatedOrder, offset, - + filters, ...(timelineFilter && timelineFilter !== TimelineFilter.ALL ? { state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal', diff --git a/frontend/src/providers/Alert.tsx b/frontend/src/providers/Alert.tsx index 328c8e8aee..50b6584491 100644 --- a/frontend/src/providers/Alert.tsx +++ b/frontend/src/providers/Alert.tsx @@ -18,9 +18,13 @@ function AlertRuleProvider({ undefined, ); - const value = React.useMemo(() => ({ alertRuleState, setAlertRuleState }), [ - alertRuleState, - ]); + const value = React.useMemo( + () => ({ + alertRuleState, + setAlertRuleState, + }), + [alertRuleState], + ); return ( diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index cd26d9864b..2897b6219b 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -1,3 +1,4 @@ +import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; // default match type for threshold @@ -96,7 +97,11 @@ export interface AlertRuleTimelineTableResponse { relatedLogsLink: string; } export type AlertRuleTimelineTableResponsePayload = { - data: { items: AlertRuleTimelineTableResponse[]; total: number }; + data: { + items: AlertRuleTimelineTableResponse[]; + total: number; + labels: AlertLabelsProps['labels']; + }; }; type AlertState = 'firing' | 'normal' | 'no-data' | 'muted';