Compare commits

..

12 Commits

Author SHA1 Message Date
Abhi kumar
91ee0fb06f Merge branch 'main' into enh/external-api-charts 2026-03-03 15:12:59 +05:30
Abhi Kumar
6a138fa422 fix: tests 2026-03-03 15:10:10 +05:30
Srikanth Chekuri
1d967fadac chore(metrics-explorer): handle errors properly (#10474) 2026-03-03 09:27:09 +00:00
Abhi Kumar
28b21f6c48 Merge branch 'main' of https://github.com/SigNoz/signoz into enh/external-api-charts 2026-03-03 14:25:32 +05:30
Abhi Kumar
7974f4fb08 feat: replaced external apis barchart with the new bar chart 2026-03-01 19:16:45 +05:30
Abhi kumar
f11153cdec Merge branch 'main' into chore/uplot-builder-restructure 2026-02-28 18:55:41 +05:30
Abhi Kumar
a380c4d6be chore: fixed tsc + test 2026-02-28 18:08:39 +05:30
Abhi Kumar
a0f576e5fb chore: fixed tsc + test 2026-02-28 17:53:44 +05:30
Abhi Kumar
98013ee3b9 chore: fixed tsc + test 2026-02-28 15:51:58 +05:30
Abhi Kumar
97b94fc4f5 chore: updated timezone types 2026-02-28 15:20:24 +05:30
Abhi Kumar
0f3f49b96a chore: updated baseconfigbuilder test 2026-02-28 15:15:21 +05:30
Abhi Kumar
0928a30863 chore: made baseconfigbuilder generic to be used across different charts 2026-02-28 15:10:16 +05:30
29 changed files with 716 additions and 611 deletions

View File

@@ -2,7 +2,7 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
// Handles errors from generated API hooks (which use RenderErrorResponseDTO)
// @deprecated Use convertToApiError instead
export function ErrorResponseHandlerForGeneratedAPIs(
error: AxiosError<RenderErrorResponseDTO>,
): never {
@@ -46,3 +46,34 @@ export function ErrorResponseHandlerForGeneratedAPIs(
},
});
}
// convertToApiError converts an AxiosError from generated API
// hooks into an APIError.
export function convertToApiError(
error: AxiosError<RenderErrorResponseDTO> | null,
): APIError | undefined {
if (!error) {
return undefined;
}
const response = error.response;
const errorData = response?.data?.error;
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
message:
errorData?.message ||
response?.statusText ||
error.message ||
'Something went wrong',
url: errorData?.url ?? '',
errors: (errorData?.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
},
});
}

View File

