Compare commits

..

2 Commits

Author SHA1 Message Date
srikanthccv
c21e463e55 chore: fix ci 2026-03-01 16:12:16 +05:30
srikanthccv
2091e63195 fix(metrics-explorer): address several bugs in explorer tab 2026-03-01 15:44:30 +05:30
36 changed files with 1673 additions and 317 deletions

View File

@@ -5459,6 +5459,10 @@ paths:
name: searchText
schema:
type: string
- in: query
name: source
schema:
type: string
responses:
"200":
content:

View File

@@ -3231,6 +3231,11 @@ export type ListMetricsParams = {
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {

View File

@@ -60,11 +60,30 @@
gap: 8px;
margin-left: 108px;
position: relative;
/* Vertical dashed line connecting query elements */
&::after {
content: '';
position: absolute;
left: -28px;
top: 0;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content {
.metrics-aggregation-section-content,
.metrics-container {
position: relative;
&::before {
@@ -102,6 +121,10 @@
.qb-elements-container {
margin-left: 0px;
&::after {
display: none;
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
@@ -333,28 +356,7 @@
text-transform: uppercase;
&::before {
content: '';
height: 120px;
content: '';
position: absolute;
left: 0;
top: 31px;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
left: 15px;
}
&.has-trace-operator {
&::before {
height: 0px;
}
display: none;
}
}
@@ -462,10 +464,21 @@
.qb-content-section {
.qb-elements-container {
&::after {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content {
.metrics-aggregation-section-content,
.metrics-container {
&::before {
border-left: 6px dotted var(--bg-vanilla-300);
}
@@ -529,18 +542,6 @@
.qb-entity-options {
.options {
.query-name {
&::before {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
}
.formula-name {
&::before {
background: repeating-linear-gradient(

View File

@@ -207,6 +207,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}

View File

@@ -1,14 +1,13 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
@@ -44,21 +43,12 @@ export const MetricsSelect = memo(function MetricsSelect({
signalSourceChangeEnabled: boolean;
savePreviousQuery: boolean;
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
const {
updateAllQueriesOperators,
handleSetQueryData,
@@ -164,12 +154,10 @@ export const MetricsSelect = memo(function MetricsSelect({
/>
)}
<AggregatorFilter
onChange={handleAggregatorAttributeChange}
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

View File

@@ -202,8 +202,8 @@ function QueryAddOns({
} else {
filteredAddOns = Object.values(ADD_ONS);
// Filter out group_by for metrics data source
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);

View File

@@ -43,6 +43,7 @@ jest.mock(
);
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: (): JSX.Element => <div />,
MetricNameSelector: (): JSX.Element => <div />,
}));
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');

View File

@@ -177,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -225,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MeterAggregateOperator.COUNT,
timeAggregation: MeterAggregateOperator.AVG,
spaceAggregation: MeterAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},

View File

@@ -441,7 +441,7 @@ describe('Footer utils', () => {
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
timeAggregation: 'avg',
},
],
disabled: false,

View File

@@ -6,6 +6,7 @@ import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
@@ -115,27 +116,34 @@ function TimeSeries(): JSX.Element {
setYAxisUnit(value);
};
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
return (
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="time-series-container">
{responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
{!hasMetricSelected && <EmptyMetricsSearch />}
{hasMetricSelected &&
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,13 +1,21 @@
import { Typography } from 'antd';
import { Empty } from 'antd/lib';
export default function EmptyMetricsSearch(): JSX.Element {
interface EmptyMetricsSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMetricsSearch({
hasQueryResult,
}: EmptyMetricsSearchProps): JSX.Element {
return (
<div className="empty-metrics-search">
<Empty
description={
<Typography.Title level={5}>
Please build and run a valid query to see the result
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>

View File

@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit && unit === units[0]),
units.every((unit) => unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);

View File

@@ -28,6 +28,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import EmptyMetricsSearch from './EmptyMetricsSearch';
import { TimeSeriesProps } from './types';
import {
buildUpdateMetricYAxisUnitPayload,
@@ -209,7 +210,7 @@ function TimeSeries({
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Save the selected unit for this metric?
Set the selected unit as the metric unit?
</Typography.Text>
<Button
type="primary"
@@ -229,64 +230,71 @@ function TimeSeries({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
{metricNames.length === 0 && <EmptyMetricsSearch />}
{metricNames.length > 0 &&
responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const currentYAxisUnit = yAxisUnit || metricUnit;
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
No unit is set for this metric. You can assign one from the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle
size={16}
color={Color.BG_AMBER_400}
role="img"
aria-label="no unit warning"
/>
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import EmptyMetricsSearch from '../EmptyMetricsSearch';
describe('EmptyMetricsSearch', () => {
it('shows select metric message when no query has been run', () => {
render(<EmptyMetricsSearch />);
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
});
it('shows no data message when a query returned empty results', () => {
render(<EmptyMetricsSearch hasQueryResult />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@@ -8,7 +8,7 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
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';
@@ -157,26 +157,6 @@ describe('Explorer', () => {
jest.clearAllMocks();
});
it('should render Explorer query builder with metrics datasource selected', () => {
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
stagedQuery: initialQueriesMap[DataSource.TRACES],
} as any);
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
});
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
@@ -241,20 +221,46 @@ describe('Explorer', () => {
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('should hide y axis unit selector for multiple metrics with different units', () => {
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
metrics: [
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
// One chart per query toggle should be disabled
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeChecked();
expect(oneChartPerQueryToggle).toBeDisabled();
});
@@ -327,4 +333,53 @@ describe('Explorer', () => {
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeEnabled();
});
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
const metricWithNoUnit = {
type: MetrictypesTypeDTO.sum,
description: 'metric without unit',
unit: '',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [metricWithNoUnit, metricWithNoUnit],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
// Toggle should be enabled (not forced/disabled) since both metrics
// have the same unit (no unit) and should be viewable on the same graph
expect(oneChartPerQueryToggle).toBeEnabled();
expect(oneChartPerQueryToggle).not.toBeChecked();
});
});

View File

@@ -1,4 +1,4 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
function renderTimeSeries(
overrides: Partial<TimeSeriesProps> = {},
): RenderResult {
): ReturnType<typeof render> {
return render(
<TimeSeries
showOneChartPerQuery={false}
@@ -84,45 +84,57 @@ describe('TimeSeries', () => {
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('shows select metric message when no metric is selected', () => {
renderTimeSeries({ metricNames: [] });
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
});
it('renders chart view when a metric is selected', () => {
renderTimeSeries({
metricNames: ['metric1'],
metricUnits: ['count'],
metrics: [MOCK_METRIC_METADATA],
});
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
expect(
screen.queryByText('Select a metric and run a query to see the results'),
).not.toBeInTheDocument();
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [undefined, undefined],
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
waitFor(() =>
expect(
screen.findByText('This metric does not have a unit'),
).toBeInTheDocument(),
);
expect(
screen.getByRole('img', { name: 'no unit warning' }),
).toBeInTheDocument();
});
it('clicking on warning icon tooltip should open metric details modal', async () => {
it('warning tooltip shows metric details link', async () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
});
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
await user.hover(alertIcon);
const metricDetailsLink = await screen.findByText('metric details');
user.click(metricDetailsLink);
waitFor(() =>
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
);
expect(await screen.findByText('metric details')).toBeInTheDocument();
});
it('shows Save unit button when metric had no unit but one is selected', async () => {
const { findByText, getByRole } = renderTimeSeries({
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
@@ -131,38 +143,10 @@ describe('TimeSeries', () => {
});
expect(
await findByText('Save the selected unit for this metric?'),
await screen.findByText('Set the selected unit as the metric unit?'),
).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' });
expect(yesButton).toBeInTheDocument();
const yesButton = screen.getByRole('button', { name: 'Yes' });
expect(yesButton).toBeEnabled();
});
it('clicking on save unit button shoould upated metric metadata', async () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
const yesButton = getByRole('button', { name: /Yes/i });
await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
pathParams: {
metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});

View File

@@ -139,4 +139,14 @@ describe('getMetricUnits', () => {
expect(result).toHaveLength(1);
expect(result[0]).toBe('s');
});
it('should return undefined for metrics with no unit', () => {
const result = getMetricUnits([
{ ...MOCK_METRIC_METADATA, unit: '' },
{ ...MOCK_METRIC_METADATA, unit: '' },
]);
expect(result).toHaveLength(2);
expect(result[0]).toBeUndefined();
expect(result[1]).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Typography } from 'antd';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -27,7 +27,7 @@ function MetricNameSearch({
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<AggregatorFilter
<MetricNameSelector
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}

View File

@@ -1,8 +1,9 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsService from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
@@ -23,27 +24,31 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
<div data-testid="mock-aggregator-filter">
<input
data-testid="metric-name-input"
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
onChange({ key: e.target.value })
}
/>
<button
type="button"
data-testid="select-metric-button"
onClick={(): void => onSelect({ key: 'test_metric_2' })}
>
Select Metric
</button>
</div>
),
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: jest.fn().mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics: [] } },
}),
useUpdateMetricMetadata: jest.fn().mockReturnValue({
mutate: jest.fn(),
isLoading: false,
}),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}),
);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -123,6 +128,24 @@ describe('QueryBuilder', () => {
it('should call setCurrentMetricName when metric name is selected', async () => {
const user = userEvent.setup();
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
isFetching: false,
isError: false,
data: {
data: {
metrics: [
{
metricName: 'test_metric_2',
type: 'Sum',
isMonotonic: true,
description: '',
temporality: 'cumulative',
unit: '',
},
],
},
},
});
render(
<QueryClientProvider client={queryClient}>
@@ -137,8 +160,12 @@ describe('QueryBuilder', () => {
expect(screen.getByText('From')).toBeInTheDocument();
const selectButton = screen.getByTestId('select-metric-button');
await user.click(selectButton);
const input = within(metricNameSearch).getByRole('combobox');
fireEvent.change(input, { target: { value: 'test_metric_2' } });
const options = document.querySelectorAll('.ant-select-item');
expect(options.length).toBeGreaterThan(0);
await user.click(options[0] as HTMLElement);
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
});

View File

@@ -24,6 +24,7 @@ import {
AggregatorFilter,
GroupByFilter,
HavingFilter,
MetricNameSelector,
OperatorsSelect,
OrderByFilter,
ReduceToFilter,
@@ -403,7 +404,7 @@ export const Query = memo(function Query({
)}
<Col flex="auto">
<AggregatorFilter
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
/>

View File

@@ -0,0 +1,6 @@
.metric-name-selector {
.ant-select-selection-placeholder {
color: var(--bg-slate-200);
font-style: italic;
}
}

View File

@@ -0,0 +1,887 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricNameSelector } from './MetricNameSelector';
const mockUseListMetrics = jest.fn();
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
mockUseListMetrics(...args),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
// while other tests keep the default no-op mock.
const handleSetQueryDataRef: {
current: (index: number, query: IBuilderQuery) => void;
} = {
current: jest.fn(),
};
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
handleSetQueryDataRef.current(index, query),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
panelType: 'TIME_SERIES',
initialDataSource: DataSource.METRICS,
currentQuery: {
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
queryType: 'builder',
},
setLastUsedQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
}),
}));
function makeMetric(
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
): MetricsexplorertypesListMetricDTO {
return {
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
}
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
return {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
timeAggregation: 'avg',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
...overrides,
} as IBuilderQuery;
}
function returnMetrics(
metrics: MetricsexplorertypesListMetricDTO[],
overrides: Record<string, unknown> = {},
): void {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics } },
queryKey: ['/api/v2/metrics'],
...overrides,
});
}
// snippet so tests can assert on them.
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
</div>
);
}
function getOptionLabels(testId: string): string[] {
const list = screen.getByTestId(testId);
const items = within(list).queryAllByRole('listitem');
return items.map((el) => el.textContent || '');
}
describe('MetricNameSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('shows metric names from API as dropdown options', () => {
returnMetrics([
makeMetric({ metricName: 'http_requests_total' }),
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'h' } });
expect(
screen.getAllByText('http_requests_total').length,
).toBeGreaterThanOrEqual(1);
expect(
screen.getAllByText('cpu_usage_percent').length,
).toBeGreaterThanOrEqual(1);
});
it('retains typed metric name in input after blur', () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(input).toHaveValue('http_requests_total');
});
it('shows error message when API request fails', () => {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: true,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'test' } });
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
});
it('shows loading spinner while fetching metrics', () => {
mockUseListMetrics.mockReturnValue({
isFetching: true,
isError: false,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
const { container } = render(
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'test' } });
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
});
});
describe('selecting a metric type updates the aggregation options', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'active_connections' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_seconds',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_seconds' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('ExponentialHistogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_exp',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_exp' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('unknown metric (typed name not in API results) shows all time and space options', () => {
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Max',
'Min',
'Sum',
'Avg',
'Count',
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
});
// these tests require the previous state, so we setup it to
// tracks previousMetricInfo across metric selections
function StatefulMetricQueryHarness({
initialQuery,
}: {
initialQuery: IBuilderQuery;
}): JSX.Element {
const [query, setQuery] = useState(initialQuery);
useEffect(() => {
handleSetQueryDataRef.current = (
_index: number,
newQuery: IBuilderQuery,
): void => {
setQuery(newQuery);
};
return (): void => {
handleSetQueryDataRef.current = jest.fn();
};
}, []);
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<div data-testid="selected-time-agg">
{currentAggregation?.timeAggregation || ''}
</div>
<div data-testid="selected-space-agg">
{currentAggregation?.spaceAggregation || ''}
</div>
<div data-testid="selected-metric-name">
{currentAggregation?.metricName || ''}
</div>
</div>
);
}
describe('switching between metrics of the same type preserves aggregation settings', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
returnMetrics([
makeMetric({
metricName: 'metric_a',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'metric_b',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'metric_a',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'metric_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'metric_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'metric_b',
);
});
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'mem_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'mem_usage' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'mem_usage',
);
});
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'req_duration',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'db_latency',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'req_duration',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'req_duration',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'db_latency' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'db_latency',
);
});
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'exp_hist_a',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
makeMetric({
metricName: 'exp_hist_b',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'exp_hist_a',
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p75',
metricName: 'exp_hist_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'exp_hist_b',
);
});
});
describe('switching to a different metric type resets aggregation to new defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
returnMetrics([
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'sum_metric',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'sum_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'gauge_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'gauge_metric',
);
});
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
returnMetrics([
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'gauge_metric',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'gauge_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'hist_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'hist_metric',
);
});
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
returnMetrics([
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'hist_metric',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'hist_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'sum_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'sum_metric',
);
});
});
describe('typed metric not in search results is committed with unknown defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
// Metric not in search results is committed with empty type resets to unknown defaults
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'unknown_metric',
);
});
});
describe('Summary metric type is treated as Gauge', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('selecting a Summary metric shows Gauge aggregation options', () => {
returnMetrics([
makeMetric({
metricName: 'rpc_duration_summary',
type: MetrictypesTypeDTO.summary,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'rpc_duration_summary' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
});

View File

@@ -0,0 +1,282 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { AutoComplete, Spin, Typography } from 'antd';
import { useListMetrics } from 'api/generated/services/metrics';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';
// N.B on the metric name selector behaviour.
//
// Metric aggregation options resolution:
// The component maintains a ref (metricsRef) of the latest API results.
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
// resolveMetricFromText looks up the metric in metricsRef to determine its type
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
// a name before the debounced search returned), the type is empty and downstream
// treats it as unknown.
//
// Selection handling:
// - Dropdown select: user picks from the dropdown; type is always resolved
// since the option came from the current API results.
// - Blur: user typed a name and tabbed/clicked away without selecting from
// the dropdown. If the name differs from the current metric, it's resolved
// and committed. If the input is empty, it resets to the current metric name.
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
// so the state update is processed synchronously before QueryBuilderV2's
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
// to run before React's root-level event dispatch. However, there is still one
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
//
// Edit mode:
// When a saved query is loaded, the metric name may be set via aggregations
// but aggregateAttribute.type may be missing. Once the API returns metric data,
// the component calls onChange with isEditMode=true to backfill the type without
// resetting aggregation options.
//
// Signal source:
// When signalSource is 'meter', the API is filtered to meter metrics only.
// Changing signalSource clears the input and search text.
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
if (metric.type === MetrictypesTypeDTO.sum && !metric.isMonotonic) {
return ATTRIBUTE_TYPES.GAUGE;
}
const mapping: Record<MetrictypesTypeDTO, ATTRIBUTE_TYPES> = {
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.exponentialhistogram]:
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
};
return mapping[metric.type] || '';
}
function toAutocompleteData(
metricName: string,
type: string,
): BaseAutocompleteData {
return { key: metricName, type, dataType: DataTypes.Float64 };
}
export type MetricNameSelectorProps = {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
disabled?: boolean;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
};
export const MetricNameSelector = memo(function MetricNameSelector({
query,
onChange,
disabled,
defaultValue,
onSelect,
signalSource,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
'';
const [inputValue, setInputValue] = useState<string>(
defaultValue || currentMetricName,
);
const [searchText, setSearchText] = useState<string>(currentMetricName);
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
const selectedFromDropdownRef = useRef(false);
const prevSignalSourceRef = useRef(signalSource);
useEffect(() => {
setInputValue(defaultValue || currentMetricName);
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
prevSignalSourceRef.current = signalSource;
setSearchText('');
setInputValue('');
}
}, [signalSource]);
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const { isFetching, isError, data: listMetricsData } = useListMetrics(
{
searchText: debouncedValue,
limit: 100,
source: signalSource || undefined,
} as Record<string, unknown>,
{
query: {
keepPreviousData: false,
retry: 2,
},
},
);
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
listMetricsData,
]);
useEffect(() => {
metricsRef.current = metrics;
}, [metrics]);
const optionsData = useMemo((): ExtendedSelectOption[] => {
if (!metrics.length) {
return [];
}
return metrics.map((metric) => ({
label: (
<OptionRenderer
label={metric.metricName}
value={metric.metricName}
dataType={DataTypes.Float64}
type={getAttributeType(metric) || ''}
/>
),
value: metric.metricName,
key: metric.metricName,
}));
}, [metrics]);
useEffect(() => {
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
const found = metrics.find((m) => m.metricName === metricName);
if (found) {
onChange(
toAutocompleteData(found.metricName, getAttributeType(found)),
true,
);
}
}
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {
const found = metricsRef.current.find((m) => m.metricName === text);
if (found) {
return toAutocompleteData(found.metricName, getAttributeType(found));
}
return toAutocompleteData(text, '');
},
[],
);
const placeholder = useMemo(() => {
if (signalSource === 'meter') {
return 'Search for a meter metric...';
}
return 'Search for a metric...';
}, [signalSource]);
const handleChange = useCallback((value: string): void => {
setInputValue(value);
}, []);
const handleSearch = useCallback((value: string): void => {
setSearchText(value);
selectedFromDropdownRef.current = false;
}, []);
const handleSelect = useCallback(
(value: string): void => {
selectedFromDropdownRef.current = true;
const resolved = resolveMetricFromText(value);
onChange(resolved);
if (onSelect) {
onSelect(resolved);
}
setSearchText('');
},
[onChange, onSelect, resolveMetricFromText],
);
const handleBlur = useCallback(() => {
if (selectedFromDropdownRef.current) {
selectedFromDropdownRef.current = false;
return;
}
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
onChange(resolveMetricFromText(typedValue));
} else if (!typedValue && currentMetricName) {
setInputValue(currentMetricName);
}
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
flushSync(() => {
onChange(resolveMetricFromText(typedValue));
});
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return (): void => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}
onSearch={handleSearch}
onChange={handleChange}
notFoundContent={
isFetching ? (
<Spin size="small" />
) : isError ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
Failed to load metrics
</Typography.Text>
) : null
}
options={optionsData}
value={inputValue}
onBlur={handleBlur}
onSelect={handleSelect}
disabled={disabled}
/>
);
});

