Custom Quick Filters: Logs (#7986)

* chore: quick filters - added filters init (#7867)

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

* Custom quick filter: Other Filters | Search integration (#7939)

* chore: added filters init

* chore: handle save and discard

* chore: search and api intergrations

* feat: search on filters

* feat: style fix

* feat: style fix

* feat: signal to data source config

* feat: search styles

* feat: update drawer slide style

* feat: no results state

* fix: minor fix

* Custom Quick FIlters: UI fixes and Announcement Tooltip (#7950)

* feat: qf setting ui

* feat: add skeleton to dynamic qf

* fix: minor fix

* feat: announcement tooltip added

* feat: announcement tooltip added refactor

* feat: announcement tooltip styles

* feat: announcement tooltip integration

* fix: number vals in filter list

* feat: announcement tooltip show logic added

* feat: light mode styles

* feat: remove unwanted styles

* feat: remove filter disable when one filter added

* style: minor style

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

* Custom quick filters: Tests and pr review comments (#7967)

* chore: added filters init

* chore: handle save and discard

* chore: search and api intergrations

* feat: search on filters

* feat: style fix

* feat: style fix

* feat: signal to data source config

* feat: search styles

* feat: update drawer slide style

* feat: no results state

* fix: minor fix

* feat: qf setting ui

* feat: add skeleton to dynamic qf

* fix: minor fix

* feat: announcement tooltip added

* feat: announcement tooltip added refactor

* feat: announcement tooltip styles

* feat: announcement tooltip integration

* fix: number vals in filter list

* feat: announcement tooltip show logic added

* feat: light mode styles

* feat: remove unwanted styles

* feat: remove filter disable when one filter added

* style: minor style

* fix: minor refactor

* test: added test cases

* feat: integrate custom quick filters in logs

* feat: code refactor

* feat: debounce search

* feat: refactor

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
This commit is contained in:
Aditya Singh 2025-05-20 18:09:49 +05:30 committed by GitHub
parent 207d7602ab
commit 040c45b144
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1624 additions and 526 deletions

View File

@ -31,6 +31,7 @@
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.2.3",
"@mdx-js/loader": "2.3.0",
"@mdx-js/react": "2.3.0",

View File

@ -0,0 +1,25 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await ApiBaseInstance.get(`orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@ -0,0 +1,13 @@
import { ApiBaseInstance } from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
ApiBaseInstance.put(`orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@ -12,7 +12,7 @@ export const Logout = (): void => {
deleteLocalStorageKey(LOCALSTORAGE.LOGGED_IN_USER_NAME);
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT);
window.dispatchEvent(new CustomEvent('LOGOUT'));
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -19,7 +19,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isEmpty, isEqual, isFunction } from 'lodash-es';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@ -82,7 +82,9 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const attributeValues: string[] = useMemo(() => {
const dataType = filter.attributeKey.dataType || DataTypes.String;
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
return (data?.payload?.[key] || []).filter((val) => !isEmpty(val));
return (data?.payload?.[key] || []).filter(
(val) => val !== undefined && val !== null,
);
}, [data?.payload, filter.attributeKey.dataType]);
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);

View File

@ -1,7 +1,16 @@
.quick-filters-container {
display: flex;
height: 100%;
.quick-filters-settings-container {
position: relative;
}
}
.quick-filters {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
@ -44,7 +53,7 @@
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
line-height: 18px;
text-transform: uppercase;
}
}
@ -52,7 +61,7 @@
.right-actions {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
width: 100%;
justify-content: flex-end;
@ -63,10 +72,34 @@
}
.sync-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--bg-ink-500);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--bg-slate-500);
}
}
}
}
.quick-filters-skeleton {
.ant-skeleton-input {
width: 236px;
margin: 8px 12px;
}
}
}
@ -90,8 +123,12 @@
}
}
.right-actions {
.sync-icon {
.right-action-icon-container {
background-color: var(--bg-vanilla-100);
&.active,
&:hover {
background-color: var(--bg-vanilla-200);
}
}
}
}