@@ -1,54 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { TreemapViewType } from 'container/MetricsExplorer/Summary/types';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTreeMapPayload {
filters: TagFilter;
limit?: number;
treemap?: TreemapViewType;
}
export interface MetricsTreeMapResponse {
status: string;
data: {
[TreemapViewType.TIMESERIES]: TimeseriesData[];
[TreemapViewType.SAMPLES]: SamplesData[];
};
}
export interface TimeseriesData {
percentage: number;
total_value: number;
metric_name: string;
}
export interface SamplesData {
percentage: number;
metric_name: string;
}
export const getMetricsTreeMap = async (
props: MetricsTreeMapPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<MetricsTreeMapResponse> | ErrorResponse> => {
try {
const response = await axios.post('/metrics/treemap', props, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
params: props,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,36 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Temporality } from './getMetricDetails';
import { MetricType } from './getMetricsList';
export interface UpdateMetricMetadataProps {
description: string;
metricType: MetricType;
temporality?: Temporality;
isMonotonic?: boolean;
unit?: string;
}
export interface UpdateMetricMetadataResponse {
success: boolean;
message: string;
}
const updateMetricMetadata = async (
metricName: string,
props: UpdateMetricMetadataProps,
): Promise<SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse> => {
const response = await axios.post(`/metrics/${metricName}/metadata`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateMetricMetadata;

View File

@@ -49,7 +49,6 @@ export const REACT_QUERY_KEY = {
// Metrics Explorer Query Keys
GET_METRICS_LIST: 'GET_METRICS_LIST',
GET_METRICS_TREE_MAP: 'GET_METRICS_TREE_MAP',
GET_METRICS_LIST_FILTER_KEYS: 'GET_METRICS_LIST_FILTER_KEYS',
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',

View File

@@ -3,16 +3,14 @@ import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getCustomFiltersForBarChart,
getFormattedEndPointStatusCodeChartData,
getStatusCodeBarChartWidgetData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
@@ -20,15 +18,16 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useNotifications } from 'hooks/useNotifications';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
import { prepareStatusCodeBarChartsConfig } from './utils';
function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
@@ -67,13 +66,6 @@ function StatusCodeBarCharts({
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { startTime: minTime, endTime: maxTime } = timeRange;
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -119,6 +111,7 @@ function StatusCodeBarCharts({
const navigateToExplorer = useNavigateToExplorer();
const { currentQuery } = useQueryBuilder();
const { timezone } = useTimezone();
const navigateToExplorerPages = useNavigateToExplorerPages();
const { notifications } = useNotifications();
@@ -134,12 +127,6 @@ function StatusCodeBarCharts({
[],
);
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping,
});
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
@@ -193,49 +180,36 @@ function StatusCodeBarCharts({
],
);
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse:
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
isDarkMode,
dimensions,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: minTime,
maxTimeScale: maxTime,
panelType: PANEL_TYPES.BAR,
onClickHandler: graphClickHandler,
customSeries: getCustomSeries,
onDragSelect,
colorMapping,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
minTime,
maxTime,
currentWidgetInfoIndex,
dimensions,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
const config = useMemo(() => {
const apiResponse =
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
return prepareStatusCodeBarChartsConfig({
timezone,
isDarkMode,
graphClickHandler,
getCustomSeries,
query: currentQuery,
onDragSelect,
onClick: graphClickHandler,
apiResponse,
minTimeScale: minTime,
maxTimeScale: maxTime,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
colorMapping,
currentQuery,
],
);
});
}, [
currentQuery,
isDarkMode,
minTime,
maxTime,
graphClickHandler,
onDragSelect,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
timezone,
currentWidgetInfoIndex,
colorMapping,
]);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
@@ -253,11 +227,20 @@ function StatusCodeBarCharts({
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
/>
</div>
);
},
[options, chartData],
[config, chartData, dimensions, timezone],
);
return (

View File

@@ -0,0 +1,83 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { v4 as uuid } from 'uuid';
export const prepareStatusCodeBarChartsConfig = ({
timezone,
isDarkMode,
query,
onDragSelect,
onClick,
apiResponse,
minTimeScale,
maxTimeScale,
yAxisUnit,
colorMapping,
}: {
timezone: Timezone;
isDarkMode: boolean;
query: Query;
onDragSelect: (startTime: number, endTime: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
apiResponse: MetricRangePayloadProps;
yAxisUnit?: string;
colorMapping?: Record<string, string>;
}): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const minStepInterval = Math.min(...Object.values(stepIntervals));
const config = buildBaseConfig({
id: uuid(),
yAxisUnit: yAxisUnit,
apiResponse,
isDarkMode,
onDragSelect,
timezone,
onClick,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
panelType: PANEL_TYPES.BAR,
});
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
config.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label: label,
colorMapping: colorMapping ?? {},
isDarkMode,
stepInterval: currentStepInterval,
});
});
return config;
};

View File

@@ -21,10 +21,15 @@ interface MockQueryResult {
}
// Mocks
jest.mock('components/Uplot', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -70,6 +75,24 @@ jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
timezone: {
name: string;
value: string;
offset: string;
searchIndex: string;
};
} => ({
timezone: {
name: 'UTC',
value: 'UTC',
offset: '+00:00',
searchIndex: 'UTC',
},
}),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
}));
@@ -319,7 +342,7 @@ describe('StatusCodeBarCharts', () => {
mockData.payload,
'sum',
);
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
expect(screen.getByText('Number of calls')).toBeInTheDocument();
expect(screen.getByText('Latency')).toBeInTheDocument();
});

