From 4a9847abdd4cc02d0bac89c1215200203a2133d9 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Fri, 6 Sep 2024 10:24:47 +0530 Subject: [PATCH] feat: implement quick filters for the new logs explorer page (#5799) * feat: logs quick filter * feat: added open button in the closed state * fix: build issues * chore: minor css * feat: handle changes for last used query,states and reset * feat: refactor some code * feat: handle on change functionality * fix: handle only and all * chore: handle empty edge cases * feat: added necessary tooltips * feat: use tag instead of tooltip icon * feat: handle light mode designs * feat: added correct facets * feat: added resize observer for the graph resize * chore: added local storage state for the toggle * chore: make refresh text configurable * feat: added environment and fix build * feat: handle the cases for = and != operators * feat: design changes and zoom out * feat: minor css issue * fix: light mode designs * fix: handle the case for state initialization * fix: onDelete query the last used index should be set to 0 --- .../Checkbox/Checkbox.styles.scss | 145 +++++ .../FilterRenderers/Checkbox/Checkbox.tsx | 503 ++++++++++++++++++ .../FilterRenderers/Slider/Slider.styles.scss | 0 .../FilterRenderers/Slider/Slider.tsx | 14 + .../QuickFilters/QuickFilters.styles.scss | 93 ++++ .../components/QuickFilters/QuickFilters.tsx | 124 +++++ frontend/src/constants/localStorage.ts | 1 + .../QueryBuilder/QueryBuilder.styles.scss | 6 + .../container/QueryBuilder/QueryBuilder.tsx | 17 +- .../QBEntityOptions.styles.scss | 6 + .../QBEntityOptions/QBEntityOptions.tsx | 13 + .../QueryBuilder/components/Query/Query.tsx | 1 + .../ToolbarActions/LeftToolbarActions.tsx | 12 + .../ToolbarActions/ToolbarActions.styles.scss | 11 + .../tests/ToolbarActions.test.tsx | 4 + .../TimeSeriesView/TimeSeriesView.tsx | 14 +- frontend/src/container/Toolbar/Toolbar.tsx | 13 +- .../TopNav/DateTimeSelectionV2/index.tsx | 5 +- .../queryBuilder/useQueryBuilderOperations.ts | 9 +- .../LogsExplorer/LogsExplorer.styles.scss | 44 +- .../__tests__/LogsExplorer.test.tsx | 2 + frontend/src/pages/LogsExplorer/index.tsx | 117 ++-- frontend/src/pages/LogsExplorer/utils.ts | 19 - frontend/src/pages/LogsExplorer/utils.tsx | 113 ++++ .../__test__/TracesExplorer.test.tsx | 8 + frontend/src/providers/QueryBuilder.tsx | 8 + frontend/src/types/common/queryBuilder.ts | 2 + 27 files changed, 1220 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx create mode 100644 frontend/src/components/QuickFilters/QuickFilters.styles.scss create mode 100644 frontend/src/components/QuickFilters/QuickFilters.tsx delete mode 100644 frontend/src/pages/LogsExplorer/utils.ts create mode 100644 frontend/src/pages/LogsExplorer/utils.tsx diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss new file mode 100644 index 0000000000..c46d9975f4 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -0,0 +1,145 @@ +.checkbox-filter { + display: flex; + flex-direction: column; + padding: 12px; + gap: 12px; + border-bottom: 1px solid var(--bg-slate-400); + .filter-header-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + + .left-action { + display: flex; + align-items: center; + gap: 6px; + + .title { + 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; + } + } + + .right-action { + display: flex; + align-items: center; + + .clear-all { + font-size: 12px; + color: var(--bg-robin-500); + cursor: pointer; + } + } + } + + .values { + display: flex; + flex-direction: column; + gap: 8px; + + .value { + display: flex; + align-items: center; + gap: 8px; + + .checkbox-value-section { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + + &.filter-disabled { + cursor: not-allowed; + + .value-string { + color: var(--bg-slate-200); + } + + .only-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + + .toggle-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + } + + .value-string { + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .toggle-btn:hover { + background-color: unset; + } + + .only-btn:hover { + background-color: unset; + } + } + + .checkbox-value-section:hover { + .toggle-btn { + display: none; + } + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .value:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .no-data { + align-self: center; + } + + .show-more { + display: flex; + align-items: center; + justify-content: center; + + .show-more-text { + color: var(--bg-robin-500); + cursor: pointer; + } + } +} + +.lightMode { + .checkbox-filter { + border-bottom: 1px solid var(--bg-vanilla-300); + .filter-header-checkbox { + .left-action { + .title { + color: var(--bg-ink-400); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..fc9a71a7b1 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -0,0 +1,503 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './Checkbox.styles.scss'; + +import { Button, Checkbox, Input, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; +import { OPERATORS } from 'constants/queryBuilder'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +const SELECTED_OPERATORS = [OPERATORS['='], 'in']; +const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin']; + +function setDefaultValues( + values: string[], + trueOrFalse: boolean, +): Record { + const defaultState: Record = {}; + values.forEach((val) => { + defaultState[val] = trueOrFalse; + }); + return defaultState; +} +interface ICheckboxProps { + filter: IQuickFiltersConfig; +} + +export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { + const { filter } = props; + const [searchText, setSearchText] = useState(''); + const [isOpen, setIsOpen] = useState(filter.defaultOpen); + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + + const { + lastUsedQuery, + currentQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + const { data, isLoading } = useGetAggregateValues( + { + aggregateOperator: 'noop', + dataSource: DataSource.LOGS, + aggregateAttribute: '', + attributeKey: filter.attributeKey.key, + filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY, + tagType: filter.attributeKey.type || '', + searchText: searchText ?? '', + }, + { + enabled: isOpen, + keepPreviousData: true, + }, + ); + + const attributeValues: string[] = useMemo( + () => + ((Object.values(data?.payload || {}).find((el) => !!el) || + []) as string[]).filter((val) => !isEmpty(val)), + [data?.payload], + ); + const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount); + + // derive the state of each filter key here in the renderer itself and keep it in sync with staged query + // also we need to keep a note of last focussed query. + // eslint-disable-next-line sonarjs/cognitive-complexity + const currentFilterState = useMemo(() => { + let filterState: Record = setDefaultValues( + attributeValues, + false, + ); + const filterSync = currentQuery?.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey)); + + if (filterSync) { + if (SELECTED_OPERATORS.includes(filterSync.op)) { + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = true; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = true; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = true; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = true; + } + } else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) { + filterState = setDefaultValues(attributeValues, true); + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = false; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = false; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = false; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = false; + } + } + } else { + filterState = setDefaultValues(attributeValues, true); + } + return filterState; + }, [ + attributeValues, + currentQuery?.builder.queryData, + filter.attributeKey, + lastUsedQuery, + ]); + + // disable the filter when there are multiple entries of the same attribute key present in the filter bar + const isFilterDisabled = useMemo( + () => + (currentQuery?.builder?.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey)) + ?.length || 0) > 1, + + [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], + ); + + // variable to check if the current filter has multiple values to its name in the key op value section + const isMultipleValuesTrueForTheKey = + Object.values(currentFilterState).filter((val) => val).length > 1; + + const handleClearFilterAttribute = (): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: + idx === lastUsedQuery + ? item.filters.items.filter( + (fil) => !isEqual(fil.key, filter.attributeKey), + ) + : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)); + + const onChange = ( + value: string, + checked: boolean, + isOnlyOrAllClicked: boolean, + // eslint-disable-next-line sonarjs/cognitive-complexity + ): void => { + const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]); + + // if only or all are clicked we do not need to worry about anything just override whatever we have + // by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL. + if (isOnlyOrAllClicked && query?.filters?.items) { + const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute + ? currentFilterState[value] && !isMultipleValuesTrueForTheKey + ? 'All' + : 'Only' + : 'Only'; + query.filters.items = query.filters.items.filter( + (q) => !isEqual(q.key, filter.attributeKey), + ); + if (isOnlyOrAll === 'Only') { + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.IN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } else if (query?.filters?.items) { + if ( + query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)) + ) { + // if there is already a running filter for the current attribute key then + // we split the cases by which particular operator is present right now! + const currentFilter = query.filters?.items?.find((q) => + isEqual(q.key, filter.attributeKey), + ); + if (currentFilter) { + const runningOperator = currentFilter?.op; + switch (runningOperator) { + case 'in': + if (checked) { + // if it's an IN operator then if we are checking another value it get's added to the + // filter clause. example - key IN [value1, currentSelectedValue] + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // if the current state wasn't an array we make it one and add our value + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (!checked) { + // if we are removing some value when the running operator is IN we filter. + // example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + // if not an array remove the whole thing altogether! + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case 'nin': + // if the current running operator is NIN then when unchecking the value it gets + // added to the clause like key NIN [value1 , currentUnselectedValue] + if (!checked) { + // in case of array add the currentUnselectedValue to the list. + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // in case of not an array make it one! + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (checked) { + // opposite of above! + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case '=': + if (checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.IN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (!checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + case '!=': + if (!checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.NIN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + default: + break; + } + } + } else { + // case - when there is no filter for the current key that means all are selected right now. + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.NIN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } + const finalQuery = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + ...currentQuery.builder.queryData.map((q, idx) => { + if (idx === lastUsedQuery) { + return query; + } + return q; + }), + ], + }, + }; + + redirectWithQueryBuilderData(finalQuery); + }; + + return ( +
+
+
+ {isOpen ? ( + { + setIsOpen(false); + setVisibleItemsCount(10); + }} + /> + ) : ( + setIsOpen(true)} + cursor="pointer" + /> + )} + {filter.title} +
+
+ {isOpen && ( + + Clear All + + )} +
+
+ {isOpen && isLoading && !attributeValues.length && ( +
+ +
+ )} + {isOpen && !isLoading && ( + <> +
+ setSearchText(e.target.value)} + disabled={isFilterDisabled} + /> +
+ {attributeValues.length > 0 ? ( +
+ {currentAttributeKeys.map((value: string) => ( +
+ onChange(value, e.target.checked, false)} + checked={currentFilterState[value]} + disabled={isFilterDisabled} + rootClassName="check-box" + /> + +
{ + if (isFilterDisabled) { + return; + } + onChange(value, currentFilterState[value], true); + }} + > + {filter.customRendererForValue ? ( + filter.customRendererForValue(value) + ) : ( + + {value} + + )} + + +
+
+ ))} +
+ ) : ( +
+ No values found{' '} +
+ )} + {visibleItemsCount < attributeValues?.length && ( +
+ setVisibleItemsCount((prev) => prev + 10)} + > + Show More... + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx new file mode 100644 index 0000000000..f7cd9547e8 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx @@ -0,0 +1,14 @@ +import './Slider.styles.scss'; + +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; + +interface ISliderProps { + filter: IQuickFiltersConfig; +} + +// not needed for now build when required +export default function Slider(props: ISliderProps): JSX.Element { + const { filter } = props; + console.log(filter); + return
Slider
; +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.styles.scss b/frontend/src/components/QuickFilters/QuickFilters.styles.scss new file mode 100644 index 0000000000..d5c3460891 --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.styles.scss @@ -0,0 +1,93 @@ +.quick-filters { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid var(--bg-slate-400); + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10.5px; + border-bottom: 1px solid var(--bg-slate-400); + + .left-actions { + display: flex; + align-items: center; + gap: 6px; + + .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; + } + + .sync-tag { + display: flex; + padding: 5px 9px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 128.571% */ + text-transform: uppercase; + } + } + + .right-actions { + display: flex; + align-items: center; + gap: 12px; + + .divider-filter { + width: 1px; + height: 14px; + background: #161922; + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + } + } + } +} + +.lightMode { + .quick-filters { + background-color: var(--bg-vanilla-100); + border-right: 1px solid var(--bg-vanilla-300); + + .header { + border-bottom: 1px solid var(--bg-vanilla-300); + + .left-actions { + .text { + color: var(--bg-ink-400); + } + + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + .right-actions { + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx new file mode 100644 index 0000000000..a706e35aef --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -0,0 +1,124 @@ +import './QuickFilters.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import Checkbox from './FilterRenderers/Checkbox/Checkbox'; +import Slider from './FilterRenderers/Slider/Slider'; + +export enum FiltersType { + SLIDER = 'SLIDER', + CHECKBOX = 'CHECKBOX', +} + +export enum MinMax { + MIN = 'MIN', + MAX = 'MAX', +} + +export enum SpecficFilterOperations { + ALL = 'ALL', + ONLY = 'ONLY', +} + +export interface IQuickFiltersConfig { + type: FiltersType; + title: string; + attributeKey: BaseAutocompleteData; + customRendererForValue?: (value: string) => JSX.Element; + defaultOpen: boolean; +} + +interface IQuickFiltersProps { + config: IQuickFiltersConfig[]; + handleFilterVisibilityChange: () => void; +} + +export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { + const { config, handleFilterVisibilityChange } = props; + + const { + currentQuery, + lastUsedQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + // clear all the filters for the query which is in sync with filters + const handleReset = (): void => { + const updatedQuery = cloneDeep( + currentQuery?.builder.queryData?.[lastUsedQuery || 0], + ); + + if (!updatedQuery) { + return; + } + + if (updatedQuery?.filters?.items) { + updatedQuery.filters.items = []; + } + + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: idx === lastUsedQuery ? [] : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const lastQueryName = + currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; + return ( +
+
+
+ + Filters for + + {lastQueryName} + +
+
+ + + +
+ + + +
+
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ; + case FiltersType.SLIDER: + return ; + default: + return ; + } + })} +
+
+ ); +} diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index c7e8b81179..bab93a7ff1 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,4 +19,5 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss index dbb7a962ef..7cac6794c5 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss +++ b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss @@ -77,6 +77,12 @@ border: 1px solid rgba(242, 71, 105, 0.4); color: var(--bg-sakura-400); } + + &.sync-btn { + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + } } &.formula-btn { diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.tsx b/frontend/src/container/QueryBuilder/QueryBuilder.tsx index 844f9e3ab3..5726087e6d 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.tsx +++ b/frontend/src/container/QueryBuilder/QueryBuilder.tsx @@ -1,17 +1,20 @@ import './QueryBuilder.styles.scss'; import { Button, Col, Divider, Row, Tooltip, Typography } from 'antd'; +import cx from 'classnames'; import { MAX_FORMULAS, MAX_QUERIES, OPERATORS, PANEL_TYPES, } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; // ** Hooks import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { DatabaseZap, Sigma } from 'lucide-react'; // ** Constants import { memo, useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import { DataSource } from 'types/common/queryBuilder'; // ** Components @@ -35,6 +38,8 @@ export const QueryBuilder = memo(function QueryBuilder({ handleSetConfig, panelType, initialDataSource, + setLastUsedQuery, + lastUsedQuery, } = useQueryBuilder(); const containerRef = useRef(null); @@ -46,6 +51,10 @@ export const QueryBuilder = memo(function QueryBuilder({ [config], ); + const { pathname } = useLocation(); + + const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER; + useEffect(() => { if (currentDataSource !== initialDataSource || newPanelType !== panelType) { if (newPanelType === PANEL_TYPES.BAR) { @@ -212,6 +221,7 @@ export const QueryBuilder = memo(function QueryBuilder({ setLastUsedQuery(index)} className="query" id={`qb-query-${query.queryName}`} > @@ -265,10 +275,13 @@ export const QueryBuilder = memo(function QueryBuilder({ {!isListViewPanel && ( - {currentQuery.builder.queryData.map((query) => ( + {currentQuery.builder.queryData.map((query, index) => ( + + )}
)} - {!hasSelectedTimeError && !refreshButtonHidden && ( + {!hasSelectedTimeError && !refreshButtonHidden && showRefreshText && ( 1) { removeQueryBuilderEntityByIndex('queryData', index); } - }, [removeQueryBuilderEntityByIndex, index, currentQuery]); + setLastUsedQuery(0); + }, [ + currentQuery.builder.queryData.length, + setLastUsedQuery, + removeQueryBuilderEntityByIndex, + index, + ]); const handleChangeQueryData: HandleChangeQueryData = useCallback( (key, value) => { diff --git a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss index 95d53fe9a4..82d3f5bffc 100644 --- a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss +++ b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss @@ -1,11 +1,35 @@ -.log-explorer-query-container { - display: flex; - flex-direction: column; - flex: 1; +.logs-module-page { + display: flex; + height: 100%; + .log-quick-filter-left-section { + width: 0%; + flex-shrink: 0; + } - .logs-explorer-views { - flex: 1; - display: flex; - flex-direction: column; - } -} \ No newline at end of file + .log-module-right-section { + display: flex; + flex-direction: column; + width: 100%; + .log-explorer-query-container { + display: flex; + flex-direction: column; + flex: 1; + + .logs-explorer-views { + flex: 1; + display: flex; + flex-direction: column; + } + } + } + + &.filter-visible { + .log-quick-filter-left-section { + width: 260px; + } + + .log-module-right-section { + width: calc(100% - 260px); + } + } +} diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index fab08d51a8..4970d6cf17 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -189,6 +189,8 @@ describe('Logs Explorer Tests', () => { initialDataSource: null, panelType: PANEL_TYPES.TIME_SERIES, isEnabledQuery: false, + lastUsedQuery: 0, + setLastUsedQuery: noop, handleSetQueryData: noop, handleSetFormulaData: noop, handleSetQueryItemData: noop, diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index 8873d04e39..9e23b34c2c 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -1,25 +1,40 @@ import './LogsExplorer.styles.scss'; import * as Sentry from '@sentry/react'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import cx from 'classnames'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { LOCALSTORAGE } from 'constants/localStorage'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViews from 'container/LogsExplorerViews'; import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions'; import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; import Toolbar from 'container/Toolbar/Toolbar'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useEffect, useMemo, useRef, useState } from 'react'; import { DataSource } from 'types/common/queryBuilder'; import { WrapperStyled } from './styles'; -import { SELECTED_VIEWS } from './utils'; +import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils'; function LogsExplorer(): JSX.Element { const [showFrequencyChart, setShowFrequencyChart] = useState(true); const [selectedView, setSelectedView] = useState( SELECTED_VIEWS.SEARCH, ); + const [showFilters, setShowFilters] = useState(() => { + const localStorageValue = getLocalStorageKey( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + ); + if (!isNull(localStorageValue)) { + return localStorageValue === 'true'; + } + return true; + }); const { handleRunQuery, currentQuery } = useQueryBuilder(); @@ -37,6 +52,14 @@ function LogsExplorer(): JSX.Element { setSelectedView(view); }; + const handleFilterVisibilityChange = (): void => { + setLocalStorageApi( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + String(!showFilters), + ); + setShowFilters((prev) => !prev); + }; + // Switch to query builder view if there are more than 1 queries useEffect(() => { if (currentQuery.builder.queryData.length > 1) { @@ -90,46 +113,60 @@ function LogsExplorer(): JSX.Element { return ( }> - - } - rightActions={ - - } - showOldCTA - /> - - -
-
- - - -
-
- + {showFilters && ( +
+ -
-
-
+ + )} +
+ + } + rightActions={ + + } + showOldCTA + /> + + +
+
+ + + +
+
+ +
+
+
+
+
); } diff --git a/frontend/src/pages/LogsExplorer/utils.ts b/frontend/src/pages/LogsExplorer/utils.ts deleted file mode 100644 index 0fedaaece4..0000000000 --- a/frontend/src/pages/LogsExplorer/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Query } from 'types/api/queryBuilder/queryBuilderData'; - -export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ - ...query, - builder: { - ...query.builder, - queryData: query.builder.queryData?.map((item) => ({ - ...item, - orderBy: [{ columnName: 'timestamp', order: 'desc' }], - })), - }, -}); - -// eslint-disable-next-line @typescript-eslint/naming-convention -export enum SELECTED_VIEWS { - SEARCH = 'search', - QUERY_BUILDER = 'query-builder', - CLICKHOUSE = 'clickhouse', -} diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx new file mode 100644 index 0000000000..7a197bd467 --- /dev/null +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -0,0 +1,113 @@ +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/QuickFilters'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData?.map((item) => ({ + ...item, + orderBy: [{ columnName: 'timestamp', order: 'desc' }], + })), + }, +}); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum SELECTED_VIEWS { + SEARCH = 'search', + QUERY_BUILDER = 'query-builder', + CLICKHOUSE = 'clickhouse', +} + +export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Severity Text', + attributeKey: { + key: 'severity_text', + dataType: DataTypes.String, + type: '', + isColumn: true, + isJSON: false, + id: 'severity_text--string----true', + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + id: 'service.name--string--resource--true', + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Hostname', + attributeKey: { + key: 'hostname', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Cluster Name', + attributeKey: { + key: 'k8s.cluster.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Deployment Name', + attributeKey: { + key: 'k8s.deployment.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Namespace Name', + attributeKey: { + key: 'k8s.namespace.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + defaultOpen: false, + }, +]; diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index a28776f0d0..4a3fa8018e 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -77,6 +77,14 @@ jest.mock( }, ); +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + const successNotification = jest.fn(); jest.mock('hooks/useNotifications', () => ({ __esModule: true, diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index c3b50bbc7e..305372eea6 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -62,6 +62,8 @@ import { v4 as uuid } from 'uuid'; export const QueryBuilderContext = createContext({ currentQuery: initialQueriesMap.metrics, supersetQuery: initialQueriesMap.metrics, + lastUsedQuery: null, + setLastUsedQuery: () => {}, setSupersetQuery: () => {}, stagedQuery: initialQueriesMap.metrics, initialDataSource: null, @@ -117,6 +119,7 @@ export function QueryBuilderProvider({ const [currentQuery, setCurrentQuery] = useState(queryState); const [supersetQuery, setSupersetQuery] = useState(queryState); + const [lastUsedQuery, setLastUsedQuery] = useState(0); const [stagedQuery, setStagedQuery] = useState(null); const [queryType, setQueryType] = useState(queryTypeParam); @@ -230,6 +233,8 @@ export function QueryBuilderProvider({ timeUpdated ? merge(currentQuery, newQueryState) : newQueryState, ); setQueryType(type); + // this is required to reset the last used query when navigating or initializing the query builder + setLastUsedQuery(0); }, [prepareQueryBuilderData, currentQuery], ); @@ -857,6 +862,8 @@ export function QueryBuilderProvider({ () => ({ currentQuery: query, supersetQuery: superQuery, + lastUsedQuery, + setLastUsedQuery, setSupersetQuery, stagedQuery, initialDataSource, @@ -884,6 +891,7 @@ export function QueryBuilderProvider({ [ query, superQuery, + lastUsedQuery, stagedQuery, initialDataSource, panelType, diff --git a/frontend/src/types/common/queryBuilder.ts b/frontend/src/types/common/queryBuilder.ts index 4a67619a61..fd3b4c0530 100644 --- a/frontend/src/types/common/queryBuilder.ts +++ b/frontend/src/types/common/queryBuilder.ts @@ -189,6 +189,8 @@ export type QueryBuilderData = { export type QueryBuilderContextType = { currentQuery: Query; stagedQuery: Query | null; + lastUsedQuery: number | null; + setLastUsedQuery: Dispatch>; supersetQuery: Query; setSupersetQuery: Dispatch>; initialDataSource: DataSource | null;