chore: persist the filters and time selection, modal open state in summary view (#7942)

This commit is contained in:
Amlan Kumar Nandy 2025-05-19 11:54:05 +07:00 committed by GitHub
parent 6dbcc5fb9d
commit 2b28c5f2e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 352 additions and 53 deletions

View File

@ -5,9 +5,9 @@
/* eslint-disable prefer-destructuring */
import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import Table, { ColumnsType } from 'antd/es/table';
import { Table, Tooltip, Typography } from 'antd';
import { Progress } from 'antd/lib';
import { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';

View File

@ -15,8 +15,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { COMPOSITE_QUERY_KEY } from './constants';
function MetricNameSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
@ -24,6 +27,7 @@ function MetricNameSearch(): JSX.Element {
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>('');
@ -66,7 +70,7 @@ function MetricNameSearch(): JSX.Element {
const handleSelect = useCallback(
(selectedMetricName: string): void => {
handleChangeQueryData('filters', {
const newFilter = {
items: [
...currentQuery.builder.queryData[0].filters.items,
{
@ -81,10 +85,26 @@ function MetricNameSearch(): JSX.Element {
},
],
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
setIsPopoverOpen(false);
},
[currentQuery.builder.queryData, handleChangeQueryData],
[currentQuery, handleChangeQueryData, setSearchParams],
);
const metricNameFilterValues = useMemo(

View File

@ -4,8 +4,13 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VALUES_MAP } from './constants';
import {
COMPOSITE_QUERY_KEY,
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
} from './constants';
function MetricTypeSearch(): JSX.Element {
const { currentQuery } = useQueryBuilder();
@ -15,6 +20,7 @@ function MetricTypeSearch(): JSX.Element {
entityVersion: '',
});
const [, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo(
@ -34,7 +40,7 @@ function MetricTypeSearch(): JSX.Element {
const handleSelect = useCallback(
(selectedMetricType: string): void => {
if (selectedMetricType !== 'all') {
handleChangeQueryData('filters', {
const newFilter = {
items: [
...currentQuery.builder.queryData[0].filters.items,
{
@ -49,18 +55,50 @@ function MetricTypeSearch(): JSX.Element {
},
],
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
} else {
handleChangeQueryData('filters', {
const newFilter = {
items: currentQuery.builder.queryData[0].filters.items.filter(
(item) => item.id !== 'metric_type',
),
op: 'AND',
};
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilter,
},
],
},
};
handleChangeQueryData('filters', newFilter);
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
}
setIsPopoverOpen(false);
},
[currentQuery.builder.queryData, handleChangeQueryData],
[currentQuery, handleChangeQueryData, setSearchParams],
);
const menu = (

View File

@ -1,32 +1,13 @@
import { Select, Tooltip } from 'antd';
import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { HardHat, Info } from 'lucide-react';
import { TREEMAP_VIEW_OPTIONS } from './constants';
import { MetricsSearchProps } from './types';
function MetricsSearch({
query,
onChange,
heatmapView,
setHeatmapView,
}: MetricsSearchProps): JSX.Element {
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
return (
<div className="metrics-search-container">
<div className="metrics-search-options">
<Select
style={{ width: 140 }}
options={TREEMAP_VIEW_OPTIONS}
value={heatmapView}
onChange={setHeatmapView}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
<div className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
@ -41,6 +22,13 @@ function MetricsSearch({
isMetricsExplorer
/>
</div>
<div className="metrics-search-options">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Skeleton, Tooltip, Typography } from 'antd';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
import { useMemo } from 'react';
@ -10,6 +10,7 @@ import {
TREEMAP_HEIGHT,
TREEMAP_MARGINS,
TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS,
} from './constants';
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
import {
@ -24,6 +25,7 @@ function MetricsTreemap({
isLoading,
isError,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
const { width: windowWidth } = useWindowSize();
@ -55,7 +57,10 @@ function MetricsTreemap({
if (isLoading) {
return (
<div data-testid="metrics-treemap-loading-state">
<Skeleton style={{ width: treemapWidth, height: TREEMAP_HEIGHT }} active />
<Skeleton
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
</div>
);
}
@ -90,13 +95,20 @@ function MetricsTreemap({
data-testid="metrics-treemap-container"
>
<div className="metrics-treemap-title">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
<div className="metrics-treemap-title-left">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
<Select
options={TREEMAP_VIEW_OPTIONS}
value={viewType}
onChange={setHeatmapView}
/>
</div>
<svg
width={treemapWidth}

View File

@ -21,9 +21,22 @@
}
}
.metrics-treemap-title {
justify-content: space-between;
.metrics-treemap-title-left {
display: flex;
align-items: center;
gap: 8px;
}
.ant-select {
width: 140px;
}
}
.metrics-search-container {
display: flex;
flex-direction: column;
gap: 16px;
.metrics-search-options {
@ -35,6 +48,7 @@
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.lucide-info {
cursor: pointer;

View File

@ -11,6 +11,7 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@ -18,6 +19,12 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import InspectModal from '../Inspect';
import MetricDetails from '../MetricDetails';
import {
COMPOSITE_QUERY_KEY,
IS_INSPECT_MODAL_OPEN_KEY,
IS_METRIC_DETAILS_OPEN_KEY,
SELECTED_METRIC_NAME_KEY,
} from './constants';
import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap';
@ -40,10 +47,16 @@ function Summary(): JSX.Element {
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.TIMESERIES,
);
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const [isInspectModalOpen, setIsInspectModalOpen] = useState(false);
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
null,
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
() => searchParams.get(IS_METRIC_DETAILS_OPEN_KEY) === 'true' || false,
);
const [isInspectModalOpen, setIsInspectModalOpen] = useState(
() => searchParams.get(IS_INSPECT_MODAL_OPEN_KEY) === 'true' || false,
);
const [selectedMetricName, setSelectedMetricName] = useState(
() => searchParams.get(SELECTED_METRIC_NAME_KEY) || null,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@ -75,13 +88,25 @@ function Summary(): JSX.Element {
useShareBuilderUrl(defaultQuery);
// This is used to avoid the filters from being serialized with the id
const currentQueryFiltersString = useMemo(() => {
const filters = currentQuery?.builder?.queryData[0]?.filters;
if (!filters) return '';
const filtersWithoutId = {
...filters,
items: filters.items.map(({ id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [currentQuery]);
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery],
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentQueryFiltersString],
);
const { handleChangeQueryData } = useQueryOperations({
@ -145,9 +170,24 @@ function Summary(): JSX.Element {
const handleFilterChange = useCallback(
(value: TagFilter) => {
handleChangeQueryData('filters', value);
const compositeQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: value,
},
],
},
};
setSearchParams({
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
});
setCurrentPage(1);
},
[handleChangeQueryData],
[handleChangeQueryData, currentQuery, setSearchParams],
);
const updatedCurrentQuery = useMemo(
@ -184,17 +224,29 @@ function Summary(): JSX.Element {
const openMetricDetails = (metricName: string): void => {
setSelectedMetricName(metricName);
setIsMetricDetailsOpen(true);
setSearchParams({
[IS_METRIC_DETAILS_OPEN_KEY]: 'true',
[SELECTED_METRIC_NAME_KEY]: metricName,
});
};
const closeMetricDetails = (): void => {
setSelectedMetricName(null);
setIsMetricDetailsOpen(false);
setSearchParams({
[IS_METRIC_DETAILS_OPEN_KEY]: 'false',
[SELECTED_METRIC_NAME_KEY]: '',
});
};
const openInspectModal = (metricName: string): void => {
setSelectedMetricName(metricName);
setIsInspectModalOpen(true);
setIsMetricDetailsOpen(false);
setSearchParams({
[IS_INSPECT_MODAL_OPEN_KEY]: 'true',
[SELECTED_METRIC_NAME_KEY]: metricName,
});
};
const closeInspectModal = (): void => {
@ -204,23 +256,23 @@ function Summary(): JSX.Element {
});
setIsInspectModalOpen(false);
setSelectedMetricName(null);
setSearchParams({
[IS_INSPECT_MODAL_OPEN_KEY]: 'false',
[SELECTED_METRIC_NAME_KEY]: '',
});
};
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab">
<MetricsSearch
query={searchQuery}
onChange={handleFilterChange}
heatmapView={heatmapView}
setHeatmapView={setHeatmapView}
/>
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
<MetricsTreemap
data={treeMapData?.payload}
isLoading={isTreeMapLoading || isTreeMapFetching}
isError={isProportionViewError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={setHeatmapView}
/>
<MetricsTable
isLoading={isMetricsLoading || isMetricsFetching}

View File

@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
@ -28,7 +29,23 @@ const mockData: MetricsListItemRowData[] = [
},
];
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useSearchParams: jest.fn().mockReturnValue([{}, jest.fn()]),
useNavigationType: (): any => 'PUSH',
};
});
describe('MetricsTable', () => {
beforeEach(() => {
jest
.spyOn(useQueryBuilderOperationsHooks, 'useQueryOperations')
.mockReturnValue({
handleChangeQueryData: jest.fn(),
} as any);
});
jest
.spyOn(useGetMetricsListFilterValues, 'useGetMetricsListFilterValues')
.mockReturnValue({

View File

@ -55,6 +55,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@ -79,6 +80,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@ -105,6 +107,7 @@ describe('MetricsTreemap', () => {
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@ -128,6 +131,7 @@ describe('MetricsTreemap', () => {
data={null}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
</MemoryRouter>,

View File

@ -0,0 +1,150 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import store from 'store';
import Summary from '../Summary';
import { TreemapViewType } from '../types';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
id: jest.fn().mockReturnValue({
parentId: jest.fn().mockReturnValue(
jest.fn().mockReturnValue({
sum: jest.fn().mockReturnValue({
descendants: jest.fn().mockReturnValue([]),
eachBefore: jest.fn().mockReturnValue([]),
}),
}),
),
}),
}),
treemapBinary: jest.fn(),
}));
jest.mock('react-use', () => ({
useWindowSize: jest.fn().mockReturnValue({ width: 1000, height: 1000 }),
}));
jest.mock('react-router-dom-v5-compat', () => {
const actual = jest.requireActual('react-router-dom-v5-compat');
return {
...actual,
useSearchParams: jest.fn(),
useNavigationType: (): any => 'PUSH',
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
const queryClient = new QueryClient();
const mockMetricName = 'test-metric';
jest.spyOn(useGetMetricsListHooks, 'useGetMetricsList').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
metrics: [
{
metric_name: mockMetricName,
description: 'description for a test metric',
type: MetricType.GAUGE,
unit: 'count',
lastReceived: '1715702400',
[TreemapViewType.TIMESERIES]: 100,
[TreemapViewType.SAMPLES]: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
jest.spyOn(useGetMetricsTreeMapHooks, 'useGetMetricsTreeMap').mockReturnValue({
data: {
payload: {
status: 'success',
data: {
[TreemapViewType.TIMESERIES]: [
{
metric_name: mockMetricName,
percentage: 100,
total_value: 100,
},
],
[TreemapViewType.SAMPLES]: [
{
metric_name: mockMetricName,
percentage: 100,
},
],
},
},
},
isError: false,
isLoading: false,
} as any);
const mockSetSearchParams = jest.fn();
describe('Summary', () => {
it('persists inspect modal open state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isInspectModalOpen: 'true',
selectedMetricName: 'test-metric',
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
it('persists metric details modal state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isMetricDetailsOpen: 'true',
selectedMetricName: mockMetricName,
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
});

View File

@ -32,3 +32,8 @@ export const METRIC_TYPE_VALUES_MAP = {
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
};
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';

View File

@ -20,8 +20,6 @@ export interface MetricsTableProps {
export interface MetricsSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
heatmapView: TreemapViewType;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface MetricsTreemapProps {
@ -30,6 +28,7 @@ export interface MetricsTreemapProps {
isError: boolean;
viewType: TreemapViewType;
openMetricDetails: (metricName: string) => void;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface OrderByPayload {