View File

@ -5,18 +5,43 @@ import {
SyncOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { Skeleton, Tooltip, Typography } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import classNames from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isFunction } from 'lodash-es';
import { cloneDeep, isFunction, isNull } from 'lodash-es';
import { Settings2 as SettingsIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
import AnnouncementTooltip from './QuickFiltersSettings/AnnouncementTooltip';
import QuickFiltersSettings from './QuickFiltersSettings/QuickFiltersSettings';
import { FiltersType, IQuickFiltersProps, QuickFiltersSource } from './types';
export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const { config, handleFilterVisibilityChange, source, onFilterChange } = props;
const {
className,
config,
handleFilterVisibilityChange,
source,
onFilterChange,
signal,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const {
filterConfig,
isDynamicFilters,
customFilters,
setIsStale,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
const {
currentQuery,
@ -24,6 +49,16 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
redirectWithQueryBuilderData,
} = useQueryBuilder();
const showAnnouncementTooltip = useMemo(() => {
const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
);
if (!isNull(localStorageValue)) {
return !(localStorageValue === 'false');
}
return true;
}, []);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
const updatedQuery = cloneDeep(
@ -63,68 +98,141 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
return (
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
<div className="quick-filters-container">
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<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">
<div className="right-action-icon-container">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</div>
</Tooltip>
)}
<Tooltip title="Collapse Filters">
<div className="right-action-icon-container">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</div>
</Tooltip>
{isDynamicFilters && (
<Tooltip title="Settings">
<div
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
onClick={(): void => setIsSettingsOpen(true)}
/>
<AnnouncementTooltip
show={showAnnouncementTooltip}
position={{ top: -5, left: 15 }}
title="Edit your quick filters"
message="You can now customize and re-arrange your quick filters panel. Select the quick filters youd need and hide away the rest for faster exploration."
onClose={(): void => {
setLocalStorageKey(
LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT,
'false',
);
}}
/>
</div>
</Tooltip>
)}
</section>
</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>
{isCustomFiltersLoading ? (
<div className="quick-filters-skeleton">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton.Input
active
size="small"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>
))}
</div>
) : (
<OverlayScrollbar>
<section className="filters">
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
</section>
</OverlayScrollbar>
)}
<TypicalOverlayScrollbar>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
</TypicalOverlayScrollbar>
</div>
<div className="quick-filters-settings-container">
<div
className={classNames(
'quick-filters-settings',
{
hidden: !isSettingsOpen,
},
className,
)}
>
{isSettingsOpen && (
<QuickFiltersSettings
signal={signal}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
setIsStale={setIsStale}
/>
)}
</div>
</div>
</div>
);
}
QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
};

View File

