From 9733612be8a90ec6bcf8c48ed16df96f1073b0bd Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 4 Jun 2024 14:03:49 +0530 Subject: [PATCH] feat: added trace-filter in new trace-explorer (#5081) * feat: added trace-filter in new trace-explorer * feat: added trace-filter in new trace-explorer * feat: style improvement * feat: query builder sync and filter section refactor * feat: added duration and code refactor * feat: added default open case * feat: removed API calls and used keys from const * feat: added sync and prepare data logic for querybuilder * feat: added styles for lightmode * feat: code refactor and sync issue fixed * feat: code refactor and sync issue fixed * feat: code refactor and feedback issue fixed * feat: checkbox label and other feedback fix * feat: filter open and close btn style and handling * feat: added filter reset and clear all * feat: fixed query modification issue when filtering * feat: code refactor * feat: search text via BE API * feat: added CTA btn for old explorer page * feat: make trace-explorer default page * feat: removed new ribbon on CTA for old trace explorer * feat: fixed qb and filter panel sync via url state * feat: fixed duration section issues --- .../src/container/NewExplorerCTA/config.ts | 1 + .../src/container/NewExplorerCTA/index.tsx | 9 +- .../filters/QueryBuilderSearch/index.tsx | 2 +- frontend/src/container/SideNav/menuItems.tsx | 2 +- .../TopNav/DateTimeSelectionV2/index.tsx | 9 + .../queryBuilder/useFetchKeysAndValues.ts | 38 +- .../TracesExplorer/Filter/DurationSection.tsx | 143 ++++++++ .../TracesExplorer/Filter/Filter.styles.scss | 190 ++++++++++ .../pages/TracesExplorer/Filter/Filter.tsx | 247 +++++++++++++ .../pages/TracesExplorer/Filter/Section.tsx | 81 ++++ .../TracesExplorer/Filter/SectionContent.tsx | 161 ++++++++ .../TracesExplorer/Filter/filterUtils.ts | 347 ++++++++++++++++++ .../TracesExplorer/TracesExplorer.styles.scss | 79 +++- frontend/src/pages/TracesExplorer/index.tsx | 84 +++-- 14 files changed, 1338 insertions(+), 55 deletions(-) create mode 100644 frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx create mode 100644 frontend/src/pages/TracesExplorer/Filter/Filter.styles.scss create mode 100644 frontend/src/pages/TracesExplorer/Filter/Filter.tsx create mode 100644 frontend/src/pages/TracesExplorer/Filter/Section.tsx create mode 100644 frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx create mode 100644 frontend/src/pages/TracesExplorer/Filter/filterUtils.ts diff --git a/frontend/src/container/NewExplorerCTA/config.ts b/frontend/src/container/NewExplorerCTA/config.ts index e5ccc110d3..c798e4fa51 100644 --- a/frontend/src/container/NewExplorerCTA/config.ts +++ b/frontend/src/container/NewExplorerCTA/config.ts @@ -8,4 +8,5 @@ export const buttonText: Record = { [ROUTES.LOGS_EXPLORER]: 'Switch to Old Logs Explorer', [ROUTES.TRACE]: 'Try new Traces Explorer', [ROUTES.OLD_LOGS_EXPLORER]: 'Switch to New Logs Explorer', + [ROUTES.TRACES_EXPLORER]: 'Switch to Old Trace Explorer', }; diff --git a/frontend/src/container/NewExplorerCTA/index.tsx b/frontend/src/container/NewExplorerCTA/index.tsx index 5b6e485193..c91151940c 100644 --- a/frontend/src/container/NewExplorerCTA/index.tsx +++ b/frontend/src/container/NewExplorerCTA/index.tsx @@ -14,7 +14,8 @@ function NewExplorerCTA(): JSX.Element | null { () => location.pathname === ROUTES.LOGS_EXPLORER || location.pathname === ROUTES.TRACE || - location.pathname === ROUTES.OLD_LOGS_EXPLORER, + location.pathname === ROUTES.OLD_LOGS_EXPLORER || + location.pathname === ROUTES.TRACES_EXPLORER, [location.pathname], ); @@ -25,6 +26,8 @@ function NewExplorerCTA(): JSX.Element | null { history.push(ROUTES.TRACES_EXPLORER); } else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) { history.push(ROUTES.LOGS_EXPLORER); + } else if (location.pathname === ROUTES.TRACES_EXPLORER) { + history.push(ROUTES.TRACE); } }, [location.pathname]); @@ -47,6 +50,10 @@ function NewExplorerCTA(): JSX.Element | null { return null; } + if (location.pathname === ROUTES.TRACES_EXPLORER) { + return button; + } + if (location.pathname === ROUTES.LOGS_EXPLORER) { return button; } diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx index 85c7ea2c64..eaaccb607d 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx @@ -180,7 +180,7 @@ function QueryBuilderSearch({ const { tagKey, tagOperator, tagValue } = getTagToken(tag); const filterAttribute = [...initialSourceKeys, ...sourceKeys].find( - (key) => key.key === getRemovePrefixFromKey(tagKey), + (key) => key?.key === getRemovePrefixFromKey(tagKey), ); const computedTagValue = diff --git a/frontend/src/container/SideNav/menuItems.tsx b/frontend/src/container/SideNav/menuItems.tsx index 38529a7f3b..6c9a87db02 100644 --- a/frontend/src/container/SideNav/menuItems.tsx +++ b/frontend/src/container/SideNav/menuItems.tsx @@ -72,7 +72,7 @@ const menuItems: SidebarItem[] = [ icon: , }, { - key: ROUTES.TRACE, + key: ROUTES.TRACES_EXPLORER, label: 'Traces', icon: , }, diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index 7bb7545bfe..b5c8868184 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -19,6 +19,7 @@ import { defaultLiveQueryDataConfig, } from 'container/LiveLogs/constants'; import { QueryHistoryState } from 'container/LiveLogs/types'; +import NewExplorerCTA from 'container/NewExplorerCTA'; import dayjs, { Dayjs } from 'dayjs'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval'; @@ -63,6 +64,7 @@ function DateTimeSelection({ location, updateTimeInterval, globalTimeLoading, + showOldExplorerCTA = false, }: Props): JSX.Element { const [formSelector] = Form.useForm(); @@ -561,6 +563,11 @@ function DateTimeSelection({ return (
+ {showOldExplorerCTA && ( +
+ +
+ )} {!hasSelectedTimeError && !refreshButtonHidden && ( ([]); const [sourceKeys, setSourceKeys] = useState([]); const [results, setResults] = useState([]); + const [isAggregateFetching, setAggregateFetching] = useState(false); const memoizedSearchParams = useMemo( () => [ @@ -106,22 +107,29 @@ export const useFetchKeysAndValues = ( if (!tagKey || !tagOperator) { return; } + setAggregateFetching(true); - const { payload } = 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() ?? '' // last element of tagvalue will be always user search value - : tagValue?.toString() ?? '', - }); + try { + const { payload } = 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() ?? '' // last element of tagvalue will be always user search value + : tagValue?.toString() ?? '', + }); - if (payload) { - const values = Object.values(payload).find((el) => !!el) || []; - setResults(values); + if (payload) { + const values = Object.values(payload).find((el) => !!el) || []; + setResults(values); + } + } catch (e) { + console.error(e); + } finally { + setAggregateFetching(false); } }; @@ -157,7 +165,7 @@ export const useFetchKeysAndValues = ( return { keys, results, - isFetching, + isFetching: isFetching || isAggregateFetching, sourceKeys, handleRemoveSourceKey, }; diff --git a/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx b/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx new file mode 100644 index 0000000000..af06027139 --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx @@ -0,0 +1,143 @@ +import { Input, Slider } from 'antd'; +import { SliderRangeProps } from 'antd/es/slider'; +import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; +import useDebouncedFn from 'hooks/useDebouncedFunction'; +import { + ChangeEventHandler, + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import { addFilter, FilterType, traceFilterKeys } from './filterUtils'; + +interface DurationProps { + selectedFilters: FilterType | undefined; + setSelectedFilters: Dispatch>; +} + +export function DurationSection(props: DurationProps): JSX.Element { + const { setSelectedFilters, selectedFilters } = props; + + const getDuration = useMemo(() => { + if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) { + return { + minDuration: selectedFilters?.durationNanoMin?.values || '', + maxDuration: selectedFilters?.durationNanoMax?.values || '', + }; + } + + if (selectedFilters?.durationNano) { + return { + minDuration: getMs(selectedFilters?.durationNano?.values?.[0] || ''), + maxDuration: getMs(selectedFilters?.durationNano?.values?.[1] || ''), + }; + } + + return { + maxDuration: '', + minDuration: '', + }; + }, [selectedFilters]); + + const [preMax, setPreMax] = useState(''); + const [preMin, setPreMin] = useState(''); + + useEffect(() => { + setPreMax(getDuration.maxDuration as string); + setPreMin(getDuration.minDuration as string); + }, [getDuration]); + + const updateDurationFilter = (min: string, max: string): void => { + const durationMin = 'durationNanoMin'; + const durationMax = 'durationNanoMax'; + + addFilter(durationMin, min, setSelectedFilters, traceFilterKeys.durationNano); + addFilter(durationMax, max, setSelectedFilters, traceFilterKeys.durationNano); + }; + + const onRangeSliderHandler = (number: [string, string]): void => { + const [min, max] = number; + + setPreMin(min); + setPreMax(max); + }; + + const debouncedFunction = useDebouncedFn( + (min, max) => { + updateDurationFilter(min as string, max as string); + }, + 1500, + undefined, + ); + + const onChangeMaxHandler: ChangeEventHandler = (event) => { + const { value } = event.target; + const min = preMin; + const max = value; + + onRangeSliderHandler([min, max]); + debouncedFunction(min, max); + }; + + const onChangeMinHandler: ChangeEventHandler = (event) => { + const { value } = event.target; + const min = value; + const max = preMax; + + onRangeSliderHandler([min, max]); + debouncedFunction(min, max); + }; + + const onRangeHandler: SliderRangeProps['onChange'] = ([min, max]) => { + updateDurationFilter(min.toString(), max.toString()); + }; + + const TipComponent = useCallback((value: undefined | number) => { + if (value === undefined) { + return
; + } + return
{`${value?.toString()}ms`}
; + }, []); + + return ( +
+
+ + +
+
+ { + onRangeSliderHandler([String(min), String(max)]); + }} + onAfterChange={onRangeHandler} + value={[Number(preMin), Number(preMax)]} + /> +
+
+ ); +} diff --git a/frontend/src/pages/TracesExplorer/Filter/Filter.styles.scss b/frontend/src/pages/TracesExplorer/Filter/Filter.styles.scss new file mode 100644 index 0000000000..f45b9b5e37 --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/Filter.styles.scss @@ -0,0 +1,190 @@ +.collapseContainer { + background-color: var(--bg-ink-500); + .ant-collapse-header { + padding: 12px !important; + align-items: center !important; + } + + .ant-collapse-header-text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + text-transform: capitalize; + } + + .duration-inputs { + display: grid; + gap: 12px; + + .min-max-input { + .ant-input-group-addon { + color: var(--bg-vanilla-400); + font-family: 'Space Mono', monospace; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.48px; + padding: 0 6px; + } + + .ant-input { + padding: 4px 6px; + + color: var(--bg-vanilla-400); + font-family: 'Space Mono', monospace; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.48px; + } + } + } +} + +.divider { + background-color: var(--bg-slate-400); + margin: 0; + border-color: var(--bg-slate-400); +} + +.filter-header { + padding: 16px 8px 16px 12px; + .filter-title { + display: flex; + gap: 6px; + + .ant-typography { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + } + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + padding: 8px; + } + + .arrow-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + padding-top: 8px; + + .anticon-vertical-align-top { + svg { + width: 16px; + height: 16px; + } + } + } +} + +.section-body-header { + display: flex; + + > button { + position: absolute; + right: 4px; + padding-top: 13px; + } + .ant-collapse { + width: 100%; + } +} + +.section-card { + background-color: var(--bg-ink-500); + .ant-card-body { + padding: 0; + display: flex; + flex-direction: column; + + max-height: 500px; + overflow-x: hidden; + overflow-y: auto; + } + width: 240px; + + .submenu-checkbox { + padding-bottom: 8px; + } + + .search-input { + margin-bottom: 12px; + } + + .checkbox-label { + display: flex; + align-items: center; + gap: 4px; + + .hasError-Error { + width: 2px; + height: 11px; + border-radius: 2px; + background: var(--bg-cherry-500); + } + + .hasError-Ok { + width: 2px; + height: 11px; + border-radius: 2px; + background: var(--bg-forest-500); + } + } +} +.lightMode { + .collapseContainer { + background-color: var(--bg-vanilla-100); + + .ant-collapse-header-text { + color: var(--bg-slate-100); + } + + .duration-inputs { + .min-max-input { + .ant-input-group-addon { + color: var(--bg-slate-100); + } + + .ant-input { + color: var(--bg-slate-100); + } + } + } + } + + .divider { + background-color: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-200); + } + + .filter-header { + .filter-title { + .ant-typography { + color: var(--bg-slate-100); + } + } + + .arrow-icon { + background-color: var(--bg-vanilla-100); + } + } + + .section-card { + background-color: var(--bg-vanilla-100); + } +} diff --git a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx new file mode 100644 index 0000000000..7eadc3d5cd --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx @@ -0,0 +1,247 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import './Filter.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Button, Flex, Tooltip, Typography } from 'antd'; +import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; +import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { isArray, isEqual } from 'lodash-es'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { v4 as uuid } from 'uuid'; + +import { + AllTraceFilterKeys, + AllTraceFilterKeyValue, + AllTraceFilterOptions, + FilterType, + HandleRunProps, + unionTagFilterItems, +} from './filterUtils'; +import { Section } from './Section'; + +interface FilterProps { + setOpen: Dispatch>; +} + +export function Filter(props: FilterProps): JSX.Element { + const { setOpen } = props; + const [selectedFilters, setSelectedFilters] = useState< + Record< + AllTraceFilterKeys, + { values: string[] | string; keys: BaseAutocompleteData } + > + >(); + + const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); + const compositeQuery = useGetCompositeQueryParam(); + + // eslint-disable-next-line sonarjs/cognitive-complexity + const syncSelectedFilters = useMemo((): FilterType => { + const filters = compositeQuery?.builder.queryData?.[0].filters; + if (!filters) { + return {} as FilterType; + } + + return filters.items + .filter((item) => + Object.keys(AllTraceFilterKeyValue).includes(item.key?.key as string), + ) + .filter( + (item) => + (item.op === 'in' && item.key?.key !== 'durationNano') || + (item.key?.key === 'durationNano' && ['>=', '<='].includes(item.op)), + ) + .reduce((acc, item) => { + const keys = item.key as BaseAutocompleteData; + const attributeName = item.key?.key || ''; + const values = item.value as string[]; + + if ((attributeName as AllTraceFilterKeys) === 'durationNano') { + if (item.op === '>=') { + acc.durationNanoMin = { + values: getMs(String(values)), + keys, + }; + } else { + acc.durationNanoMax = { + values: getMs(String(values)), + keys, + }; + } + return acc; + } + + if (attributeName) { + if (acc[attributeName as AllTraceFilterKeys]) { + const existingValue = acc[attributeName as AllTraceFilterKeys]; + acc[attributeName as AllTraceFilterKeys] = { + values: [...existingValue.values, ...values], + keys, + }; + } else { + acc[attributeName as AllTraceFilterKeys] = { values, keys }; + } + } + + return acc; + }, {} as FilterType); + }, [compositeQuery]); + + useEffect(() => { + if (!isEqual(syncSelectedFilters, selectedFilters)) { + setSelectedFilters(syncSelectedFilters); + } + }, [syncSelectedFilters]); + + // eslint-disable-next-line sonarjs/cognitive-complexity + const preparePostData = (): TagFilterItem[] => { + if (!selectedFilters) { + return []; + } + + const items = Object.keys(selectedFilters)?.flatMap((attribute) => { + const { keys, values } = selectedFilters[attribute as AllTraceFilterKeys]; + if ( + ['durationNanoMax', 'durationNanoMin', 'durationNano'].includes( + attribute as AllTraceFilterKeys, + ) + ) { + if (!values || !values.length) { + return []; + } + let minValue = ''; + let maxValue = ''; + + const durationItems: TagFilterItem[] = []; + + if (isArray(values)) { + minValue = values?.[0]; + maxValue = values?.[1]; + + const minItems: TagFilterItem = { + id: uuid().slice(0, 8), + op: '>=', + key: keys, + value: Number(minValue) * 1000000, + }; + + const maxItems: TagFilterItem = { + id: uuid().slice(0, 8), + op: '<=', + key: keys, + value: Number(maxValue) * 1000000, + }; + return maxValue ? [minItems, maxItems] : [minItems]; + } + if (attribute === 'durationNanoMin') { + durationItems.push({ + id: uuid().slice(0, 8), + op: '>=', + key: keys, + value: Number(values) * 1000000, + }); + } else { + durationItems.push({ + id: uuid().slice(0, 8), + op: '<=', + key: keys, + value: Number(values) * 1000000, + }); + } + + return durationItems; + } + return { + id: uuid().slice(0, 8), + key: keys, + op: 'in', + value: values, + }; + }); + + return items as TagFilterItem[]; + }; + + const handleRun = useCallback( + (props?: HandleRunProps): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item) => ({ + ...item, + filters: { + ...item.filters, + items: props?.resetAll + ? [] + : (unionTagFilterItems(item.filters.items, preparePostData()) + .map((item) => + item.key?.key === props?.clearByType ? undefined : item, + ) + .filter((i) => i) as TagFilterItem[]), + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }, + [currentQuery, redirectWithQueryBuilderData, selectedFilters], + ); + + useEffect(() => { + handleRun(); + }, [selectedFilters]); + + return ( + <> + + +
+ + Filters +
+ + + +
+ + + +
+ <> + {AllTraceFilterOptions.filter( + (i) => i !== 'durationNanoMax' && i !== 'durationNanoMin', + ).map((panelName) => ( +
+ ))} + + + ); +} diff --git a/frontend/src/pages/TracesExplorer/Filter/Section.tsx b/frontend/src/pages/TracesExplorer/Filter/Section.tsx new file mode 100644 index 0000000000..c95f8a77e0 --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/Section.tsx @@ -0,0 +1,81 @@ +import './Filter.styles.scss'; + +import { Button, Collapse, Divider } from 'antd'; +import { Dispatch, MouseEvent, SetStateAction } from 'react'; + +import { DurationSection } from './DurationSection'; +import { + AllTraceFilterKeys, + AllTraceFilterKeyValue, + FilterType, + HandleRunProps, +} from './filterUtils'; +import { SectionBody } from './SectionContent'; + +interface SectionProps { + panelName: AllTraceFilterKeys; + selectedFilters: FilterType | undefined; + setSelectedFilters: Dispatch>; + handleRun: (props?: HandleRunProps) => void; +} +export function Section(props: SectionProps): JSX.Element { + const { panelName, setSelectedFilters, selectedFilters, handleRun } = props; + + const onClearHandler = (e: MouseEvent): void => { + e.stopPropagation(); + e.preventDefault(); + + if ( + selectedFilters?.[panelName] || + selectedFilters?.durationNanoMin || + selectedFilters?.durationNanoMax + ) { + handleRun({ clearByType: panelName }); + } + }; + + return ( +
+ +
+ + ), + label: AllTraceFilterKeyValue[panelName], + } + : { + key: panelName, + children: ( + + ), + label: AllTraceFilterKeyValue[panelName], + }, + ]} + /> + +
+
+ ); +} diff --git a/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx b/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx new file mode 100644 index 0000000000..ed9cf08542 --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx @@ -0,0 +1,161 @@ +import './Filter.styles.scss'; + +import { Button, Card, Checkbox, Input, Tooltip } from 'antd'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; +import { ParaGraph } from 'container/Trace/Filters/Panel/PanelBody/Common/styles'; +import useDebounce from 'hooks/useDebounce'; +import { defaultTo, isEmpty } from 'lodash-es'; +import { + ChangeEvent, + Dispatch, + SetStateAction, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + addFilter, + AllTraceFilterKeys, + FilterType, + HandleRunProps, + removeFilter, + statusFilterOption, + useGetAggregateValues, +} from './filterUtils'; + +interface SectionBodyProps { + type: AllTraceFilterKeys; + selectedFilters: FilterType | undefined; + setSelectedFilters: Dispatch>; + handleRun: (props?: HandleRunProps) => void; +} + +export function SectionBody(props: SectionBodyProps): JSX.Element { + const { type, setSelectedFilters, selectedFilters, handleRun } = props; + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + const [searchFilter, setSearchFilter] = useState(''); + const [checkedItems, setCheckedItems] = useState( + defaultTo(selectedFilters?.[type]?.values as string[], []), + ); + + const [results, setResults] = useState([]); + const [isFetching, setFetching] = useState(false); + + useEffect( + () => + setCheckedItems(defaultTo(selectedFilters?.[type]?.values as string[], [])), + [selectedFilters, type], + ); + const debouncedSearchText = useDebounce(searchFilter, DEBOUNCE_DELAY); + + const { isFetching: fetching, keys, results: res } = useGetAggregateValues({ + value: type, + searchText: debouncedSearchText, + }); + + useEffect(() => { + setResults(res); + setFetching(fetching); + }, [fetching, res]); + + const handleShowMore = (): void => { + setVisibleItemsCount((prevCount) => prevCount + 10); + }; + + const listData = useMemo( + () => + (type === 'hasError' ? statusFilterOption : results) + .filter((i) => i.length) + .filter((filter) => { + if (searchFilter.length === 0) { + return true; + } + return filter + .toLocaleLowerCase() + .includes(searchFilter.toLocaleLowerCase()); + }) + .slice(0, visibleItemsCount), + [results, searchFilter, type, visibleItemsCount], + ); + + const onCheckHandler = (event: CheckboxChangeEvent, value: string): void => { + const { checked } = event.target; + let newValue = value; + if (type === 'hasError') { + newValue = String(value === 'Error'); + } + if (checked) { + addFilter(type, newValue, setSelectedFilters, keys); + setCheckedItems((prev) => { + if (!prev.includes(newValue)) { + prev.push(newValue); + } + return prev; + }); + } else if (checkedItems.length === 1) { + handleRun({ clearByType: type }); + setCheckedItems([]); + } else { + removeFilter(type, newValue, setSelectedFilters, keys); + setCheckedItems((prev) => prev.filter((item) => item !== newValue)); + } + }; + + const checkboxMatcher = (item: string): boolean => + checkedItems?.includes(type === 'hasError' ? String(item === 'Error') : item); + + const labelClassname = (item: string): string => `${type}-${item}`; + + const handleSearch = (e: ChangeEvent): void => { + const inputValue = e.target.value; + setSearchFilter(inputValue); + }; + + return ( + + <> + + {listData.length === 0 && isEmpty(searchFilter) ? ( +
No data found
+ ) : ( + <> + {listData.map((item) => ( + onCheckHandler(e, item)} + checked={checkboxMatcher(item)} + > +
+
+ {item}
} placement="rightTop"> + + {item} + + +
+
+ ))} + {visibleItemsCount < results.length && ( + + )} + + )} + +
+ ); +} diff --git a/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts b/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts new file mode 100644 index 0000000000..ede7615e47 --- /dev/null +++ b/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts @@ -0,0 +1,347 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { getAttributesValues } from 'api/queryBuilder/getAttributesValues'; +import { isArray } from 'lodash-es'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; + +export const AllTraceFilterKeyValue = { + durationNanoMin: 'Duration', + durationNano: 'Duration', + durationNanoMax: 'Duration', + hasError: 'Status', + serviceName: 'Service Name', + name: 'Operation / Name', + rpcMethod: 'RPC Method', + responseStatusCode: 'Status Code', + httpHost: 'HTTP Host', + httpMethod: 'HTTP Method', + httpRoute: 'HTTP Route', + httpUrl: 'HTTP URL', + traceID: 'Trace ID', +}; + +export type AllTraceFilterKeys = keyof typeof AllTraceFilterKeyValue; + +// Type for the values of AllTraceFilterKeyValue +export type AllTraceFilterValues = typeof AllTraceFilterKeyValue[AllTraceFilterKeys]; + +export const AllTraceFilterOptions = Object.keys( + AllTraceFilterKeyValue, +) as (keyof typeof AllTraceFilterKeyValue)[]; + +export const statusFilterOption = ['Error', 'Ok']; + +export type FilterType = Record< + AllTraceFilterKeys, + { values: string[] | string; keys: BaseAutocompleteData } +>; + +export const addFilter = ( + filterType: AllTraceFilterKeys, + value: string, + setSelectedFilters: Dispatch< + SetStateAction< + | Record< + AllTraceFilterKeys, + { values: string[] | string; keys: BaseAutocompleteData } + > + | undefined + > + >, + keys?: BaseAutocompleteData, +): void => { + setSelectedFilters((prevFilters) => { + const isDuration = [ + 'durationNanoMax', + 'durationNanoMin', + 'durationNano', + ].includes(filterType); + + // If previous filters are undefined, initialize them + if (!prevFilters) { + return ({ + [filterType]: { values: isDuration ? value : [value], keys }, + } as unknown) as FilterType; + } + // If the filter type doesn't exist, initialize it + if (!prevFilters[filterType]?.values.length) { + return { + ...prevFilters, + [filterType]: { values: isDuration ? value : [value], keys }, + }; + } + // If the value already exists, don't add it again + if (prevFilters[filterType].values.includes(value)) { + return prevFilters; + } + // Otherwise, add the value to the existing array + return { + ...prevFilters, + [filterType]: { + values: isDuration ? value : [...prevFilters[filterType].values, value], + keys, + }, + }; + }); +}; + +// Function to remove a filter +export const removeFilter = ( + filterType: AllTraceFilterKeys, + value: string, + setSelectedFilters: Dispatch< + SetStateAction< + | Record< + AllTraceFilterKeys, + { values: string[] | string; keys: BaseAutocompleteData } + > + | undefined + > + >, + keys?: BaseAutocompleteData, +): void => { + setSelectedFilters((prevFilters) => { + if (!prevFilters || !prevFilters[filterType]?.values.length) { + return prevFilters; + } + + const prevValue = prevFilters[filterType]?.values; + const updatedValues = !isArray(prevValue) + ? prevValue + : prevValue?.filter((item: any) => item !== value); + + if (updatedValues.length === 0) { + const { [filterType]: item, ...remainingFilters } = prevFilters; + return Object.keys(remainingFilters).length > 0 + ? (remainingFilters as FilterType) + : undefined; + } + + return { + ...prevFilters, + [filterType]: { values: updatedValues, keys }, + }; + }); +}; + +export const removeAllFilters = ( + filterType: AllTraceFilterKeys, + setSelectedFilters: Dispatch< + SetStateAction< + | Record< + AllTraceFilterKeys, + { values: string[]; keys: BaseAutocompleteData } + > + | undefined + > + >, +): void => { + setSelectedFilters((prevFilters) => { + if (!prevFilters || !prevFilters[filterType]) { + return prevFilters; + } + + const { [filterType]: item, ...remainingFilters } = prevFilters; + + return Object.keys(remainingFilters).length > 0 + ? (remainingFilters as Record< + AllTraceFilterKeys, + { values: string[]; keys: BaseAutocompleteData } + >) + : undefined; + }); +}; + +export const traceFilterKeys: Record< + AllTraceFilterKeys, + BaseAutocompleteData +> = { + durationNano: { + key: 'durationNano', + dataType: DataTypes.Float64, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNano--float64--tag--true', + }, + hasError: { + key: 'hasError', + dataType: DataTypes.bool, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'hasError--bool--tag--true', + }, + serviceName: { + key: 'serviceName', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'serviceName--string--tag--true', + }, + name: { + key: 'name', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + }, + rpcMethod: { + key: 'rpcMethod', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'rpcMethod--string--tag--true', + }, + responseStatusCode: { + key: 'responseStatusCode', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'responseStatusCode--string--tag--true', + }, + httpHost: { + key: 'httpHost', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpHost--string--tag--true', + }, + httpMethod: { + key: 'httpMethod', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpMethod--string--tag--true', + }, + httpRoute: { + key: 'httpRoute', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpRoute--string--tag--true', + }, + httpUrl: { + key: 'httpUrl', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpUrl--string--tag--true', + }, + traceID: { + key: 'traceID', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'traceID--string--tag--true', + }, + durationNanoMin: { + key: 'durationNanoMin', + dataType: DataTypes.Float64, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNanoMin--float64--tag--true', + }, + durationNanoMax: { + key: 'durationNanoMax', + dataType: DataTypes.Float64, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNanoMax--float64--tag--true', + }, +}; + +interface AggregateValuesProps { + value: AllTraceFilterKeys; + searchText?: string; +} + +type IuseGetAggregateValue = { + keys: BaseAutocompleteData; + results: string[]; + isFetching: boolean; +}; + +export function useGetAggregateValues( + props: AggregateValuesProps, +): IuseGetAggregateValue { + const { value, searchText } = props; + const keyData = traceFilterKeys[value]; + const [isFetching, setFetching] = useState(true); + const [results, setResults] = useState([]); + + const getValues = async (): Promise => { + try { + setResults([]); + const { payload } = await getAttributesValues({ + aggregateOperator: 'noop', + dataSource: DataSource.TRACES, + aggregateAttribute: '', + attributeKey: value, + filterAttributeKeyDataType: keyData ? keyData.dataType : DataTypes.EMPTY, + tagType: keyData.type ?? '', + searchText: searchText ?? '', + }); + + if (payload) { + const values = Object.values(payload).find((el) => !!el) || []; + setResults(values); + } + } catch (e) { + console.error(e); + } finally { + setFetching(false); + } + }; + + useEffect(() => { + getValues(); + }, [searchText]); + + if (!value) { + setFetching(false); + return { keys: keyData, results, isFetching }; + } + + return { keys: keyData, results, isFetching }; +} + +export function unionTagFilterItems( + items1: TagFilterItem[], + items2: TagFilterItem[], +): TagFilterItem[] { + const unionMap = new Map(); + + items1.forEach((item) => { + const keyOp = `${item?.key?.key}_${item.op}`; + unionMap.set(keyOp, item); + }); + + items2.forEach((item) => { + const keyOp = `${item?.key?.key}_${item.op}`; + unionMap.set(keyOp, item); + }); + + return Array.from(unionMap.values()); +} + +export interface HandleRunProps { + resetAll?: boolean; + clearByType?: AllTraceFilterKeys; +} diff --git a/frontend/src/pages/TracesExplorer/TracesExplorer.styles.scss b/frontend/src/pages/TracesExplorer/TracesExplorer.styles.scss index 5ab2bd07b2..e26e38dbaa 100644 --- a/frontend/src/pages/TracesExplorer/TracesExplorer.styles.scss +++ b/frontend/src/pages/TracesExplorer/TracesExplorer.styles.scss @@ -1,9 +1,27 @@ -.trace-explorer-run-query { +.trace-explorer-header { display: flex; - flex-direction: row-reverse; - align-items: center; - margin: 8px 16px; - gap: 8px; + justify-content: space-between; + + .trace-explorer-run-query { + display: flex; + flex-direction: row-reverse; + align-items: center; + margin: 8px 16px; + gap: 8px; + } + + .filter-outlined-btn { + border-radius: 0px 2px 2px 0px; + border-top: 1px solid var(--bg-slate-400); + border-right: 1px solid var(--bg-slate-400); + border-bottom: 1px solid var(--bg-slate-400); + background: var(--bg-ink-400); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + } +} + +.trace-explorer-header.single-child { + justify-content: flex-end; } .traces-explorer-views { @@ -11,3 +29,54 @@ padding: 0 8px; } } + +.trace-explorer-page { + display: flex; + + .filter { + width: 260px; + height: 100vh; + + border-right: 0px; + border: 1px solid var(--bg-slate-400); + background-color: var(--bg-ink-500); + + > .ant-card-body { + padding: 0; + width: 258px; + } + } + + .trace-explorer { + width: 100%; + border-left: 1px solid var(--bg-slate-400); + background: var(--bg-ink-500); + + > .ant-card-body { + padding: 8px 8px; + } + + border-color: var(--bg-slate-400); + } +} + +.lightMode { + .trace-explorer-page { + .filter { + border: 1px solid var(--bg-vanilla-200); + background-color: var(--bg-vanilla-200); + } + + .trace-explorer { + width: 100%; + border-left: 1px solid var(--bg-vanilla-200); + background: var(--bg-vanilla-200); + + > .ant-card-body { + padding: 8px 8px; + } + + border-color: var(--bg-vanilla-200); + } + } +} diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index 99527fba98..6c4057332f 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -1,6 +1,7 @@ import './TracesExplorer.styles.scss'; -import { Tabs } from 'antd'; +import { FilterOutlined } from '@ant-design/icons'; +import { Button, Card, Tabs, Tooltip } from 'antd'; import axios from 'axios'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes'; @@ -19,13 +20,14 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Dashboard } from 'types/api/dashboard/getAll'; import { DataSource } from 'types/common/queryBuilder'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; import { v4 } from 'uuid'; +import { Filter } from './Filter/Filter'; import { ActionsWrapper, Container } from './styles'; import { getTabsItems } from './utils'; @@ -180,42 +182,60 @@ function TracesExplorer(): JSX.Element { handleExplorerTabChange, currentPanelType, ]); + const [isOpen, setOpen] = useState(true); return ( - <> -
- - -
- - - +
+ + +
+ {!isOpen && ( + + + + )} +
+ + +
+
+ + + - - - + + + + + - - - + - - - +
+
); }