View File

@@ -179,19 +179,6 @@
&__input.description {
color: var(--text-ink-300);
}
.ant-btn {
color: var(--text-ink-400);
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
&:hover,
&:focus {
color: var(--text-ink-500);
background: var(--bg-vanilla-200);
border-color: var(--bg-vanilla-400);
}
}
}
.edit-alert-header {

View File

@@ -15,7 +15,7 @@ export const getRandomColor = (): string => {
};
export const DATASOURCE_VS_ROUTES: Record<DataSource, string> = {
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER,
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER_EXPLORER,
[DataSource.TRACES]: ROUTES.TRACES_EXPLORER,
[DataSource.LOGS]: ROUTES.LOGS_EXPLORER,
};

View File

@@ -12,14 +12,21 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import { v4 as uuid } from 'uuid';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
@@ -42,15 +49,20 @@ function Explorer(): JSX.Element {
stagedQuery,
updateAllQueriesOperators,
currentQuery,
handleSetConfig,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const metricNames = useMemo(() => {
const currentMetricNames: string[] = [];
stagedQuery?.builder.queryData.forEach((query) => {
if (query.aggregateAttribute?.key) {
currentMetricNames.push(query.aggregateAttribute?.key);
const metricName =
query.aggregateAttribute?.key ||
(query.aggregations?.[0] as MetricAggregation | undefined)?.metricName;
if (metricName) {
currentMetricNames.push(metricName);
}
});
return currentMetricNames;
@@ -176,6 +188,16 @@ function Explorer(): JSX.Element {
useShareBuilderUrl({ defaultValue: defaultQuery });
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
const nextPanelType =
explorerViewToPanelType[view] || PANEL_TYPES.TIME_SERIES;
handleSetConfig(nextPanelType, DataSource.METRICS);
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange],
);
const handleExport = useCallback(
(
dashboard: Dashboard | null,
@@ -348,6 +370,7 @@ function Explorer(): JSX.Element {
onExport={handleExport}
isOneChartPerQuery={showOneChartPerQuery}
splitedQueries={splitedQueries}
handleChangeSelectedView={handleChangeSelectedView}
/>
{isMetricDetailsOpen && selectedMetricName && (
<MetricDetails

View File

@@ -12,6 +12,7 @@ import { initialQueriesMap } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import * as timezoneHooks from 'providers/Timezone';
@@ -29,6 +30,8 @@ const queryClient = new QueryClient();
const mockUpdateAllQueriesOperators = jest
.fn()
.mockReturnValue(initialQueriesMap[DataSource.METRICS]);
const mockHandleSetConfig = jest.fn();
const mockHandleExplorerTabChange = jest.fn();
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
@@ -40,7 +43,7 @@ const mockUseQueryBuilderData = {
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
handleSetConfig: mockHandleSetConfig,
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
@@ -135,6 +138,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
jest
.spyOn(useHandleExplorerTabChangeHooks, 'useHandleExplorerTabChange')
.mockReturnValue({
handleExplorerTabChange: mockHandleExplorerTabChange,
});
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
@@ -382,4 +390,109 @@ describe('Explorer', () => {
expect(oneChartPerQueryToggle).toBeEnabled();
expect(oneChartPerQueryToggle).not.toBeChecked();
});
describe('loading saved views with v5 query format', () => {
const EMPTY_STATE_TEXT = 'Select a metric and run a query to see the results';
it('should show empty state when no metric is selected', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [],
});
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
renderExplorer();
expect(screen.getByText(EMPTY_STATE_TEXT)).toBeInTheDocument();
});
it('should not show empty state when saved view has v5 aggregations format', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA],
});
// saved view loaded back from v5 format
// aggregateAttribute.key is empty (lost in v3/v4 -> v5 -> v3/v4 round trip)
// but aggregations[0].metricName has metric name
// TODO(srikanthccv): remove this mess
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: '',
},
aggregations: [
{
metricName: 'http_requests_total',
temporality: 'cumulative',
timeAggregation: 'rate',
spaceAggregation: 'sum',
},
],
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData],
},
},
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderExplorer();
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
});
it('should not show empty state when query uses v3 aggregateAttribute format', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({}),
mockSetSearchParams,
]);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA],
});
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'system_cpu_usage',
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData],
},
},
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
renderExplorer();
expect(screen.queryByText(EMPTY_STATE_TEXT)).not.toBeInTheDocument();
});
});
});

