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
This commit is contained in:
Vikrant Gupta 2024-09-06 10:24:47 +05:30 committed by GitHub
parent ba95ca682b
commit 4a9847abdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1220 additions and 84 deletions

View File

@ -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);
}
}
}
}
}

View File

@ -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<string, boolean> {
const defaultState: Record<string, boolean> = {};
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<string>('');
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(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<string, boolean> = 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 (
<div className="checkbox-filter">
<section className="filter-header-checkbox">
<section className="left-action">
{isOpen ? (
<ChevronDown
size={13}
cursor="pointer"
onClick={(): void => {
setIsOpen(false);
setVisibleItemsCount(10);
}}
/>
) : (
<ChevronRight
size={13}
onClick={(): void => setIsOpen(true)}
cursor="pointer"
/>
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && (
<Typography.Text
className="clear-all"
onClick={handleClearFilterAttribute}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && (
<>
<section className="search">
<Input
placeholder="Filter values"
onChange={(e): void => setSearchText(e.target.value)}
disabled={isFilterDisabled}
/>
</section>
{attributeValues.length > 0 ? (
<section className="values">
{currentAttributeKeys.map((value: string) => (
<div key={value} className="value">
<Checkbox
onChange={(e): void => onChange(value, e.target.checked, false)}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
rootClassName="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{value}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
))}
</section>
) : (
<section className="no-data">
<Typography.Text>No values found</Typography.Text>{' '}
</section>
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}

View File

@ -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 <div>Slider</div>;
}

View File

@ -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);
}
}
}
}
}

View File

@ -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 (
<div className="quick-filters">
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
</section>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return <Checkbox filter={filter} />;
case FiltersType.SLIDER:
return <Slider filter={filter} />;
default:
return <Checkbox filter={filter} />;
}
})}
</section>
</div>
);
}

View File

@ -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',
}

View File