@ -0,0 +1,147 @@
/* eslint-disable react/jsx-props-no-spreading */
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from 'lucide-react';
import { useMemo } from 'react';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function SortableFilter({
filter,
onRemove,
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: filter.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`qf-filter-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
}}
>
Remove
</Button>
)}
</div>
);
}
function AddedFilters({
inputValue,
addedFilters,
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAddedFilters((items) => {
const oldIndex = items.findIndex((item) => item.key === active.id);
const newIndex = items.findIndex((item) => item.key === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
const allowDrag = inputValue.length === 0;
const allowRemove = addedFilters.length > 1;
return (
<div className="qf-filters added-filters">
<div className="qf-filters-header">ADDED FILTERS</div>
<div className="qf-added-filters-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredAddedFilters.length === 0 ? (
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredAddedFilters.map((filter) => (
<SortableFilter
key={filter.key}
filter={filter}
onRemove={handleRemoveFilter}
allowDrag={allowDrag}
allowRemove={allowRemove}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFilters;

View File

@ -0,0 +1,56 @@
.announcement-tooltip {
&__dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--bg-robin-500);
z-index: 1000;
}
&__title {
font-weight: 500;
font-size: 15px;
}
&__container {
position: absolute;
width: 320px;
background-color: var(--bg-robin-500);
color: var(--text-white);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__close-icon {
cursor: pointer;
color: var(--text-white);
}
&__message {
margin: 12px 0;
line-height: 20px;
}
&__footer {
display: flex;
justify-content: flex-end;
}
&__button {
background: var(--bg-vanilla-100);
color: var(--bg-robin-500);
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
}

View File

@ -0,0 +1,79 @@
import './AnnouncementTooltip.styles.scss';
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { X } from 'lucide-react';
import { useState } from 'react';
type AnnouncementTooltipProps = {
position: { top: number; left: number };
title: string;
message: string;
show?: boolean;
className?: string;
onClose?: () => void;
};
// TEMPORARY HACK FOR ANNOUNCEMENTS: To be removed once proper system in place.
function AnnouncementTooltip({
position,
show,
title,
message,
className,
onClose,
}: AnnouncementTooltipProps): JSX.Element | null {
const [visible, setVisible] = useState(show);
const closeTooltip = (): void => {
setVisible(false);
onClose?.();
};
return visible ? (
<>
{/* Dot */}
<div
className={classNames('announcement-tooltip__dot', className)}
style={{
top: position.top,
left: position.left,
}}
/>
{/* Tooltip box */}
<div
className={classNames('announcement-tooltip__container', className)}
style={{
top: position.top,
left: position.left + 30,
}}
>
<div className="announcement-tooltip__header">
<Typography.Text className="announcement-tooltip__title">
{title}
</Typography.Text>
<X
size={18}
onClick={closeTooltip}
className="announcement-tooltip__close-icon"
/>
</div>
<p className="announcement-tooltip__message">{message}</p>
<div className="announcement-tooltip__footer">
<Button onClick={closeTooltip} className="announcement-tooltip__button">
Okay
</Button>
</div>
</div>
</>
) : null;
}
AnnouncementTooltip.defaultProps = {
show: false,
className: '',
onClose: (): void => {},
};
export default AnnouncementTooltip;

View File

@ -0,0 +1,104 @@
import { Button, Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useMemo } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function OtherFiltersSkeleton(): JSX.Element {
return (
<>
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton.Input
active
size="small"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>
))}
</>
);
}
function OtherFilters({
signal,
inputValue,
addedFilters,
setAddedFilters,
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
} = useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal,
},
);
const otherFilters = useMemo(
() =>
suggestionsData?.payload?.attributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
),
[suggestionsData, addedFilters],
);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
isColumn: filter.isColumn,
isJSON: filter.isJSON,
type: filter.type,
},
]);
};
const renderFilters = (): React.ReactNode => {
if (isFetchingSuggestions) return <OtherFiltersSkeleton />;
if (!otherFilters?.length)
return <div className="no-values-found">No values found</div>;
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
>
Add
</Button>
</div>
));
};
return (
<div className="qf-filters other-filters">
<div className="qf-filters-header">OTHER FILTERS</div>
<div className="qf-other-filters-list">
<OverlayScrollbar>
<>{renderFilters()}</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFilters;

View File

@ -0,0 +1,190 @@
.quick-filters-settings {
display: flex;
flex-direction: column;
position: absolute;
z-index: 999;
width: 342px;
background: var(--bg-slate-500);
transition: width 0.05s ease-in-out;
overflow: hidden;
&.qf-logs-explorer {
height: calc(100vh - 45px);
}
&.qf-exceptions {
height: 100vh;
}
&.hidden {
width: 0;
}
.qf-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10.5px;
.qf-title {
display: flex;
align-items: center;
gap: 12px;
}
.qf-header-icon {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.qf-filters {
&.added-filters {
max-height: 40%;
}
&.other-filters {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
}
.search {
.ant-input {
background-color: var(--bg-slate-500);
height: 46px;
border-radius: 0;
}
}
.qf-other-filters-list {
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.qf-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--bg-slate-400);
button {
display: flex;
align-items: center;
justify-content: center;
width: 50%;
.ant-btn-icon {
margin: 3px !important;
}
}
}
}
//ADDED FILTERS AND OTHER FILTERS COMMON STYLES
.qf-filters {
display: flex;
flex-direction: column;
.qf-filters-header {
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
}
.no-values-found {
padding: 8px;
text-align: center;
}
.qf-added-filters-list {
margin-bottom: 12px;
overflow: hidden;
}
.qf-other-filters-list {
margin-bottom: 12px;
overflow: hidden;
}
.qf-filter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 4px;
user-select: none;
transition: background-color 0.2s ease-in-out;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.qf-filter-content {
display: flex;
align-items: center;
gap: 8px;
}
&.other-filters-item {
padding: 8px 12px;
height: 32px;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 8px 12px;
}
.remove-filter-btn,
.add-filter-btn {
padding: 6px 12px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover {
background-color: var(--bg-slate-400);
.remove-filter-btn,
.add-filter-btn {
opacity: 1;
}
}
}
.lightMode {
.quick-filters-settings {
background: var(--bg-vanilla-100);
.search {
.ant-input {
background-color: var(--bg-vanilla-100);
}
}
.qf-footer {
border-top: 1px solid var(--bg-vanilla-300);
}
}
.qf-filter-item {
&:hover {
background-color: var(--bg-vanilla-200);
}
}
}

View File

@ -0,0 +1,109 @@
import './QuickFiltersSettings.styles.scss';
import { Button, Input } from 'antd';
import { CheckIcon, TableColumnsSplit, XIcon } from 'lucide-react';
import { useMemo } from 'react';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
import useQuickFilterSettings from './hooks/useQuickFilterSettings';
import OtherFilters from './OtherFilters';
function QuickFiltersSettings({
signal,
setIsSettingsOpen,
customFilters,
setIsStale,
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
}): JSX.Element {
const {
handleSettingsClose,
handleDiscardChanges,
addedFilters,
setAddedFilters,
handleSaveChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
debouncedInputValue,
} = useQuickFilterSettings({
setIsSettingsOpen,
customFilters,
setIsStale,
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">
<div className="qf-title">
<TableColumnsSplit width={16} height={16} />
Edit quick filters
</div>
<XIcon
className="qf-header-icon"
width={16}
height={16}
onClick={handleSettingsClose}
/>
</div>
<section className="search">
<Input
type="text"
value={inputValue}
placeholder="Search for a filter..."
onChange={handleInputChange}
/>
</section>
<AddedFilters
inputValue={inputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}
/>
<OtherFilters
signal={signal}
inputValue={debouncedInputValue}
addedFilters={addedFilters}
setAddedFilters={setAddedFilters}
/>
{hasUnsavedChanges && (
<div className="qf-footer">
<Button
type="default"
onClick={handleDiscardChanges}
icon={<XIcon width={16} height={16} />}
>
Discard
</Button>
<Button
type="primary"
onClick={handleSaveChanges}
icon={<CheckIcon width={16} height={16} />}
loading={isUpdatingCustomFilters}
>
Save changes
</Button>
</div>
)}
</>
);
}
export default QuickFiltersSettings;

View File

@ -0,0 +1,9 @@
import { SignalType } from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.LOGS]: DataSource.LOGS,
[SignalType.TRACES]: DataSource.TRACES,
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
};

View File

@ -0,0 +1,111 @@
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
debouncedInputValue: string;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
setIsStale,
signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const { notifications } = useNotifications();
const {
mutate: updateCustomFilters,
isLoading: isUpdatingCustomFilters,
} = useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
setIsStale(true);
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
});
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleSettingsClose = useCallback((): void => {
setIsSettingsOpen(false);
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
data: {
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
})),
signal,
},
});
}
}, [addedFilters, signal, updateCustomFilters]);
return {
handleSettingsClose,
handleDiscardChanges,
addedFilters,
setAddedFilters,
handleSaveChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,
debouncedInputValue,
handleInputChange,
};
};
export default useQuickFilterSettings;

View File

@ -0,0 +1,67 @@
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
Filter as FilterType,
PayloadProps,
} from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
interface UseFilterConfigProps {
signal?: SignalType;
config: IQuickFiltersConfig[];
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
}
const useFilterConfig = ({
signal,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
const [isStale, setIsStale] = useState(true);
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters,
]);
const { isLoading: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
() => getCustomFilters({ signal: signal || '' }),
{
onSuccess: (data) => {
if ('payload' in data && data.payload?.filters) {
setCustomFilters(data.payload.filters || ([] as FilterType[]));
}
setIsStale(false);
},
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo(() => getFilterConfig(customFilters, config), [
config,
customFilters,
]);
return {
filterConfig,
customFilters,
setCustomFilters,
isCustomFiltersLoading,
isDynamicFilters,
setIsStale,
};
};
export default useFilterConfig;

View File

@ -1,111 +1,288 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
quickFiltersAttributeValuesResponse,
quickFiltersListResponse,
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource } from '../types';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
import { QuickFiltersConfig } from './constants';
// Mock the useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// Mock the useGetAggregateValues hook
jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
useGetAggregateValues: jest.fn(),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
function TestQuickFilters(): JSX.Element {
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const FILTER_OS_DESCRIPTION = 'os.description';
const FILTER_K8S_DEPLOYMENT_NAME = 'k8s.deployment.name';
const ADDED_FILTERS_LABEL = /ADDED FILTERS/i;
const OTHER_FILTERS_LABEL = /OTHER FILTERS/i;
const SAVE_CHANGES_TEXT = 'Save changes';
const DISCARD_TEXT = 'Discard';
const FILTER_SERVICE_NAME = 'Service Name';
const SETTINGS_ICON_TEST_ID = 'settings-icon';
const QUERY_NAME = 'Test Query';
const setupServer = (): void => {
server.use(
rest.get(quickFiltersListURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
),
rest.get(quickFiltersSuggestionsURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(otherFiltersResponse)),
),
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (_, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
};
function TestQuickFilters({
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
</MockQueryClientProvider>
);
}
describe('Quick Filters', () => {
beforeEach(() => {
// Provide a mock implementation for useQueryBuilder
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'Test Query',
filters: { items: [{ key: 'test', value: 'value' }] },
},
],
},
TestQuickFilters.defaultProps = {
signal: '',
config: QuickFiltersConfig,
};
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
cleanup();
});
beforeEach(() => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: QUERY_NAME,
filters: { items: [{ key: 'test', value: 'value' }] },
},
],
},
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
// Provide a mock implementation for useGetAggregateValues
(useGetAggregateValues as jest.Mock).mockReturnValue({
data: {
statusCode: 200,
error: null,
message: 'success',
payload: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
},
}, // Mocked API response
isLoading: false,
});
});
it('renders correctly with default props', () => {
const { container } = render(<TestQuickFilters />);
expect(container).toMatchSnapshot();
},
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
setupServer();
});
describe('Quick Filters', () => {
it('displays the correct query name in the header', () => {
render(<TestQuickFilters />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText('Test Query')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
});
it('should add filter data to query when checkbox is clicked', () => {
it('should add filter data to query when checkbox is clicked', async () => {
render(<TestQuickFilters />);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment',
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment',
}),
value: 'mq-kafka',
}),
value: 'mq-kafka',
}),
]),
]),
}),
}),
}),
]),
},
}),
); // sets composite query param
]),
},
}),
);
});
});
});
describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME);
await screen.findByText('otel-demo');
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
expect(addedSection).toContainElement(
await screen.findByText(FILTER_OS_DESCRIPTION),
);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
expect(otherSection).toContainElement(
await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME),
);
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
const addButton = otherFilterItem.parentElement?.querySelector('button');
expect(addButton).not.toBeNull();
fireEvent.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(addedSection).toHaveTextContent(FILTER_K8S_DEPLOYMENT_NAME);
});
});
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await waitFor(() => {
expect(addedSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
expect(otherSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
it('restores original filter state on Discard', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(addedSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
expect(otherSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
fireEvent.click(screen.getByText(DISCARD_TEXT));
await waitFor(() => {
expect(addedSection).toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
expect(otherSection).not.toContainElement(
screen.getByText(FILTER_OS_DESCRIPTION),
);
});
});
it('saves the updated filters by calling PUT with correct payload', async () => {
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(putHandler).toHaveBeenCalled();
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
);
expect(requestBody.signal).toBe(SIGNAL);
});
});

View File

@ -1,384 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Quick Filters renders correctly with default props 1`] = `
<div>
<div
class="quick-filters"
>
<section
class="header"
>
<section
class="left-actions"
>
<span
aria-label="filter"
class="anticon anticon-filter"
role="img"
>
<svg
aria-hidden="true"
data-icon="filter"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z"
/>
</svg>
</span>
<span
class="ant-typography text css-dev-only-do-not-override-2i2tap"
>
Filters for
</span>
<span
class="ant-typography sync-tag css-dev-only-do-not-override-2i2tap"
>
Test Query
</span>
</section>
<section
class="right-actions"
>
<span
aria-label="sync"
class="anticon anticon-sync sync-icon"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="sync"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M168 504.2c1-43.7 10-86.1 26.9-126 17.3-41 42.1-77.7 73.7-109.4S337 212.3 378 195c42.4-17.9 87.4-27 133.9-27s91.5 9.1 133.8 27A341.5 341.5 0 01755 268.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.7 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c0-6.7-7.7-10.5-12.9-6.3l-56.4 44.1C765.8 155.1 646.2 92 511.8 92 282.7 92 96.3 275.6 92 503.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8zm756 7.8h-60c-4.4 0-7.9 3.5-8 7.8-1 43.7-10 86.1-26.9 126-17.3 41-42.1 77.8-73.7 109.4A342.45 342.45 0 01512.1 856a342.24 342.24 0 01-243.2-100.8c-9.9-9.9-19.2-20.4-27.8-31.4l60.2-47a8 8 0 00-3-14.1l-175.7-43c-5-1.2-9.9 2.6-9.9 7.7l-.7 181c0 6.7 7.7 10.5 12.9 6.3l56.4-44.1C258.2 868.9 377.8 932 512.2 932c229.2 0 415.5-183.7 419.8-411.8a8 8 0 00-8-8.2z"
/>
</svg>
</span>
<div
class="divider-filter"
/>
<span
aria-label="vertical-align-top"
class="anticon anticon-vertical-align-top"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="vertical-align-top"
fill="currentColor"
focusable="false"
height="1em"
style="transform: rotate(270deg);"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M859.9 168H164.1c-4.5 0-8.1 3.6-8.1 8v60c0 4.4 3.6 8 8.1 8h695.8c4.5 0 8.1-3.6 8.1-8v-60c0-4.4-3.6-8-8.1-8zM518.3 355a8 8 0 00-12.6 0l-112 141.7a7.98 7.98 0 006.3 12.9h73.9V848c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V509.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 355z"
/>
</svg>
</span>
</section>
</section>
<div
class="overlay-scrollbar"
data-overlayscrollbars-initialize="true"
>
<div
data-overlayscrollbars-contents=""
>
<section
class="filters"
>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Environment
</span>
</section>
<section
class="right-action"
>
<span
class="ant-typography clear-all css-dev-only-do-not-override-2i2tap"
>
Clear All
</span>
</section>
</section>
<section
class="search"
>
<input
class="ant-input css-dev-only-do-not-override-2i2tap"
placeholder="Filter values"
type="text"
value=""
/>
</section>
<section
class="values"
>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
mq-kafka
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otel-demo
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otlp-python
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
sample-flask
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
</section>
</div>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-right"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m9 18 6-6-6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Service Name
</span>
</section>
<section
class="right-action"
/>
</section>
</div>
</section>
</div>
</div>
</div>
</div>
`;

View File

@ -17,6 +17,13 @@ export enum SpecficFilterOperations {
ONLY = 'ONLY',
}
export enum SignalType {
TRACES = 'traces',
LOGS = 'logs',
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
}
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
@ -33,6 +40,8 @@ export interface IQuickFiltersProps {
handleFilterVisibilityChange: () => void;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
signal?: SignalType;
className?: string;
}
export enum QuickFiltersSource {

View File

@ -0,0 +1,39 @@
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { FiltersType, IQuickFiltersConfig } from './types';
const getFilterName = (str: string): string =>
// replace . and _ with space
// capitalize the first letter of each word
str
.replace(/\./g, ' ')
.replace(/_/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
export const getFilterConfig = (
customFilters?: FilterType[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length) {
return config || [];
}
return customFilters.map(
(att, index) =>
({
type: FiltersType.CHECKBOX,
title: getFilterName(att.key),
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
isColumn: att.isColumn,
isJSON: att.isJSON,
},
defaultOpen: index === 0,
} as IQuickFiltersConfig),
);
};

View File

@ -28,4 +28,5 @@ export enum LOCALSTORAGE {
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
}

View File

@ -78,4 +78,8 @@ export const REACT_QUERY_KEY = {
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
// Quick Filters Query Keys
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
} as const;

View File

@ -0,0 +1,163 @@
export const quickFiltersListResponse = {
status: 'success',
data: {
signal: 'logs',
filters: [
{
key: 'os.description',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'quantity',
dataType: 'float64',
type: 'tag',
isColumn: false,
isJSON: false,
},
{
key: 'body',
dataType: 'string',
type: '',
isColumn: false,
isJSON: false,
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'process.owner',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
],
},
};
export const otherFiltersResponse = {
status: 'success',
data: {
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
isColumn: false,
isJSON: false,
},
],
},
};
export const quickFiltersAttributeValuesResponse = {
status: 'success',
data: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
},
};