View File

@@ -0,0 +1,2 @@
export type { MetricNameSelectorProps } from './MetricNameSelector';
export { MetricNameSelector } from './MetricNameSelector';

View File

@@ -2,6 +2,7 @@ export { AggregatorFilter } from './AggregatorFilter';
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
export { GroupByFilter } from './GroupByFilter';
export { HavingFilter } from './HavingFilter';
export { MetricNameSelector } from './MetricNameSelector';
export { OperatorsSelect } from './OperatorsSelect';
export { OrderByFilter } from './OrderByFilter';
export { ReduceToFilter } from './ReduceToFilter';

View File

@@ -257,7 +257,9 @@ function TimeSeriesView({
chartData[0]?.length === 0 &&
!isLoading &&
!isError &&
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
dataSource === DataSource.METRICS && (
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
)}
{!isLoading &&
!isError &&

View File

@@ -248,19 +248,12 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
(
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
): void => {
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
const newQuery: IBuilderQuery = {
...query,
aggregateAttribute: value,
};
const getAttributeKeyFromMetricName = (metricName: string): string =>
attributeKeys?.find((key) => key.key === metricName)?.type || '';
if (
newQuery.dataSource === DataSource.METRICS &&
entityVersion === ENTITY_VERSION_V4
@@ -311,9 +304,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Get current metric info
const currentMetricType = newQuery.aggregateAttribute?.type || '';
const prevMetricType = previousMetricInfo?.type
? previousMetricInfo.type
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
const prevMetricType = previousMetricInfo?.type || '';
// Check if metric type has changed by comparing with tracked previous values
const metricTypeChanged =
@@ -374,7 +365,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'rate', space - 'sum', op - 'count'
// If not typed - time - 'avg', space - 'sum'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
@@ -388,7 +379,7 @@ export const useQueryOperations: UseQueryOperations = ({
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.COUNT,
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
@@ -408,6 +399,29 @@ export const useQueryOperations: UseQueryOperations = ({
];
}
}
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
}
}
}
}

View File

@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -177,7 +177,7 @@ export const replaceVariables = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -267,7 +267,7 @@ export const defaultOutput = {
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
},
],
filter: { expression: '' },
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'count',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},