@ -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 {

View File

@ -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({
<Col
key={query.queryName}
span={24}
onClickCapture={(): void => setLastUsedQuery(index)}
className="query"
id={`qb-query-${query.queryName}`}
>
@ -265,10 +275,13 @@ export const QueryBuilder = memo(function QueryBuilder({
{!isListViewPanel && (
<Col span={1} className="query-builder-mini-map">
{currentQuery.builder.queryData.map((query) => (
{currentQuery.builder.queryData.map((query, index) => (
<Button
disabled={isDisabledQueryButton}
className="query-btn"
className={cx(
'query-btn',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
)}
key={query.queryName}
onClick={(): void => handleScrollIntoView('query', query.queryName)}
>

View File

@ -44,6 +44,12 @@
border: 1px solid rgba(242, 71, 105, 0.2) !important;
background: rgba(242, 71, 105, 0.1) !important;
&.sync-btn {
border: 1px solid rgba(78, 116, 248, 0.2) !important;
background: rgba(78, 116, 248, 0.1) !important;
color: var(--bg-robin-500) !important;
}
&:hover {
border: 1px solid rgba(242, 71, 105, 0.4) !important;
color: var(--bg-sakura-400) !important;

View File

@ -4,6 +4,8 @@ import './QBEntityOptions.styles.scss';
import { Button, Col, Tooltip } from 'antd';
import { noop } from 'antd/lib/_util/warning';
import cx from 'classnames';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import {
ChevronDown,
@ -13,6 +15,7 @@ import {
EyeOff,
Trash2,
} from 'lucide-react';
import { useLocation } from 'react-router-dom';
import {
IBuilderQuery,
QueryFunctionProps,
@ -35,6 +38,7 @@ interface QBEntityOptionsProps {
onQueryFunctionsUpdates?: (functions: QueryFunctionProps[]) => void;
showDeleteButton: boolean;
isListViewPanel?: boolean;
index?: number;
}
export default function QBEntityOptions({
@ -51,6 +55,7 @@ export default function QBEntityOptions({
showDeleteButton,
onQueryFunctionsUpdates,
isListViewPanel,
index,
}: QBEntityOptionsProps): JSX.Element {
const handleCloneEntity = (): void => {
if (isFunction(onCloneQuery)) {
@ -58,6 +63,12 @@ export default function QBEntityOptions({
}
};
const { pathname } = useLocation();
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
const { lastUsedQuery } = useQueryBuilder();
const isLogsDataSource = query?.dataSource === DataSource.LOGS;
return (
@ -98,6 +109,7 @@ export default function QBEntityOptions({
className={cx(
'periscope-btn',
entityType === 'query' ? 'query-name' : 'formula-name',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
)}
>
{entityData.queryName}
@ -143,4 +155,5 @@ QBEntityOptions.defaultProps = {
onQueryFunctionsUpdates: undefined,
showFunctions: false,
onCloneQuery: noop,
index: 0,
};

View File

@ -348,6 +348,7 @@ export const Query = memo(function Query({
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
showDeleteButton={currentQuery.builder.queryData.length > 1}
isListViewPanel={isListViewPanel}
index={index}
/>
{!isCollapse && (

View File

@ -1,5 +1,6 @@
import './ToolbarActions.styles.scss';
import { FilterOutlined } from '@ant-design/icons';
import { Button, Switch, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import { Atom, SquareMousePointer, Terminal } from 'lucide-react';
@ -11,6 +12,8 @@ interface LeftToolbarActionsProps {
onToggleHistrogramVisibility: () => void;
onChangeSelectedView: (view: SELECTED_VIEWS) => void;
showFrequencyChart: boolean;
showFilter: boolean;
handleFilterVisibilityChange: () => void;
}
const activeTab = 'active-tab';
@ -23,11 +26,20 @@ export default function LeftToolbarActions({
onToggleHistrogramVisibility,
onChangeSelectedView,
showFrequencyChart,
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, search, queryBuilder: QB } = items;
return (
<div className="left-toolbar">
{!showFilter && (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<FilterOutlined />
</Button>
</Tooltip>
)}
<div className="left-toolbar-query-actions">
<Tooltip title="Search">
<Button

View File

@ -2,6 +2,17 @@
display: flex;
align-items: center;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
width: 32px;
height: 32px;
margin-right: 12px;
border: 1px solid var(--bg-slate-400);
}
.left-toolbar-query-actions {
display: flex;
border-radius: 2px;

View File

@ -35,6 +35,8 @@ describe('ToolbarActions', () => {
onChangeSelectedView={handleChangeSelectedView}
onToggleHistrogramVisibility={handleToggleShowFrequencyChart}
showFrequencyChart
showFilter
handleFilterVisibilityChange={(): void => {}}
/>,
);
expect(screen.getByTestId('search-view')).toBeInTheDocument();
@ -79,6 +81,8 @@ describe('ToolbarActions', () => {
onChangeSelectedView={handleChangeSelectedView}
onToggleHistrogramVisibility={handleToggleShowFrequencyChart}
showFrequencyChart
showFilter
handleFilterVisibilityChange={(): void => {}}
/>,
);

View File

@ -9,6 +9,7 @@ import NoLogs from 'container/NoLogs/NoLogs';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
@ -48,14 +49,7 @@ function TimeSeriesView({
]);
const isDarkMode = useIsDarkMode();
const width = graphRef.current?.clientWidth
? graphRef.current.clientWidth
: 700;
const height = graphRef.current?.clientWidth
? graphRef.current.clientHeight
: 300;
const containerDimensions = useResizeObserver(graphRef);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
@ -129,8 +123,8 @@ function TimeSeriesView({
yAxisUnit: yAxisUnit || '',
apiResponse: data?.payload,
dimensions: {
width,
height,
width: containerDimensions.width,
height: containerDimensions.height,
},
isDarkMode,
minTimeScale,

View File

@ -1,7 +1,10 @@
import './Toolbar.styles.scss';
import ROUTES from 'constants/routes';
import NewExplorerCTA from 'container/NewExplorerCTA';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
interface ToolbarProps {
showAutoRefresh: boolean;
@ -16,12 +19,20 @@ export default function Toolbar({
rightActions,
showOldCTA,
}: ToolbarProps): JSX.Element {
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [
pathname,
]);
return (
<div className="toolbar">
<div className="leftActions">{leftActions}</div>
<div className="timeRange">
{showOldCTA && <NewExplorerCTA />}
<DateTimeSelectionV2 showAutoRefresh={showAutoRefresh} />
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
showRefreshText={!isLogsExplorerPage}
/>
</div>
<div className="rightActions">{rightActions}</div>
</div>

View File

@ -60,6 +60,7 @@ import { Form, FormContainer, FormItem } from './styles';
function DateTimeSelection({
showAutoRefresh,
showRefreshText = true,
hideShareModal = false,
location,
updateTimeInterval,
@ -632,7 +633,7 @@ function DateTimeSelection({
<NewExplorerCTA />
</div>
)}
{!hasSelectedTimeError && !refreshButtonHidden && (
{!hasSelectedTimeError && !refreshButtonHidden && showRefreshText && (
<RefreshText
{...{
onLastRefreshHandler,
@ -716,6 +717,7 @@ function DateTimeSelection({
interface DateTimeSelectionV2Props {
showAutoRefresh: boolean;
showRefreshText?: boolean;
hideShareModal?: boolean;
showOldExplorerCTA?: boolean;
showResetButton?: boolean;
@ -725,6 +727,7 @@ interface DateTimeSelectionV2Props {
DateTimeSelection.defaultProps = {
hideShareModal: false,
showOldExplorerCTA: false,
showRefreshText: true,
showResetButton: false,
defaultRelativeTime: RelativeTimeMap['6hr'] as Time,
};

View File

@ -52,6 +52,7 @@ export const useQueryOperations: UseQueryOperations = ({
panelType,
initialDataSource,
currentQuery,
setLastUsedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
@ -259,7 +260,13 @@ export const useQueryOperations: UseQueryOperations = ({
if (currentQuery.builder.queryData.length > 1) {
removeQueryBuilderEntityByIndex('queryData', index);
}
}, [removeQueryBuilderEntityByIndex, index, currentQuery]);
setLastUsedQuery(0);
}, [
currentQuery.builder.queryData.length,
setLastUsedQuery,
removeQueryBuilderEntityByIndex,
index,
]);
const handleChangeQueryData: HandleChangeQueryData = useCallback(
(key, value) => {

View File

@ -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;
}
}
.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);
}
}
}

View File

@ -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,

View File

@ -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>(
SELECTED_VIEWS.SEARCH,
);
const [showFilters, setShowFilters] = useState<boolean>(() => {
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 (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<Toolbar
showAutoRefresh={false}
leftActions={
<LeftToolbarActions
items={toolbarViews}
selectedView={selectedView}
onChangeSelectedView={handleChangeSelectedView}
onToggleHistrogramVisibility={handleToggleShowFrequencyChart}
showFrequencyChart={showFrequencyChart}
/>
}
rightActions={
<RightToolbarActions
onStageRunQuery={handleRunQuery}
listQueryKeyRef={listQueryKeyRef}
chartQueryKeyRef={chartQueryKeyRef}
isLoadingQueries={isLoadingQueries}
/>
}
showOldCTA
/>
<WrapperStyled>
<div className="log-explorer-query-container">
<div>
<ExplorerCard sourcepage={DataSource.LOGS}>
<LogExplorerQuerySection selectedView={selectedView} />
</ExplorerCard>
</div>
<div className="logs-explorer-views">
<LogsExplorerViews
selectedView={selectedView}
showFrequencyChart={showFrequencyChart}
listQueryKeyRef={listQueryKeyRef}
chartQueryKeyRef={chartQueryKeyRef}
setIsLoadingQueries={setIsLoadingQueries}
<div className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
config={LogsQuickFiltersConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</div>
</div>
</WrapperStyled>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar
showAutoRefresh={false}
leftActions={
<LeftToolbarActions
showFilter={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
items={toolbarViews}
selectedView={selectedView}
onChangeSelectedView={handleChangeSelectedView}
onToggleHistrogramVisibility={handleToggleShowFrequencyChart}
showFrequencyChart={showFrequencyChart}
/>
}
rightActions={
<RightToolbarActions
onStageRunQuery={handleRunQuery}
listQueryKeyRef={listQueryKeyRef}
chartQueryKeyRef={chartQueryKeyRef}
isLoadingQueries={isLoadingQueries}
/>
}
showOldCTA
/>
<WrapperStyled>
<div className="log-explorer-query-container">
<div>
<ExplorerCard sourcepage={DataSource.LOGS}>
<LogExplorerQuerySection selectedView={selectedView} />
</ExplorerCard>
</div>
<div className="logs-explorer-views">
<LogsExplorerViews
selectedView={selectedView}
showFrequencyChart={showFrequencyChart}
listQueryKeyRef={listQueryKeyRef}
chartQueryKeyRef={chartQueryKeyRef}
setIsLoadingQueries={setIsLoadingQueries}
/>
</div>
</div>
</WrapperStyled>
</section>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@ -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',
}

View File

@ -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,
},
];

View File

@ -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,

View File

@ -62,6 +62,8 @@ import { v4 as uuid } from 'uuid';
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
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>(queryState);
const [supersetQuery, setSupersetQuery] = useState<QueryState>(queryState);
const [lastUsedQuery, setLastUsedQuery] = useState<number | null>(0);
const [stagedQuery, setStagedQuery] = useState<Query | null>(null);
const [queryType, setQueryType] = useState<EQueryType>(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,

View File

@ -189,6 +189,8 @@ export type QueryBuilderData = {
export type QueryBuilderContextType = {
currentQuery: Query;
stagedQuery: Query | null;
lastUsedQuery: number | null;
setLastUsedQuery: Dispatch<SetStateAction<number | null>>;
supersetQuery: Query;
setSupersetQuery: Dispatch<SetStateAction<QueryState>>;
initialDataSource: DataSource | null;