Compare commits

...

5 Commits

Author SHA1 Message Date
Karan Balani
c9985b56bc feat: add org id support in root user config (#10418)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat: add org id support in root user config

* chore: address review comments

* fix: use zero value uuid for org id in example.conf
2026-02-26 13:44:14 +05:30
Abhi kumar
f9868e2221 fix: thresholds working correctly with number panel (#10394)
* fix: fixed unit converstion support across thresholds and yaxisunit

* fix: thresholds working correctly with number panel

* fix: fixed tsc

* chore: fixed unit tests

* chore: reverted the extractnumberfromstring change

* chore: replaced select component with yaxisunitselector in threshold

* fix: fixed test

* chore: pr review fix

* chore: added test for yaxisunitselector
2026-02-26 07:42:29 +00:00
Amlan Kumar Nandy
72b0398eaf chore: metrics explorer v2 api migration in explorer section (#10111) 2026-02-26 06:57:41 +00:00
Abhi kumar
5b75a39777 chore: removed sentry instrumentation for querysearch (#10426) 2026-02-26 12:04:58 +05:30
Abhi kumar
6948b69012 fix: throttled legend color picker in dashboard + memory leak fix due to tooltip persistance (#10421)
* fix: creating tooltip plugin container only once

* chore: throttled legendcolor change

* fix: fixed tooltip plugin test
2026-02-26 11:28:20 +05:30
32 changed files with 594 additions and 323 deletions

View File

@@ -320,3 +320,4 @@ user:
# The name of the organization to create or look up for the root user.
org:
name: default
id: 00000000-0000-0000-0000-000000000000

View File

@@ -1,26 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export const getMetricMetadata = async (
metricName: string,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
signal,
headers,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@@ -11,7 +11,6 @@ import {
startCompletion,
} from '@codemirror/autocomplete';
import { javascript } from '@codemirror/lang-javascript';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
@@ -564,15 +563,7 @@ function QuerySearch({
const lastPos = lastPosRef.current;
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos((lastPos) => {
if (newPos.ch !== lastPos.ch && newPos.ch === 0) {
Sentry.captureEvent({
message: `Cursor jumped to start of line from ${lastPos.ch} to ${newPos.ch}`,
level: 'warning',
});
}
return newPos;
});
setCursorPos(newPos);
lastPosRef.current = newPos;
if (doc) {

View File

@@ -40,6 +40,7 @@ function ValueGraph({
value,
rawValue,
thresholds,
yAxisUnit,
}: ValueGraphProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
const containerRef = useRef<HTMLDivElement>(null);
@@ -87,7 +88,7 @@ function ValueGraph({
const {
threshold,
isConflictingThresholds,
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue);
} = getBackgroundColorAndThresholdCheck(thresholds, rawValue, yAxisUnit);
return (
<div
@@ -155,6 +156,7 @@ interface ValueGraphProps {
value: string;
rawValue: number;
thresholds: ThresholdProps[];
yAxisUnit?: string;
}
export default ValueGraph;

View File

@@ -1,9 +1,10 @@
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { evaluateThresholdWithConvertedValue } from 'container/GridTableComponent/utils';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
function compareThreshold(
function doesValueSatisfyThreshold(
rawValue: number,
threshold: ThresholdProps,
yAxisUnit?: string,
): boolean {
if (
threshold.thresholdOperator === undefined ||
@@ -11,31 +12,14 @@ function compareThreshold(
) {
return false;
}
switch (threshold.thresholdOperator) {
case '>':
return rawValue > threshold.thresholdValue;
case '>=':
return rawValue >= threshold.thresholdValue;
case '<':
return rawValue < threshold.thresholdValue;
case '<=':
return rawValue <= threshold.thresholdValue;
case '=':
return rawValue === threshold.thresholdValue;
default:
return false;
}
}
function extractNumbersFromString(inputString: string): number[] {
const regex = /[+-]?\d+(\.\d+)?/g;
const matches = inputString.match(regex);
if (matches) {
return matches.map(Number);
}
return [];
return evaluateThresholdWithConvertedValue(
rawValue,
threshold.thresholdValue,
threshold.thresholdOperator,
threshold.thresholdUnit,
yAxisUnit,
);
}
function getHighestPrecedenceThreshold(
@@ -60,21 +44,32 @@ function getHighestPrecedenceThreshold(
return highestPrecedenceThreshold;
}
function extractNumbersFromString(inputString: string): number[] {
const regex = /[+-]?\d+(\.\d+)?/g;
const matches = inputString.match(regex);
if (matches) {
return matches.map(Number);
}
return [];
}
export function getBackgroundColorAndThresholdCheck(
thresholds: ThresholdProps[],
rawValue: number,
yAxisUnit?: string,
): {
threshold: ThresholdProps;
isConflictingThresholds: boolean;
} {
const matchingThresholds = thresholds.filter((threshold) =>
compareThreshold(
extractNumbersFromString(
getYAxisFormattedValue(rawValue.toString(), threshold.thresholdUnit || ''),
)[0],
threshold,
),
);
const matchingThresholds = thresholds.filter((threshold) => {
const numbers = extractNumbersFromString(rawValue.toString());
if (numbers.length === 0) {
return false;
}
return doesValueSatisfyThreshold(numbers[0], threshold, yAxisUnit);
});
if (matchingThresholds.length === 0) {
return {

View File

@@ -22,6 +22,8 @@ function YAxisUnitSelector({
'data-testid': dataTestId,
source,
initialValue,
categoriesOverride,
containerClassName,
}: YAxisUnitSelectorProps): JSX.Element {
const universalUnit = mapMetricUnitToUniversalUnit(value);
@@ -66,10 +68,14 @@ function YAxisUnitSelector({
return aliases.some((alias) => alias.toLowerCase().includes(search));
};
const categories = getYAxisCategories(source);
const categoriesToRender = useMemo(() => {
return categoriesOverride || getYAxisCategories(source);
}, [categoriesOverride, source]);
return (
<div className="y-axis-unit-selector-component">
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<Select
showSearch
value={universalUnit}
@@ -90,7 +96,7 @@ function YAxisUnitSelector({
data-testid={dataTestId}
allowClear
>
{categories.map((category) => (
{categoriesToRender.map((category) => (
<Select.OptGroup key={category.name} label={category.name}>
{category.units.map((unit) => (
<Select.Option key={unit.id} value={unit.id}>

View File

@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisSource } from '../types';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
@@ -123,4 +124,34 @@ describe('YAxisUnitSelector', () => {
const warningIcon = screen.queryByLabelText('warning');
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
units: [
{
id: UniversalYAxisUnit.BYTES,
name: 'Custom Bytes (B)',
},
],
},
];
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
categoriesOverride={customCategories}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();
});
});

View File

@@ -9,6 +9,8 @@ export interface YAxisUnitSelectorProps {
'data-testid'?: string;
source: YAxisSource;
initialValue?: string;
categoriesOverride?: YAxisCategory[];
containerClassName?: string;
}
export enum UniversalYAxisUnit {

View File

@@ -49,7 +49,7 @@ function evaluateCondition(
* @param columnUnit - The current unit of the value.
* @returns A boolean indicating whether the value meets the threshold condition.
*/
function evaluateThresholdWithConvertedValue(
export function evaluateThresholdWithConvertedValue(
value: number,
thresholdValue: number,
thresholdOperator?: string,

View File

@@ -99,6 +99,7 @@ function GridValueComponent({
<ValueGraph
thresholds={thresholds || []}
rawValue={value}
yAxisUnit={yAxisUnit}
value={
yAxisUnit
? getYAxisFormattedValue(

View File

@@ -87,7 +87,7 @@ function Explorer(): JSX.Element {
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const unitsLength = useMemo(() => units.length, [units]);
const firstUnit = useMemo(() => units?.[0], [units]);
const firstUnit = useMemo(() => units[0], [units]);
useEffect(() => {
// Set the y axis unit to the first metric unit if

View File

@@ -1,8 +1,13 @@
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useQueries, useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import { toast } from '@signozhq/sonner';
import { Button, Tooltip, Typography } from 'antd';
import {
invalidateGetMetricMetadata,
useUpdateMetricMetadata,
} from 'api/generated/services/metrics';
import { isAxiosError } from 'axios';
import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
@@ -23,7 +28,10 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TimeSeriesProps } from './types';
import { splitQueryIntoOneChartPerQuery } from './utils';
import {
buildUpdateMetricYAxisUnitPayload,
splitQueryIntoOneChartPerQuery,
} from './utils';
function TimeSeries({
showOneChartPerQuery,
@@ -35,6 +43,7 @@ function TimeSeries({
yAxisUnit,
setYAxisUnit,
showYAxisUnitSelector,
metrics,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
@@ -42,6 +51,7 @@ function TimeSeries({
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryClient = useQueryClient();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
@@ -138,54 +148,51 @@ function TimeSeries({
setYAxisUnit(value);
};
// TODO: Enable once we have resolved all related metrics v2 api issues
// Show the save unit button if
// 1. There is only one metric
// 2. The metric has no saved unit
// 3. The user has selected a unit
// const showSaveUnitButton = useMemo(
// () =>
// metricUnits.length === 1 &&
// Boolean(metrics?.[0]) &&
// !metricUnits[0] &&
// yAxisUnit,
// [metricUnits, metrics, yAxisUnit],
// );
const showSaveUnitButton = useMemo(
() =>
metricUnits.length === 1 &&
Boolean(metrics[0]) &&
!metricUnits[0] &&
yAxisUnit,
[metricUnits, metrics, yAxisUnit],
);
// const {
// mutate: updateMetricMetadata,
// isLoading: isUpdatingMetricMetadata,
// } = useUpdateMetricMetadata();
const {
mutate: updateMetricMetadata,
isLoading: isUpdatingMetricMetadata,
} = useUpdateMetricMetadata();
// const handleSaveUnit = (): void => {
// updateMetricMetadata(
// {
// metricName: metricNames[0],
// payload: {
// unit: yAxisUnit,
// description: metrics[0]?.description ?? '',
// metricType: metrics[0]?.type as MetricType,
// temporality: metrics[0]?.temporality,
// },
// },
// {
// onSuccess: () => {
// notifications.success({
// message: 'Unit saved successfully',
// });
// queryClient.invalidateQueries([
// REACT_QUERY_KEY.GET_METRIC_DETAILS,
// metricNames[0],
// ]);
// },
// onError: () => {
// notifications.error({
// message: 'Failed to save unit',
// });
// },
// },
// );
// };
const handleSaveUnit = (): void => {
if (metrics[0] && yAxisUnit) {
updateMetricMetadata(
{
pathParams: {
metricName: metricNames[0],
},
data: buildUpdateMetricYAxisUnitPayload(
metricNames[0],
metrics[0],
yAxisUnit,
),
},
{
onSuccess: () => {
toast.success('Unit saved successfully');
invalidateGetMetricMetadata(queryClient, {
metricName: metricNames[0],
});
},
onError: () => {
toast.error('Failed to save unit');
},
},
);
}
};
return (
<>
@@ -198,8 +205,7 @@ function TimeSeries({
source={YAxisSource.EXPLORER}
data-testid="y-axis-unit-selector"
/>
{/* TODO: Enable once we have resolved all related metrics v2 api issues */}
{/* {showSaveUnitButton && (
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Save the selected unit for this metric?
@@ -213,7 +219,7 @@ function TimeSeries({
<Typography.Paragraph>Yes</Typography.Paragraph>
</Button>
</div>
)} */}
)}
</>
)}
</div>

View File

@@ -3,8 +3,10 @@ import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
@@ -14,12 +16,12 @@ import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import Explorer from '../Explorer';
import * as useGetMetricsHooks from '../utils';
import { MOCK_METRIC_METADATA } from './testUtils';
const mockSetSearchParams = jest.fn();
const queryClient = new QueryClient();
@@ -135,14 +137,6 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'y-axis-unit-selector';
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
function renderExplorer(): void {
render(
<QueryClientProvider client={queryClient}>
@@ -190,7 +184,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -207,7 +201,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -220,7 +214,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric],
metrics: [MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -237,7 +231,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -250,7 +244,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -269,10 +263,10 @@ describe('Explorer', () => {
isError: false,
metrics: [
{
type: MetricType.SUM,
type: MetrictypesTypeDTO.sum,
description: 'metric1 description',
unit: '',
temporality: Temporality.CUMULATIVE,
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
},
],
@@ -289,7 +283,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric],
metrics: [MOCK_METRIC_METADATA],
});
renderExplorer();
@@ -324,7 +318,7 @@ describe('Explorer', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
renderExplorer();

View File

@@ -1,29 +1,19 @@
import { UseMutationResult } from 'react-query';
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataResponse } from 'api/metricsExplorer/updateMetricMetadata';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { UseUpdateMetricMetadataProps } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import TimeSeries from '../TimeSeries';
import { TimeSeriesProps } from '../types';
import { MOCK_METRIC_METADATA } from './testUtils';
type MockUpdateMetricMetadata = UseMutationResult<
SuccessResponse<UpdateMetricMetadataResponse> | ErrorResponse,
Error,
UseUpdateMetricMetadataProps
>;
const mockUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue(({
mutate: mockUpdateMetricMetadata,
isLoading: false,
} as Partial<MockUpdateMetricMetadata>) as MockUpdateMetricMetadata);
const updateMetricMetadataSpy = jest.spyOn(
metricsExplorerHooks,
'useUpdateMetricMetadata',
);
type UseUpdateMetricMetadataReturnType = ReturnType<
typeof metricsExplorerHooks.useUpdateMetricMetadata
>;
jest.mock('container/TimeSeriesView/TimeSeriesView', () => ({
__esModule: true,
@@ -60,14 +50,6 @@ jest.mock('react-redux', () => ({
}),
}));
const mockMetric: MetricMetadata = {
type: MetricType.SUM,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: Temporality.CUMULATIVE,
isMonotonic: true,
};
const mockSetWarning = jest.fn();
const mockSetIsMetricDetailsOpen = jest.fn();
const mockSetYAxisUnit = jest.fn();
@@ -96,6 +78,13 @@ function renderTimeSeries(
}
describe('TimeSeries', () => {
beforeEach(() => {
updateMetricMetadataSpy.mockReturnValue(({
mutate: mockUpdateMetricMetadata,
isLoading: false,
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
const user = userEvent.setup();
const { container } = renderTimeSeries({
@@ -118,7 +107,7 @@ describe('TimeSeries', () => {
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [mockMetric, mockMetric],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
});
@@ -133,18 +122,17 @@ describe('TimeSeries', () => {
);
});
// TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('shows Save unit button when metric had no unit but one is selected', () => {
it('shows Save unit button when metric had no unit but one is selected', async () => {
const { findByText, getByRole } = renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [mockMetric],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
expect(
findByText('Save the selected unit for this metric?'),
await findByText('Save the selected unit for this metric?'),
).toBeInTheDocument();
const yesButton = getByRole('button', { name: 'Yes' });
@@ -152,24 +140,25 @@ describe('TimeSeries', () => {
expect(yesButton).toBeEnabled();
});
// TODO: Unskip this test once the save unit button is implemented
// Tracking at - https://github.com/SigNoz/engineering-pod/issues/3495
it.skip('clicking on save unit button shoould upated metric metadata', () => {
it('clicking on save unit button shoould upated metric metadata', async () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [mockMetric],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
const yesButton = getByRole('button', { name: /Yes/i });
user.click(yesButton);
await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
metricName: 'metric1',
payload: expect.objectContaining({ unit: 'seconds' }),
pathParams: {
metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),

View File

@@ -0,0 +1,13 @@
import {
MetricsexplorertypesMetricMetadataDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const MOCK_METRIC_METADATA: MetricsexplorertypesMetricMetadataDTO = {
type: MetrictypesTypeDTO.sum,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};

View File

@@ -1,14 +1,8 @@
import { UseQueryResult } from 'react-query';
import { renderHook } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useGetMultipleMetricsHook from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { SuccessResponseV2 } from 'types/api';
import {
MetricMetadata,
MetricMetadataResponse,
} from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderFormula,
@@ -22,6 +16,7 @@ import {
splitQueryIntoOneChartPerQuery,
useGetMetrics,
} from '../utils';
import { MOCK_METRIC_METADATA } from './testUtils';
const MOCK_QUERY_DATA_1: IBuilderQuery = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
@@ -91,32 +86,19 @@ describe('splitQueryIntoOneChartPerQuery', () => {
});
});
const MOCK_METRIC_METADATA: MetricMetadata = {
description: 'Metric 1 description',
unit: 'unit1',
type: MetricType.GAUGE,
temporality: Temporality.DELTA,
isMonotonic: true,
};
describe('useGetMetrics', () => {
beforeEach(() => {
jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([
({
{
isLoading: false,
isError: false,
data: {
httpStatusCode: 200,
data: {
status: 'success',
data: MOCK_METRIC_METADATA,
},
data: MOCK_METRIC_METADATA,
status: 'success',
},
} as Partial<
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
} as UseQueryResult<GetMetricMetadata200, Error>,
]);
});
@@ -133,12 +115,11 @@ describe('useGetMetrics', () => {
jest
.spyOn(useGetMultipleMetricsHook, 'useGetMultipleMetrics')
.mockReturnValue([
({
{
isLoading: true,
isError: false,
} as Partial<
UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>
>) as UseQueryResult<SuccessResponseV2<MetricMetadataResponse>, Error>,
data: undefined,
} as UseQueryResult<GetMetricMetadata200, Error>,
]);
const { result } = renderHook(() => useGetMetrics(['metric1']));
expect(result.current.metrics).toHaveLength(1);

View File

@@ -1,9 +1,9 @@
import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
export enum ExplorerTabs {
TIME_SERIES = 'time-series',
@@ -18,7 +18,7 @@ export interface TimeSeriesProps {
isMetricUnitsError: boolean;
metricUnits: (string | undefined)[];
metricNames: string[];
metrics: (MetricMetadata | undefined)[];
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[];
handleOpenMetricDetails: (metricName: string) => void;
yAxisUnit: string | undefined;
setYAxisUnit: (unit: string) => void;

View File

@@ -1,9 +1,12 @@
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { mapMetricUnitToUniversalUnit } from 'components/YAxisUnitSelector/utils';
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { determineIsMonotonic } from '../MetricDetails/utils';
/**
* Split a query with multiple queryData to multiple distinct queries, each with a single queryData.
* @param query - The query to split
@@ -68,16 +71,14 @@ export function useGetMetrics(
): {
isLoading: boolean;
isError: boolean;
metrics: (MetricMetadata | undefined)[];
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[];
} {
const metricsData = useGetMultipleMetrics(metricNames, {
enabled: metricNames.length > 0 && isEnabled,
});
return {
isLoading: metricsData.some((metric) => metric.isLoading),
metrics: metricsData
.map((metric) => metric.data?.data)
.map((data) => data?.data),
metrics: metricsData.map((metric) => metric.data?.data),
isError: metricsData.some((metric) => metric.isError),
};
}
@@ -89,9 +90,24 @@ export function useGetMetrics(
* @returns The units of the metrics, can be undefined if the metric has no unit
*/
export function getMetricUnits(
metrics: (MetricMetadata | undefined)[],
metrics: (MetricsexplorertypesMetricMetadataDTO | undefined)[],
): (string | undefined)[] {
return metrics
.map((metric) => metric?.unit)
.map((unit) => mapMetricUnitToUniversalUnit(unit) || undefined);
}
export function buildUpdateMetricYAxisUnitPayload(
metricName: string,
metric: MetricsexplorertypesMetricMetadataDTO,
yAxisUnit: string,
): UpdateMetricMetadataMutationBody {
return {
metricName,
type: metric.type,
description: metric.description,
unit: yAxisUnit || '',
temporality: metric.temporality,
isMonotonic: determineIsMonotonic(metric?.type, metric?.temporality),
};
}

View File

@@ -0,0 +1,131 @@
import { ColorPickerProps } from 'antd';
import { Color } from 'antd/es/color-picker';
import { render, screen, userEvent } from 'tests/test-utils';
import LegendColors from './LegendColors';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
__esModule: true,
useQueryBuilder: (): { currentQuery: unknown } => ({
currentQuery: {
builder: {
queryData: [
{
queryName: 'A',
legend: '{service.name}',
},
],
},
},
}),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
ColorPicker: ({ onChange, children }: ColorPickerProps): JSX.Element => (
<button
type="button"
data-testid="legend-color-picker"
onClick={(): void =>
onChange!({ toHexString: (): string => '#ffffff' } as Color, '#ffffff')
}
>
{children}
</button>
),
};
});
describe('LegendColors', () => {
it('renders legend colors panel and items', async () => {
const user = userEvent.setup();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={jest.fn()}
queryResponse={undefined}
/>,
);
expect(screen.getByText('Legend Colors')).toBeInTheDocument();
// Expand the collapse to reveal legend items
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
expect(screen.getByText('{service.name}')).toBeInTheDocument();
});
it('calls setCustomLegendColors when color is changed', async () => {
const user = userEvent.setup();
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
await user.click(colorTrigger);
expect(setCustomLegendColors).toHaveBeenCalled();
});
it('throttles rapid color changes', async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const setCustomLegendColors = jest.fn();
render(
<LegendColors
customLegendColors={{}}
setCustomLegendColors={setCustomLegendColors}
queryResponse={undefined}
/>,
);
// Expand panel to render the mocked ColorPicker button
await user.click(
screen.getByRole('tab', {
name: /Legend Colors/i,
}),
);
const colorTrigger = screen.getByTestId('legend-color-picker');
// Fire multiple rapid changes
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
await user.click(colorTrigger);
// Flush pending throttled calls
jest.advanceTimersByTime(500);
// Throttling should ensure we don't invoke the setter once per click
expect(setCustomLegendColors).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
});

View File

@@ -14,6 +14,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import throttle from 'lodash-es/throttle';
import { Palette } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -95,13 +96,24 @@ function LegendColors({
);
};
// Handle color change
const handleColorChange = (label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
};
// Handle color change (throttled to avoid excessive updates)
const handleColorChange = useMemo(
() =>
throttle((label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
}, 200), // 200ms is a good compromise between responsiveness and performance
[setCustomLegendColors],
);
// Clean up throttled handler on unmount
useEffect(() => {
return (): void => {
handleColorChange.cancel();
};
}, [handleColorChange]);
// Reset to default color
const resetToDefault = (label: string): void => {

View File

@@ -169,6 +169,10 @@
font-weight: 400;
line-height: 16px; /* 133.333% */
.ant-select {
width: 100%;
}
.ant-select-selector {
border: none;
height: unset;

View File

@@ -2,6 +2,9 @@
import { useMemo, useRef, useState } from 'react';
import { useDrag, useDrop, XYCoord } from 'react-dnd';
import { Button, Input, InputNumber, Select, Space, Typography } from 'antd';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { Y_AXIS_UNIT_NAMES } from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { unitOptions } from 'container/NewWidget/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -204,6 +207,18 @@ function Threshold({
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]);
const unitSelectCategories = useMemo(() => {
return unitOptions(
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
);
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits]);
const unitLabel = useMemo(() => {
return Y_AXIS_UNIT_NAMES[unit as keyof typeof Y_AXIS_UNIT_NAMES];
}, [unit]);
return (
<div
ref={allowDragAndDrop ? ref : null}
@@ -313,19 +328,17 @@ function Threshold({
<ShowCaseValue value={value} className="unit-input" />
)}
{isEditMode ? (
<Select
defaultValue={unit}
options={unitOptions(
selectedGraph === PANEL_TYPES.TABLE
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
: yAxisUnit || '',
)}
<YAxisUnitSelector
value={unit}
onChange={handleUnitChange}
showSearch
className="unit-selection"
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
initialValue={unit}
categoriesOverride={unitSelectCategories}
containerClassName="unit-selection"
/>
) : (
<ShowCaseValue value={unit} className="unit-selection-prev" />
<ShowCaseValue value={unitLabel} className="unit-selection-prev" />
)}
</div>
<div className="thresholds-color-selector">
@@ -356,7 +369,10 @@ function Threshold({
)}
</div>
{isInvalidUnitComparison && (
<Typography.Text className="invalid-unit">
<Typography.Text
className="invalid-unit"
data-testid="invalid-unit-comparison"
>
Threshold unit ({unit}) is not valid in comparison with the{' '}
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
{selectedGraph === PANEL_TYPES.TABLE

View File

@@ -1,6 +1,8 @@
/* eslint-disable react/jsx-props-no-spreading */
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Y_AXIS_UNIT_NAMES } from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
@@ -14,12 +16,26 @@ jest.mock('lib/query/createTableColumnsFromQuery', () => ({
),
}));
// Mock the unitOptions function
// Mock the unitOptions function to return YAxisCategory-shaped data
jest.mock('container/NewWidget/utils', () => ({
unitOptions: jest.fn(() => [
{ value: 'none', label: 'None' },
{ value: '%', label: 'Percent (0 - 100)' },
{ value: 'ms', label: 'Milliseconds (ms)' },
{
name: 'Mock Category',
units: [
{
id: UniversalYAxisUnit.NONE,
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.NONE],
},
{
id: UniversalYAxisUnit.PERCENT,
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT],
},
{
id: UniversalYAxisUnit.MILLISECONDS,
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MILLISECONDS],
},
],
},
]),
}));
@@ -28,7 +44,7 @@ const defaultProps = {
keyIndex: 0,
thresholdOperator: '>' as const,
thresholdValue: 50,
thresholdUnit: 'none',
thresholdUnit: UniversalYAxisUnit.NONE,
thresholdColor: 'Red',
thresholdFormat: 'Text' as const,
isEditEnabled: true,
@@ -38,8 +54,11 @@ const defaultProps = {
{ value: 'memory_usage', label: 'Memory Usage' },
],
thresholdTableOptions: 'cpu_usage',
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
yAxisUnit: '%',
columnUnits: {
cpu_usage: UniversalYAxisUnit.PERCENT,
memory_usage: UniversalYAxisUnit.BYTES,
},
yAxisUnit: UniversalYAxisUnit.PERCENT,
moveThreshold: jest.fn(),
};
@@ -68,28 +87,27 @@ describe('Threshold Component Unit Validation', () => {
it('should show validation error when threshold unit is not "none" and units are incompatible', () => {
// Act - Render component with incompatible units (ms vs percent)
renderThreshold({
thresholdUnit: 'ms',
thresholdUnit: UniversalYAxisUnit.MILLISECONDS,
thresholdValue: 50,
});
const errorMessage = screen.getByTestId('invalid-unit-comparison');
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(ms\) is not valid in comparison with the column unit \(percent\)/i,
),
).toBeInTheDocument();
expect(errorMessage.textContent).toBe(
`Threshold unit (${UniversalYAxisUnit.MILLISECONDS}) is not valid in comparison with the column unit (${UniversalYAxisUnit.PERCENT})`,
);
});
it('should not show validation error when threshold unit matches column unit', () => {
// Act - Render component with matching units
renderThreshold({
thresholdUnit: 'percent',
thresholdUnit: UniversalYAxisUnit.PERCENT,
thresholdValue: 50,
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
screen.queryByTestId('invalid-unit-comparison'),
).not.toBeInTheDocument();
});
@@ -97,17 +115,16 @@ describe('Threshold Component Unit Validation', () => {
// Act - Render component for time series with incompatible units
renderThreshold({
selectedGraph: PANEL_TYPES.TIME_SERIES,
thresholdUnit: 'ms',
thresholdUnit: UniversalYAxisUnit.MILLISECONDS,
thresholdValue: 100,
yAxisUnit: 'percent',
yAxisUnit: UniversalYAxisUnit.PERCENT,
});
const errorMessage = screen.getByTestId('invalid-unit-comparison');
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(ms\) is not valid in comparison with the y-axis unit \(percent\)/i,
),
).toBeInTheDocument();
expect(errorMessage.textContent).toBe(
`Threshold unit (${UniversalYAxisUnit.MILLISECONDS}) is not valid in comparison with the y-axis unit (${UniversalYAxisUnit.PERCENT})`,
);
});
it('should not show validation error for time series graph when threshold unit is "none"', () => {
@@ -116,43 +133,39 @@ describe('Threshold Component Unit Validation', () => {
selectedGraph: PANEL_TYPES.TIME_SERIES,
thresholdUnit: 'none',
thresholdValue: 100,
yAxisUnit: 'percent',
yAxisUnit: UniversalYAxisUnit.PERCENT,
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
screen.queryByTestId('invalid-unit-comparison'),
).not.toBeInTheDocument();
});
it('should not show validation error when threshold unit is compatible with column unit', () => {
// Act - Render component with compatible units (both in same category - Time)
renderThreshold({
thresholdUnit: 's',
thresholdUnit: UniversalYAxisUnit.SECONDS,
thresholdValue: 100,
columnUnits: { cpu_usage: 'ms' },
columnUnits: { cpu_usage: UniversalYAxisUnit.MILLISECONDS },
thresholdTableOptions: 'cpu_usage',
});
// Assert - No validation error should be displayed
expect(
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
screen.queryByTestId('invalid-unit-comparison'),
).not.toBeInTheDocument();
});
it('should show validation error when threshold unit is in different category than column unit', () => {
// Act - Render component with units from different categories
renderThreshold({
thresholdUnit: 'bytes',
thresholdUnit: UniversalYAxisUnit.BYTES,
thresholdValue: 100,
yAxisUnit: 'percent',
yAxisUnit: UniversalYAxisUnit.PERCENT,
});
const errorMessage = screen.getByTestId('invalid-unit-comparison');
// Assert - Validation error should be displayed
expect(
screen.getByText(
/Threshold unit \(bytes\) is not valid in comparison with the column unit \(percent\)/i,
),
).toBeInTheDocument();
expect(errorMessage.textContent).toBe(
`Threshold unit (${UniversalYAxisUnit.BYTES}) is not valid in comparison with the column unit (${UniversalYAxisUnit.PERCENT})`,
);
});
});

View File

@@ -1,9 +1,12 @@
import { Layout } from 'react-grid-layout';
import { DefaultOptionType } from 'antd/es/select';
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import {
UniversalYAxisUnit,
YAxisCategory,
YAxisSource,
} from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import {
initialQueryBuilderFormValuesMap,
@@ -606,7 +609,7 @@ export const PANEL_TYPE_TO_QUERY_TYPES: Record<PANEL_TYPES, EQueryType[]> = {
*/
export const getCategorySelectOptionByName = (
name?: YAxisCategoryNames,
): DefaultOptionType[] => {
): { name: string; id: UniversalYAxisUnit }[] => {
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
if (!categories.length) {
return [];
@@ -615,8 +618,8 @@ export const getCategorySelectOptionByName = (
categories
.find((category) => category.name === name)
?.units.map((unit) => ({
label: unit.name,
value: unit.id,
name: unit.name,
id: unit.id,
})) || []
);
};
@@ -628,19 +631,19 @@ export const getCategorySelectOptionByName = (
* select options. If a valid category is found, it filters the supported categories
* to return only the options for the matched category.
*/
export const unitOptions = (columnUnit: string): DefaultOptionType[] => {
export const unitOptions = (columnUnit: string): YAxisCategory[] => {
const category = getCategoryName(columnUnit);
if (isEmpty(category)) {
return categoryToSupport.map((category) => ({
label: category,
options: getCategorySelectOptionByName(category),
name: category,
units: getCategorySelectOptionByName(category),
}));
}
return categoryToSupport
.filter((supportedCategory) => supportedCategory === category)
.map((filteredCategory) => ({
label: filteredCategory,
options: getCategorySelectOptionByName(filteredCategory),
name: filteredCategory,
units: getCategorySelectOptionByName(filteredCategory),
}));
};

View File

@@ -1,32 +1,37 @@
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import {
getGetMetricMetadataQueryKey,
getMetricMetadata,
} from 'api/generated/services/metrics';
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
type QueryResult = UseQueryResult<
SuccessResponseV2<MetricMetadataResponse>,
Error
>;
type QueryResult = UseQueryResult<GetMetricMetadata200, Error>;
type UseGetMultipleMetrics = (
metricNames: string[],
options?: UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>,
options?: UseQueryOptions<GetMetricMetadata200, Error>,
headers?: Record<string, string>,
) => QueryResult[];
export const useGetMultipleMetrics: UseGetMultipleMetrics = (
metricNames,
options,
headers,
) =>
useQueries(
metricNames.map(
(metricName) =>
({
queryKey: [REACT_QUERY_KEY.GET_METRIC_METADATA, metricName],
queryFn: ({ signal }) => getMetricMetadata(metricName, signal, headers),
queryKey: getGetMetricMetadataQueryKey({
metricName,
}),
queryFn: ({ signal }) =>
getMetricMetadata(
{
metricName,
},
signal,
),
...options,
} as UseQueryOptions<SuccessResponseV2<MetricMetadataResponse>, Error>),
} as UseQueryOptions<GetMetricMetadata200, Error>),
),
);

View File

@@ -1,7 +1,7 @@
import { renderHook } from '@testing-library/react';
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { useGetMetrics } from 'container/MetricsExplorer/Explorer/utils';
import { MetricMetadata } from 'types/api/metricsExplorer/v2/getMetricMetadata';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
@@ -24,13 +24,13 @@ const mockUseGetMetrics = useGetMetrics as jest.MockedFunction<
const MOCK_METRIC_1 = {
unit: UniversalYAxisUnit.BYTES,
} as MetricMetadata;
} as MetricsexplorertypesMetricMetadataDTO;
const MOCK_METRIC_2 = {
unit: UniversalYAxisUnit.SECONDS,
} as MetricMetadata;
} as MetricsexplorertypesMetricMetadataDTO;
const MOCK_METRIC_3 = {
unit: '',
} as MetricMetadata;
} as MetricsexplorertypesMetricMetadataDTO;
function createMockCurrentQuery(
queryType: EQueryType,

View File

@@ -1,14 +1,22 @@
.tooltip-plugin-container {
top: 0;
left: 0;
width: 100%;
z-index: 1070;
white-space: pre;
border-radius: 4px;
position: fixed;
overflow: auto;
transform: translate(-1000px, -1000px); // hide the tooltip initially
opacity: 0;
pointer-events: none;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&.visible {
opacity: 1;
pointer-events: all;
}
}

View File

@@ -339,8 +339,9 @@ export default function TooltipPlugin({
return;
}
const layout = layoutRef.current;
layout.observer.disconnect();
if (containerRef.current) {
layout.observer.disconnect();
layout.observer.observe(containerRef.current);
const { width, height } = containerRef.current.getBoundingClientRect();
layout.width = width;
@@ -351,24 +352,28 @@ export default function TooltipPlugin({
}
}, [isHovering, plot]);
if (!plot || !isHovering) {
if (!plot) {
return null;
}
return createPortal(
<div
className={cx('tooltip-plugin-container', { pinned: isPinned })}
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isHovering,
})}
style={{
...style,
maxWidth: `${maxWidth}px`,
maxHeight: `${maxHeight}px`,
width: '100%',
}}
aria-live="polite"
aria-atomic="true"
aria-hidden={!isHovering}
ref={containerRef}
data-testid="tooltip-plugin-container"
>
{contents}
{isHovering ? contents : null}
</div>,
portalRoot.current,
);

View File

@@ -187,9 +187,7 @@ describe('TooltipPlugin', () => {
canPinTooltip: true,
});
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.classList.contains('pinned')).toBe(false);
act(() => {
@@ -197,11 +195,9 @@ describe('TooltipPlugin', () => {
});
return waitFor(() => {
const updated = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(updated).not.toBeNull();
expect(updated?.classList.contains('pinned')).toBe(true);
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.classList.contains('pinned')).toBe(true);
});
});
@@ -249,7 +245,13 @@ describe('TooltipPlugin', () => {
await user.click(button);
await waitFor(() => {
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -292,12 +294,16 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
jest.useRealTimers();
});
it('unpins the tooltip on outside mousedown', () => {
it('unpins the tooltip on outside mousedown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -335,12 +341,19 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip on outside keydown', () => {
it('unpins the tooltip on outside keydown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -380,7 +393,13 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});

View File

@@ -22,7 +22,8 @@ type RootConfig struct {
}
type OrgConfig struct {
Name string `mapstructure:"name"`
ID valuer.UUID `mapstructure:"id"`
Name string `mapstructure:"name"`
}
type PasswordConfig struct {

View File

@@ -78,6 +78,43 @@ func (s *service) Stop(ctx context.Context) error {
}
func (s *service) reconcile(ctx context.Context) error {
if !s.config.Org.ID.IsZero() {
return s.reconcileWithOrgID(ctx)
}
return s.reconcileByName(ctx)
}
func (s *service) reconcileWithOrgID(ctx context.Context) error {
org, err := s.orgGetter.Get(ctx, s.config.Org.ID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err // something really went wrong
}
// org was not found using id check if we can find an org using name
existingOrgByName, nameErr := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if nameErr != nil && !errors.Ast(nameErr, errors.TypeNotFound) {
return nameErr // something really went wrong
}
// we found an org using name
if existingOrgByName != nil {
// the existing org has the same name as config but org id is different inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, existingOrgByName.ID.StringValue(), s.config.Org.ID.StringValue())
}
// default - we did not found any org using id and name both - create a new org
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
_, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileByName(ctx context.Context) error {
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {

View File

@@ -41,6 +41,21 @@ func NewOrganization(displayName string, name string) *Organization {
}
}
func NewOrganizationWithID(id valuer.UUID, displayName string, name string) *Organization {
return &Organization{
Identifiable: Identifiable{
ID: id,
},
TimeAuditable: TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
DisplayName: displayName,
Key: NewOrganizationKey(id),
}
}
func NewOrganizationKey(orgID valuer.UUID) uint32 {
hasher := fnv.New32a()