View File

@@ -8,7 +8,6 @@ import {
createSetCursorHandler,
createSetLegendHandler,
createSetSeriesHandler,
getPlot,
isScrollEventInPlot,
updatePlotVisibility,
updateWindowSize,
@@ -54,7 +53,7 @@ export default function TooltipPlugin({
const [viewState, setState] = useState<TooltipViewState>(
createInitialViewState,
);
const { hasPlot, isHovering, isPinned, contents, style } = viewState;
const { plot, isHovering, isPinned, contents, style } = viewState;
/**
* Merge a partial view update into the current React state.
@@ -73,25 +72,12 @@ export default function TooltipPlugin({
layoutRef.current?.observer.disconnect();
layoutRef.current = createLayoutObserver(layoutRef);
/**
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
* reference; clearPlotReferences runs in cleanup so
* detached canvases can be garbage collected.
*/
// Controller holds the mutable interaction state for this tooltip
// instance. It is intentionally *not* React state so uPlot hooks
// and DOM listeners can update it freely without triggering a
// render on every mouse move.
const controller: TooltipControllerState = createInitialControllerState();
/**
* Clear plot references so detached canvases can be garbage collected.
*/
const clearPlotReferences = (): void => {
controller.plot = null;
updateState({ hasPlot: false });
};
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
// Enable uPlot's built-in cursor sync when requested so that
@@ -124,10 +110,9 @@ export default function TooltipPlugin({
// Lock uPlot's internal cursor when the tooltip is pinned so
// subsequent mouse moves do not move the crosshair.
function updateCursorLock(): void {
const plot = getPlot(controller);
if (plot) {
if (controller.plot) {
// @ts-ignore uPlot cursor lock is not working as expected
plot.cursor._lock = controller.pinned;
controller.plot.cursor._lock = controller.pinned;
}
}
@@ -157,9 +142,8 @@ export default function TooltipPlugin({
const isPinnedBeforeDismiss = controller.pinned;
controller.pinned = false;
controller.hoverActive = false;
const plot = getPlot(controller);
if (plot) {
plot.setCursor({ left: -10, top: -10 });
if (controller.plot) {
controller.plot.setCursor({ left: -10, top: -10 });
}
scheduleRender(isPinnedBeforeDismiss);
}
@@ -167,12 +151,11 @@ export default function TooltipPlugin({
// Build the React node to be rendered inside the tooltip by
// delegating to the caller-provided `render` function.
function createTooltipContents(): React.ReactNode {
const plot = getPlot(controller);
if (!controller.hoverActive || !plot) {
if (!controller.hoverActive || !controller.plot) {
return null;
}
return renderRef.current({
uPlotInstance: plot,
uPlotInstance: controller.plot,
dataIndexes: controller.seriesIndexes,
seriesIndex: controller.focusedSeriesIndex,
isPinned: controller.pinned,
@@ -257,13 +240,9 @@ export default function TooltipPlugin({
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
// Uses getPlot(controller) to avoid closing over u (plot), which
// would retain the plot and detached canvases across unmounts.
const handleUPlotOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
if (
plot &&
event.target === plot.over &&
event.target === u.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
@@ -281,9 +260,10 @@ export default function TooltipPlugin({
// on the controller and optionally attach the pinning handler.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ hasPlot: true });
updateState({ plot: u });
if (canPinTooltip) {
overClickHandler = handleUPlotOverClick;
overClickHandler = (event: MouseEvent): void =>
handleUPlotOverClick(u, event);
u.over.addEventListener('click', overClickHandler);
}
};
@@ -319,6 +299,7 @@ export default function TooltipPlugin({
const handleSetCursor = createSetCursorHandler(ctx);
handleWindowResize();
const removeReadyHook = config.addHook('ready', (): void =>
updatePlotVisibility(controller),
);
@@ -344,20 +325,16 @@ export default function TooltipPlugin({
removeSetSeriesHook();
removeSetLegendHook();
removeSetCursorHook();
if (overClickHandler) {
const plot = getPlot(controller);
if (plot) {
plot.over.removeEventListener('click', overClickHandler);
}
if (controller.plot && overClickHandler) {
controller.plot.over.removeEventListener('click', overClickHandler);
overClickHandler = null;
}
clearPlotReferences();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useLayoutEffect((): void => {
if (!hasPlot || !layoutRef.current) {
if (!plot || !layoutRef.current) {
return;
}
const layout = layoutRef.current;
@@ -372,9 +349,9 @@ export default function TooltipPlugin({
layout.width = 0;
layout.height = 0;
}
}, [isHovering, hasPlot]);
}, [isHovering, plot]);
if (!hasPlot) {
if (!plot) {
return null;
}

View File

@@ -10,11 +10,6 @@ import {
const WINDOW_OFFSET = 16;
/** Get the plot instance from the controller; returns null if never set or cleared. */
export function getPlot(controller: TooltipControllerState): uPlot | null {
return controller.plot ?? null;
}
export function createInitialControllerState(): TooltipControllerState {
return {
plot: null,
@@ -51,13 +46,12 @@ export function updateWindowSize(controller: TooltipControllerState): void {
* This is used to decide if a synced tooltip should be shown at all.
*/
export function updatePlotVisibility(controller: TooltipControllerState): void {
const plot = getPlot(controller);
if (!plot) {
if (!controller.plot) {
controller.plotWithinViewport = false;
return;
}
controller.plotWithinViewport = isPlotInViewport(
plot.rect,
controller.plot.rect,
controller.windowWidth,
controller.windowHeight,
);
@@ -72,11 +66,10 @@ export function isScrollEventInPlot(
event: Event,
controller: TooltipControllerState,
): boolean {
const plot = getPlot(controller);
return (
event.target instanceof Node &&
plot !== null &&
event.target.contains(plot.root)
controller.plot !== null &&
event.target.contains(controller.plot.root)
);
}
@@ -172,12 +165,11 @@ export function createSetLegendHandler(
): (u: uPlot) => void {
return (u: uPlot): void => {
const { controller } = ctx;
const plot = getPlot(controller);
if (!plot?.cursor?.idxs) {
if (!controller.plot?.cursor?.idxs) {
return;
}
const newSeriesIndexes = plot.cursor.idxs.slice();
const newSeriesIndexes = controller.plot.cursor.idxs.slice();
const isAnySeriesActive = newSeriesIndexes.some((v, i) => i > 0 && v != null);
const previousCursorDrivenBySync = controller.cursorDrivenBySync;

View File

@@ -18,8 +18,7 @@ export enum DashboardCursorSync {
}
export interface TooltipViewState {
/** Whether a plot instance exists; plot reference is in controller, not state. */
hasPlot?: boolean;
plot?: uPlot | null;
style: Partial<CSSProperties>;
isHovering: boolean;
isPinned: boolean;

View File

@@ -123,7 +123,7 @@ export function createInitialViewState(): TooltipViewState {
isHovering: false,
isPinned: false,
contents: null,
hasPlot: false,
plot: null,
dismiss: (): void => {},
};
}

View File

@@ -72,7 +72,6 @@ export type UseQueryOperations = (
handleChangeAggregatorAttribute: (
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
) => void;
handleChangeDataSource: (newSource: DataSource) => void;
handleDeleteQuery: () => void;

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -56,11 +57,81 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
// TODO(srikanthccv): use metadata store to fetch metric metadata
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if params.Source == "meter" {
return m.listMeterMetrics(ctx, params)
}
return m.listMetrics(ctx, orgID, params)
}
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"any(description) AS description",
"any(type) AS metric_type",
"any(unit) AS metric_unit",
"argMax(temporality, unix_milli) AS temporality",
"any(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
if params.Start != nil && params.End != nil {
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
}
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.GroupBy("metric_name")
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
}
defer rows.Close()
metrics := make([]metricsexplorertypes.ListMetric, 0)
for rows.Next() {
var metric metricsexplorertypes.ListMetric
if err := rows.Scan(
&metric.MetricName,
&metric.Description,
&metric.MetricType,
&metric.MetricUnit,
&metric.Temporality,
&metric.IsMonotonic,
); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
}
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")

View File

@@ -1631,8 +1631,10 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
if err != nil {
return nil, nil, err
}
// TODO: return error after table migration are run
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
if err != nil {
return nil, nil, err
}
// For metrics not found in the database, set to Unknown
for _, metricName := range metricNames {
@@ -1728,6 +1730,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
"metric_name",
"argMax(temporality, unix_milli) as temporality",
"any(type) AS type",
"any(is_monotonic) as is_monotonic",
).From(t.meterDBName + "." + t.meterFieldsTblName)
// Filter by metric names (in the temporality column due to data mix-up)

View File

@@ -301,6 +301,7 @@ type ListMetricsParams struct {
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
Source string `query:"source"`
}
// Validate ensures ListMetricsParams contains acceptable values.