View File

@ -6,7 +6,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { LOCALSTORAGE } from 'constants/localStorage';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViews from 'container/LogsExplorerViews';
@ -28,7 +28,7 @@ import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteRe
import { DataSource } from 'types/common/queryBuilder';
import { WrapperStyled } from './styles';
import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils';
import { SELECTED_VIEWS } from './utils';
function LogsExplorer(): JSX.Element {
const [showFrequencyChart, setShowFrequencyChart] = useState(true);
@ -215,8 +215,9 @@ function LogsExplorer(): JSX.Element {
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
config={LogsQuickFiltersConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</section>

View File

@ -0,0 +1,16 @@
export interface Filter {
key: string;
dataType: string;
type: string;
isColumn: boolean;
isJSON: boolean;
}
export interface Props {
signal: string;
}
export type PayloadProps = {
filters: Filter[];
signal: string;
};

View File

@ -0,0 +1,14 @@
import { SignalType } from 'components/QuickFilters/types';
interface FilterType {
key: string;
datatype: string;
type: string;
}
export interface UpdateCustomFiltersProps {
data: {
filters: FilterType[];
signal: SignalType;
};
}

View File

@ -2492,9 +2492,9 @@
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
"@dnd-kit/accessibility@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz#1054e19be276b5f1154ced7947fc0cb5d99192e0"
integrity sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==
version "3.1.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af"
integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==
dependencies:
tslib "^2.0.0"
@ -2523,7 +2523,7 @@
"@dnd-kit/utilities" "^3.2.2"
tslib "^2.0.0"
"@dnd-kit/utilities@^3.2.2":
"@dnd-kit/utilities@3.2.2", "@dnd-kit/utilities@^3.2.2":
version "3.2.2"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b"
integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==