View File

@@ -9,7 +9,6 @@ import {
} from 'api/generated/services/metrics';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
@@ -18,8 +17,6 @@ import { DashboardsAndAlertsPopoverProps } from './types';
function DashboardsAndAlertsPopover({
metricName,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const params = useUrlQuery();
const {
data: alertsData,
isLoading: isLoadingAlerts,
@@ -71,8 +68,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={alert.alertId}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alertId);
window.open(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`, '_blank');
window.open(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
'_blank',
);
}}
className="dashboards-popover-content-item"
>
@@ -82,7 +81,7 @@ function DashboardsAndAlertsPopover({
}));
}
return null;
}, [alerts, params]);
}, [alerts]);
const dashboardsPopoverContent = useMemo(() => {
if (dashboards && dashboards.length > 0) {

View File

@@ -16,15 +16,6 @@ import {
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', { value: mockWindowOpen });
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
toString: jest.fn(),
};
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => mockUrlQuery),
}));
const useGetMetricAlertsMock = jest.spyOn(
metricsExplorerHooks,
@@ -156,12 +147,10 @@ describe('DashboardsAndAlertsPopover', () => {
// Click on the first alert rule
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
// Should open alert in new tab
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
MOCK_ALERT_1.alertId,
expect(mockWindowOpen).toHaveBeenCalledWith(
`/alerts/overview?${QueryParams.ruleId}=${MOCK_ALERT_1.alertId}`,
'_blank',
);
expect(mockWindowOpen).toHaveBeenCalled();
});
it('renders unique dashboards even when there are duplicates', async () => {

View File

@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_LABEL_MAP } from './constants';
// TODO: @amlannandy Delete this component after API migration is complete
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
const metricTypeRendererStyle = useMemo(
() => ({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}),
[color],
);
const metricTypeRendererTextStyle = useMemo(
() => ({
color,
fontSize: 12,
}),
[color],
);
return (
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
{icon}
<Typography.Text style={metricTypeRendererTextStyle}>
{METRIC_TYPE_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeRenderer;

View File

@@ -47,7 +47,7 @@ function MetricsSearch({
}}
onRun={handleRunQuery}
showFilterSuggestionsWithoutMetric
placeholder="Try metric_name CONTAINS 'http.server' to view all HTTP Server metrics being sent"
placeholder="Search your metrics. Try service.name='api' to see all API service metrics, or http.client for HTTP client metrics."
/>
</div>
<RunQueryBtn

View File

@@ -10,6 +10,7 @@ import {
} from 'antd';
import { SorterResult } from 'antd/es/table/interface';
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { Info } from 'lucide-react';
import { MetricsListItemRowData, MetricsTableProps } from './types';
@@ -18,6 +19,7 @@ import { getMetricsTableColumns } from './utils';
function MetricsTable({
isLoading,
isError,
error,
data,
pageSize,
currentPage,
@@ -71,54 +73,54 @@ function MetricsTable({
<Info size={16} />
</Tooltip>
</div>
<Table
loading={{
spinning: isLoading,
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<LoadingOutlined size={14} spin />}
/>
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{
emptyText: isLoading ? null : (
<div
className="no-metrics-message-container"
data-testid={
isError ? 'metrics-table-error-state' : 'metrics-table-empty-state'
}
>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
{isError && error ? (
<ErrorInPlace error={error} />
) : (
<Table
loading={{
spinning: isLoading,
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<LoadingOutlined size={14} spin />}
/>
<Typography.Text className="no-metrics-message">
{isError
? 'Error fetching metrics. If the problem persists, please contact support.'
: 'This query had no results. Edit your query and try again!'}
</Typography.Text>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
total: totalCount,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => openMetricDetails(record.key, 'list'),
className: 'clickable-row',
})}
/>
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{
emptyText: isLoading ? null : (
<div
className="no-metrics-message-container"
data-testid="metrics-table-empty-state"
>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-metrics-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
total: totalCount,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => openMetricDetails(record.key, 'list'),
className: 'clickable-row',
})}
/>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
@@ -27,6 +28,7 @@ import {
function MetricsTreemapInternal({
isLoading,
isError,
error,
data,
viewType,
openMetricDetails,
@@ -91,6 +93,10 @@ function MetricsTreemapInternal({
);
}
if (isError && error) {
return <ErrorInPlace error={error} />;
}
if (isError) {
return (
<Empty
@@ -174,6 +180,7 @@ function MetricsTreemap({
data,
isLoading,
isError,
error,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
@@ -202,6 +209,7 @@ function MetricsTreemap({
<MetricsTreemapInternal
isLoading={isLoading}
isError={isError}
error={error}
data={data}
viewType={viewType}
openMetricDetails={openMetricDetails}

View File

@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
useGetMetricsStats,
useGetMetricsTreemap,
@@ -63,13 +64,20 @@ function Summary(): JSX.Element {
MetricsexplorertypesTreemapModeDTO.samples,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const {
currentQuery,
stagedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
currentQuery,
]);
const query = useMemo(
() =>
stagedQuery?.builder?.queryData?.[0] ||
initialQueriesMap[DataSource.METRICS].builder.queryData[0],
[stagedQuery],
);
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
@@ -86,14 +94,16 @@ function Summary(): JSX.Element {
(state) => state.globalTime,
);
const appliedFilterExpression = query?.filter?.expression || '';
const [
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
] = useState<string>(query?.filter?.expression || '');
] = useState<string>(appliedFilterExpression);
const [appliedFilterExpression, setAppliedFilterExpression] = useState(
query?.filter?.expression || '',
);
useEffect(() => {
setCurrentQueryFilterExpression(appliedFilterExpression);
}, [appliedFilterExpression]);
const queryFilterExpression = useMemo(
() => ({ expression: appliedFilterExpression }),
@@ -150,6 +160,7 @@ function Summary(): JSX.Element {
mutate: getMetricsStats,
isLoading: isGetMetricsStatsLoading,
isError: isGetMetricsStatsError,
error: metricsStatsError,
} = useGetMetricsStats();
const {
@@ -157,8 +168,19 @@ function Summary(): JSX.Element {
mutate: getMetricsTreemap,
isLoading: isGetMetricsTreemapLoading,
isError: isGetMetricsTreemapError,
error: metricsTreemapError,
} = useGetMetricsTreemap();
const metricsStatsApiError = useMemo(
() => convertToApiError(metricsStatsError),
[metricsStatsError],
);
const metricsTreemapApiError = useMemo(
() => convertToApiError(metricsTreemapError),
[metricsTreemapError],
);
useEffect(() => {
getMetricsStats({
data: metricsListQuery,
@@ -192,8 +214,6 @@ function Summary(): JSX.Element {
],
},
});
setCurrentQueryFilterExpression(expression);
setAppliedFilterExpression(expression);
setCurrentPage(1);
if (expression) {
logEvent(MetricsExplorerEvents.FilterApplied, {
@@ -290,10 +310,14 @@ function Summary(): JSX.Element {
};
const isMetricsListDataEmpty =
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
formattedMetricsData.length === 0 &&
!isGetMetricsStatsLoading &&
!isGetMetricsStatsError;
const isMetricsTreeMapDataEmpty =
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
!treeMapData?.data[heatmapView]?.length &&
!isGetMetricsTreemapLoading &&
!isGetMetricsTreemapError;
const showFullScreenLoading =
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
@@ -322,6 +346,7 @@ function Summary(): JSX.Element {
data={treeMapData?.data}
isLoading={isGetMetricsTreemapLoading}
isError={isGetMetricsTreemapError}
error={metricsTreemapApiError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView}
@@ -329,6 +354,7 @@ function Summary(): JSX.Element {
<MetricsTable
isLoading={isGetMetricsStatsLoading}
isError={isGetMetricsStatsError}
error={metricsStatsApiError}
data={formattedMetricsData}
pageSize={pageSize}
currentPage={currentPage}

View File

@@ -1,63 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import MetricTypeRenderer from '../MetricTypeRenderer';
jest.mock('lucide-react', () => {
return {
__esModule: true,
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
BarChartHorizontal: (): JSX.Element => (
<svg data-testid="bar-chart-horizontal-icon" />
),
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
};
});
describe('MetricTypeRenderer', () => {
it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
iconTestId: 'diff-icon',
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
iconTestId: 'gauge-icon',
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
iconTestId: 'bar-chart-2-icon',
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
iconTestId: 'bar-chart-horizontal-icon',
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
iconTestId: 'bar-chart-icon',
},
];
types.forEach(({ type, color, iconTestId }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
});
});
});

View File

@@ -6,6 +6,7 @@ import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store';
import APIError from 'types/api/error';
import MetricsTable from '../MetricsTable';
import { MetricsListItemRowData } from '../types';
@@ -119,12 +120,23 @@ describe('MetricsTable', () => {
});
it('shows error state', () => {
const mockError = new APIError({
httpStatusCode: 400,
error: {
code: '400',
message: 'invalid filter expression',
url: '',
errors: [],
},
});
render(
<MemoryRouter>
<Provider store={store}>
<MetricsTable
isLoading={false}
isError
error={mockError}
data={[]}
pageSize={10}
currentPage={1}
@@ -139,12 +151,8 @@ describe('MetricsTable', () => {
</MemoryRouter>,
);
expect(screen.getByTestId('metrics-table-error-state')).toBeInTheDocument();
expect(
screen.getByText(
'Error fetching metrics. If the problem persists, please contact support.',
),
).toBeInTheDocument();
expect(screen.getByText('400')).toBeInTheDocument();
expect(screen.getByText('invalid filter expression')).toBeInTheDocument();
});
it('shows empty state when no data', () => {

View File

@@ -1,16 +1,12 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as metricsHooks from 'api/generated/services/metrics';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import store from 'store';
import { render, screen } from 'tests/test-utils';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import { render, screen, waitFor } from 'tests/test-utils';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import Summary from '../Summary';
import { TreemapViewType } from '../types';
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
@@ -44,58 +40,135 @@ jest.mock('react-router-dom', () => ({
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
}),
}));
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
// so filter expression assertions easy
jest.mock('../MetricsSearch', () => {
return function MockMetricsSearch(props: {
currentQueryFilterExpression: string;
}): JSX.Element {
return (
<div data-testid="metrics-search-expression">
{props.currentQueryFilterExpression}
</div>
);
};
});
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();
const mockGetMetricsStats = jest.fn();
const mockGetMetricsTreemap = jest.fn();
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
updateAllQueriesOperators: jest.fn(),
currentQuery: initialQueriesMap[DataSource.METRICS],
resetQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(),
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
};
const useGetMetricsStatsSpy = jest.spyOn(metricsHooks, 'useGetMetricsStats');
const useGetMetricsTreemapSpy = jest.spyOn(
metricsHooks,
'useGetMetricsTreemap',
);
const useQueryBuilderSpy = jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder');
describe('Summary', () => {
beforeEach(() => {
jest.clearAllMocks();
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams(),
mockSetSearchParams,
]);
useGetMetricsStatsSpy.mockReturnValue({
data: null,
mutate: mockGetMetricsStats,
isLoading: true,
isError: false,
error: null,
isIdle: true,
isSuccess: false,
reset: jest.fn(),
status: 'idle',
} as any);
useGetMetricsTreemapSpy.mockReturnValue({
data: null,
mutate: mockGetMetricsTreemap,
isLoading: true,
isError: false,
error: null,
isIdle: true,
isSuccess: false,
reset: jest.fn(),
status: 'idle',
} as any);
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
});
it('does not carry filter expression from a previous page', async () => {
const staleFilterExpression = "service.name = 'redis'";
// prev filter from logs explorer
const staleQuery = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
filter: { expression: staleFilterExpression },
},
],
},
};
// stagedQuery has stale filter (before QueryBuilder resets it)
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: staleQuery,
currentQuery: staleQuery,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
const { rerender } = render(<Summary />);
expect(screen.getByTestId('metrics-search-expression')).toHaveTextContent(
staleFilterExpression,
);
// QB route change effect resets stagedQuery to null
useQueryBuilderSpy.mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: null,
currentQuery: initialQueriesMap[DataSource.METRICS],
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
rerender(<Summary />);
await waitFor(() => {
expect(
screen.getByTestId('metrics-search-expression'),
).toBeEmptyDOMElement();
});
});
it('persists inspect modal open state across page refresh', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
@@ -105,13 +178,7 @@ describe('Summary', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
render(<Summary />);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});
@@ -120,18 +187,12 @@ describe('Summary', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({
isMetricDetailsOpen: 'true',
selectedMetricName: mockMetricName,
selectedMetricName: 'test-metric',
}),
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Summary />
</Provider>
</QueryClientProvider>,
);
render(<Summary />);
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
});

View File

@@ -2,7 +2,6 @@ import {
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
export const METRICS_TABLE_PAGE_SIZE = 10;
@@ -19,15 +18,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
// TODO: Remove this once API migration is complete
export const METRIC_TYPE_LABEL_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
[MetricType.HISTOGRAM]: 'Histogram',
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
};
export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
@@ -36,15 +26,6 @@ export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
};
// TODO(@amlannandy): To remove this once API migration is complete
export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
[MetricType.HISTOGRAM]: 'Histogram',
[MetricType.SUMMARY]: 'Summary',
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
};
export const METRIC_TYPE_VIEW_VALUES_MAP: Record<MetrictypesTypeDTO, string> = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',

View File

@@ -5,11 +5,13 @@ import {
Querybuildertypesv5OrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import APIError from 'types/api/error';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTableProps {
isLoading: boolean;
isError: boolean;
error?: APIError;
data: MetricsListItemRowData[];
pageSize: number;
currentPage: number;
@@ -33,6 +35,7 @@ export interface MetricsTreemapProps {
data: MetricsexplorertypesTreemapResponseDTO | undefined;
isLoading: boolean;
isError: boolean;
error?: APIError;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
@@ -41,6 +44,7 @@ export interface MetricsTreemapProps {
export interface MetricsTreemapInternalProps {
isLoading: boolean;
isError: boolean;
error?: APIError;
data: MetricsexplorertypesTreemapResponseDTO | undefined;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;

View File

@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
@@ -221,6 +227,108 @@ describe('MetricNameSelector', () => {
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
});
it('preserves metric search text for signalSource normalization transition (undefined -> empty)', async () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
const query = makeQuery({
aggregateAttribute: {
key: 'http_requests_total',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: 'http_requests_total',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const { rerender } = render(
<MetricNameSelector
query={query}
onChange={jest.fn()}
signalSource={undefined}
/>,
);
rerender(
<MetricNameSelector query={query} onChange={jest.fn()} signalSource="" />,
);
await waitFor(() => {
const lastCall =
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
expect(lastCall?.[0]).toMatchObject({
searchText: 'http_requests_total',
limit: 100,
});
});
});
it('updates search text when metric name is hydrated after initial mount', async () => {
returnMetrics([makeMetric({ metricName: 'signoz_latency.bucket' })]);
const emptyQuery = makeQuery({
aggregateAttribute: {
key: '',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: '',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const hydratedQuery = makeQuery({
aggregateAttribute: {
key: '',
type: '',
dataType: DataTypes.Float64,
},
aggregations: [
{
metricName: 'signoz_latency.bucket',
timeAggregation: 'rate',
spaceAggregation: 'sum',
temporality: '',
},
] as MetricAggregation[],
});
const { rerender } = render(
<MetricNameSelector
query={emptyQuery}
onChange={jest.fn()}
signalSource=""
/>,
);
rerender(
<MetricNameSelector
query={hydratedQuery}
onChange={jest.fn()}
signalSource=""
/>,
);
await waitFor(() => {
const lastCall =
mockUseListMetrics.mock.calls[mockUseListMetrics.mock.calls.length - 1];
expect(lastCall?.[0]).toMatchObject({
searchText: 'signoz_latency.bucket',
limit: 100,
});
});
});
});
describe('selecting a metric type updates the aggregation options', () => {

View File

@@ -98,15 +98,30 @@ export const MetricNameSelector = memo(function MetricNameSelector({
useEffect(() => {
setInputValue(currentMetricName || defaultValue || '');
if (currentMetricName) {
setSearchText(currentMetricName);
}
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
const previousSignalSource = prevSignalSourceRef.current;
prevSignalSourceRef.current = signalSource;
const isNormalizationTransition =
(previousSignalSource === undefined && signalSource === '') ||
(previousSignalSource === '' && signalSource === undefined);
if (isNormalizationTransition && currentMetricName) {
setSearchText(currentMetricName);
setInputValue(currentMetricName || defaultValue || '');
return;
}
setSearchText('');
setInputValue('');
}
}, [signalSource]);
}, [signalSource, currentMetricName, defaultValue]);
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
@@ -152,7 +167,9 @@ export const MetricNameSelector = memo(function MetricNameSelector({
}, [metrics]);
useEffect(() => {
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
const metricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
@@ -164,7 +181,13 @@ export const MetricNameSelector = memo(function MetricNameSelector({
);
}
}
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
}, [
metrics,
query.aggregations,
query.aggregateAttribute?.key,
query.aggregateAttribute?.type,
onChange,
]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getMetricsTreeMap,
MetricsTreeMapPayload,
MetricsTreeMapResponse,
} from 'api/metricsExplorer/getMetricsTreeMap';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
type UseGetMetricsTreeMap = (
requestData: MetricsTreeMapPayload,
options?: UseQueryOptions<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>;
export const useGetMetricsTreeMap: UseGetMetricsTreeMap = (
requestData,
options,
headers,
) => {
const queryKey = useMemo(() => {
if (options?.queryKey && Array.isArray(options.queryKey)) {
return [...options.queryKey];
}
if (options?.queryKey && typeof options.queryKey === 'string') {
return options.queryKey;
}
return [REACT_QUERY_KEY.GET_METRICS_TREE_MAP, requestData];
}, [options?.queryKey, requestData]);
return useQuery<
SuccessResponse<MetricsTreeMapResponse> | ErrorResponse,
Error
>({
queryFn: ({ signal }) => getMetricsTreeMap(requestData, signal, headers),
...options,
queryKey,
});
};

View File

@@ -1,26 +0,0 @@
import { useMutation, UseMutationResult } from 'react-query';
import updateMetricMetadata, {
UpdateMetricMetadataProps,
UpdateMetricMetadataResponse,
} from 'api/metricsExplorer/updateMetricMetadata';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface UseUpdateMetricMetadataProps {
metricName: string;
payload: UpdateMetricMetadataProps;
}
export function useUpdateMetricMetadata(): UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
> {
return useMutation<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>({
mutationFn: ({ metricName, payload }) =>
updateMetricMetadata(metricName, payload),
});
}

View File

@@ -252,23 +252,4 @@
}
}
}
.alert-details-v2 {
.tabs-and-filters {
.ant-tabs {
.ant-tabs-tab {
.ant-tabs-tab-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-400) !important;
&[aria-selected='true'] {
background: var(--bg-vanilla-200);
color: var(--text-robin-500) !important;
}
}
}
}
}
}
}

View File

@@ -24,25 +24,3 @@
}
}
}
.lightMode {
.service-route-tab {
.ant-tabs-nav {
&::before {
border-bottom: 1px solid var(--bg-vanilla-300);
}
.ant-tabs-nav-wrap {
.ant-tabs-nav-list {
.ant-tabs-tab {
color: var(--text-ink-400);
}
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--text-robin-500);
}
}
}
}
}
}