-
-
-
+
{isError && error &&
}
diff --git a/frontend/src/hooks/dashboard/__test__/useVariablesFromUrl.test.tsx b/frontend/src/hooks/dashboard/__test__/useVariablesFromUrl.test.tsx
new file mode 100644
index 00000000000..57b8bfb200e
--- /dev/null
+++ b/frontend/src/hooks/dashboard/__test__/useVariablesFromUrl.test.tsx
@@ -0,0 +1,253 @@
+import { act, renderHook } from '@testing-library/react';
+import { QueryParams } from 'constants/query';
+import { createMemoryHistory } from 'history';
+import { Router } from 'react-router-dom';
+import { IDashboardVariable } from 'types/api/dashboard/getAll';
+
+import useVariablesFromUrl, {
+ LocalStoreDashboardVariables,
+} from '../useVariablesFromUrl';
+
+describe('useVariablesFromUrl', () => {
+ it('should initialize with empty variables when no URL params exist', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/'],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ expect(result.current.getUrlVariables()).toEqual({});
+ });
+
+ it('should correctly parse variables from URL', () => {
+ const mockVariables = {
+ var1: 'value1',
+ var2: ['value2', 'value3'],
+ var3: 123,
+ };
+
+ const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ expect(result.current.getUrlVariables()).toEqual(mockVariables);
+ });
+
+ it('should handle malformed URL parameters gracefully', () => {
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=invalid-json`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ // Should return empty object when JSON parsing fails
+ expect(result.current.getUrlVariables()).toEqual({});
+ });
+
+ it('should set variables to URL correctly', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/'],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const mockVariables: LocalStoreDashboardVariables = {
+ var1: 'value1',
+ var2: ['value2', 'value3'],
+ };
+
+ act(() => {
+ result.current.setUrlVariables(mockVariables);
+ });
+
+ // Check if the URL was updated correctly
+ const searchParams = new URLSearchParams(history.location.search);
+ const urlVariables = searchParams.get(QueryParams.variables);
+
+ expect(urlVariables).toBeTruthy();
+ expect(JSON.parse(decodeURIComponent(urlVariables || ''))).toEqual(
+ mockVariables,
+ );
+ });
+
+ it('should remove variables param from URL when empty object is provided', () => {
+ const mockVariables = {
+ var1: 'value1',
+ var2: ['value2', 'value3'],
+ };
+
+ const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ act(() => {
+ result.current.setUrlVariables({});
+ });
+
+ // Check if the URL param was removed
+ const searchParams = new URLSearchParams(history.location.search);
+ expect(searchParams.has(QueryParams.variables)).toBe(false);
+ });
+
+ it('should update a specific variable correctly', () => {
+ const initialVariables = {
+ var1: 'value1',
+ var2: ['value2', 'value3'],
+ };
+
+ const encodedVariables = encodeURIComponent(JSON.stringify(initialVariables));
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const newValue: IDashboardVariable['selectedValue'] = 'updated-value';
+
+ act(() => {
+ result.current.updateUrlVariable('var1', newValue);
+ });
+
+ // Check if only the specified variable was updated
+ const updatedVariables = result.current.getUrlVariables();
+ expect(updatedVariables.var1).toEqual(newValue);
+ expect(updatedVariables.var2).toEqual(initialVariables.var2);
+ });
+
+ it('should preserve other URL parameters when updating variables', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/?otherParam=value'],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const mockVariables: LocalStoreDashboardVariables = {
+ var1: 'value1',
+ };
+
+ act(() => {
+ result.current.setUrlVariables(mockVariables);
+ });
+
+ // Check if other params are preserved
+ const searchParams = new URLSearchParams(history.location.search);
+ expect(searchParams.get('otherParam')).toBe('value');
+ expect(searchParams.has(QueryParams.variables)).toBe(true);
+ });
+
+ it('should handle different variable value types correctly', () => {
+ const mockVariables: LocalStoreDashboardVariables = {
+ stringVar: 'production',
+ numberVar: 123,
+ booleanVar: true,
+ arrayVar: ['service1', 'service2'],
+ mixedArrayVar: ['string', 456, false],
+ nullVar: null,
+ };
+
+ const encodedVariables = encodeURIComponent(JSON.stringify(mockVariables));
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const urlVariables = result.current.getUrlVariables();
+ expect(urlVariables.stringVar).toBe('production');
+ expect(urlVariables.numberVar).toBe(123);
+ expect(urlVariables.booleanVar).toBe(true);
+ expect(urlVariables.arrayVar).toEqual(['service1', 'service2']);
+ expect(urlVariables.mixedArrayVar).toEqual(['string', 456, false]);
+ expect(urlVariables.nullVar).toBeNull();
+ });
+
+ it('should handle edge cases in URL variable parsing', () => {
+ const edgeCaseVariables = {
+ emptyString: '',
+ emptyArray: [],
+ singleItemArray: ['solo'],
+ };
+
+ const encodedVariables = encodeURIComponent(
+ JSON.stringify(edgeCaseVariables),
+ );
+ const history = createMemoryHistory({
+ initialEntries: [`/?${QueryParams.variables}=${encodedVariables}`],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const urlVariables = result.current.getUrlVariables();
+ expect(urlVariables.emptyString).toBe('');
+ expect(urlVariables.emptyArray).toEqual([]);
+ expect(urlVariables.singleItemArray).toEqual(['solo']);
+ expect(urlVariables.undefinedVar).toBeUndefined();
+ });
+
+ it('should update variables with array values correctly', () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/'],
+ });
+
+ const { result } = renderHook(() => useVariablesFromUrl(), {
+ wrapper: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ });
+
+ const arrayValue: IDashboardVariable['selectedValue'] = [
+ 'value1',
+ 'value2',
+ 'value3',
+ ];
+
+ act(() => {
+ result.current.updateUrlVariable('multiSelectVar', arrayValue);
+ });
+
+ const updatedVariables = result.current.getUrlVariables();
+ expect(updatedVariables.multiSelectVar).toEqual(arrayValue);
+ });
+});
diff --git a/frontend/src/hooks/dashboard/useVariablesFromUrl.tsx b/frontend/src/hooks/dashboard/useVariablesFromUrl.tsx
new file mode 100644
index 00000000000..7e845ae6913
--- /dev/null
+++ b/frontend/src/hooks/dashboard/useVariablesFromUrl.tsx
@@ -0,0 +1,91 @@
+import * as Sentry from '@sentry/react';
+import { QueryParams } from 'constants/query';
+import useUrlQuery from 'hooks/useUrlQuery';
+import { useCallback } from 'react';
+import { useHistory } from 'react-router-dom';
+import { IDashboardVariable } from 'types/api/dashboard/getAll';
+
+export interface LocalStoreDashboardVariables {
+ [name: string]:
+ | IDashboardVariable['selectedValue'][]
+ | IDashboardVariable['selectedValue'];
+}
+
+interface UseVariablesFromUrlReturn {
+ getUrlVariables: () => LocalStoreDashboardVariables;
+ setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
+ updateUrlVariable: (
+ name: string,
+ selectedValue: IDashboardVariable['selectedValue'],
+ ) => void;
+}
+
+const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
+ const urlQuery = useUrlQuery();
+ const history = useHistory();
+
+ const getUrlVariables = useCallback((): LocalStoreDashboardVariables => {
+ const variablesParam = urlQuery.get(QueryParams.variables);
+
+ if (!variablesParam) {
+ return {};
+ }
+
+ try {
+ return JSON.parse(decodeURIComponent(variablesParam));
+ } catch (error) {
+ Sentry.captureEvent({
+ message: `Failed to parse dashboard variables from URL: ${error}`,
+ level: 'error',
+ });
+ return {};
+ }
+ }, [urlQuery]);
+
+ const setUrlVariables = useCallback(
+ (variables: LocalStoreDashboardVariables): void => {
+ const params = new URLSearchParams(urlQuery.toString());
+
+ if (Object.keys(variables).length === 0) {
+ params.delete(QueryParams.variables);
+ } else {
+ try {
+ const encodedVariables = encodeURIComponent(JSON.stringify(variables));
+ params.set(QueryParams.variables, encodedVariables);
+ } catch (error) {
+ Sentry.captureEvent({
+ message: `Failed to serialize dashboard variables for URL: ${error}`,
+ level: 'error',
+ });
+ }
+ }
+
+ history.replace({
+ search: params.toString(),
+ });
+ },
+ [history, urlQuery],
+ );
+
+ const updateUrlVariable = useCallback(
+ (name: string, selectedValue: IDashboardVariable['selectedValue']): void => {
+ const currentVariables = getUrlVariables();
+
+ const updatedVariables = {
+ ...currentVariables,
+ [name]: selectedValue,
+ };
+
+ setUrlVariables(updatedVariables as LocalStoreDashboardVariables);
+ },
+ [getUrlVariables, setUrlVariables],
+ );
+
+ return {
+ getUrlVariables,
+ setUrlVariables,
+ updateUrlVariable,
+ };
+};
+
+export default useVariablesFromUrl;
diff --git a/frontend/src/hooks/queryBuilder/__tests__/useQueryBuilderOperations.test.ts b/frontend/src/hooks/queryBuilder/__tests__/useQueryBuilderOperations.test.ts
index fe0b2e96bde..0e7318ce705 100644
--- a/frontend/src/hooks/queryBuilder/__tests__/useQueryBuilderOperations.test.ts
+++ b/frontend/src/hooks/queryBuilder/__tests__/useQueryBuilderOperations.test.ts
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react';
-import { ENTITY_VERSION_V4 } from 'constants/app';
+import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
@@ -33,6 +33,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
} as BaseAutocompleteData,
timeAggregation: MetricAggregateOperator.AVG,
spaceAggregation: '',
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.AVG,
+ metricName: 'test_metric',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
having: [],
limit: null,
queryName: 'test_query',
@@ -131,5 +139,187 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
}),
);
});
+
+ it('should preserve aggregation operators when metric type remains the same (GAUGE to GAUGE)', () => {
+ const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
+ const newAttribute: BaseAutocompleteData = {
+ key: 'new_gauge_metric',
+ dataType: DataTypes.Float64,
+ type: ATTRIBUTE_TYPES.GAUGE,
+ };
+
+ act(() => {
+ result.current.handleChangeAggregatorAttribute(newAttribute);
+ });
+
+ expect(mockHandleSetQueryData).toHaveBeenCalledWith(
+ 0,
+ expect.objectContaining({
+ aggregateAttribute: newAttribute,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.AVG,
+ metricName: 'new_gauge_metric',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
+ }),
+ );
+ });
+
+ it('should reset aggregation operators when metric type changes (GAUGE to SUM) with v5 from start', () => {
+ const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
+ const newAttribute: BaseAutocompleteData = {
+ key: 'new_sum_metric',
+ dataType: DataTypes.Float64,
+ type: ATTRIBUTE_TYPES.SUM,
+ };
+
+ act(() => {
+ result.current.handleChangeAggregatorAttribute(newAttribute);
+ });
+
+ expect(mockHandleSetQueryData).toHaveBeenCalledWith(
+ 0,
+ expect.objectContaining({
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.RATE,
+ metricName: 'new_sum_metric',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
+ }),
+ );
+ });
+
+ it('should preserve aggregation operators when metric type remains the same (SUM to SUM)', () => {
+ const sumMockQuery: IBuilderQuery = {
+ ...defaultMockQuery,
+ aggregateAttribute: undefined,
+ aggregateOperator: '',
+ timeAggregation: undefined,
+ spaceAggregation: undefined,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.RATE,
+ metricName: 'original_sum_metric',
+ temporality: '',
+ spaceAggregation: MetricAggregateOperator.SUM,
+ },
+ ],
+ };
+
+ const { result } = renderHook(() =>
+ useQueryOperations({
+ query: sumMockQuery,
+ index: 0,
+ entityVersion: ENTITY_VERSION_V5,
+ }),
+ );
+
+ const newAttribute: BaseAutocompleteData = {
+ key: 'new_sum_metric',
+ dataType: DataTypes.Float64,
+ type: ATTRIBUTE_TYPES.SUM,
+ };
+
+ act(() => {
+ result.current.handleChangeAggregatorAttribute(newAttribute);
+ });
+
+ expect(mockHandleSetQueryData).toHaveBeenCalledWith(
+ 0,
+ expect.objectContaining({
+ aggregateAttribute: newAttribute,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.RATE,
+ metricName: 'new_sum_metric',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
+ }),
+ );
+ });
+
+ it('should reset operators when going from gauge -> empty -> gauge', () => {
+ // Start with a gauge metric
+ const gaugeQuery: IBuilderQuery = {
+ ...defaultMockQuery,
+ aggregateAttribute: {
+ key: 'original_gauge',
+ dataType: DataTypes.Float64,
+ type: ATTRIBUTE_TYPES.GAUGE,
+ } as BaseAutocompleteData,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.COUNT_DISTINCT,
+ metricName: 'original_gauge',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
+ };
+ const { result, rerender } = renderHook(
+ ({ query }) =>
+ useQueryOperations({
+ query,
+ index: 0,
+ entityVersion: ENTITY_VERSION_V5,
+ }),
+ {
+ initialProps: { query: gaugeQuery },
+ },
+ );
+
+ // Re-render with empty attribute
+ const emptyAttribute: BaseAutocompleteData = {
+ key: '',
+ dataType: DataTypes.Float64,
+ type: '',
+ };
+ const emptyQuery: IBuilderQuery = {
+ ...defaultMockQuery,
+ aggregateAttribute: emptyAttribute,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.COUNT,
+ metricName: '',
+ temporality: '',
+ spaceAggregation: MetricAggregateOperator.SUM,
+ },
+ ],
+ };
+ rerender({ query: emptyQuery });
+
+ // Change to a new gauge metric
+ const newGaugeAttribute: BaseAutocompleteData = {
+ key: 'new_gauge',
+ dataType: DataTypes.Float64,
+ type: ATTRIBUTE_TYPES.GAUGE,
+ };
+ act(() => {
+ result.current.handleChangeAggregatorAttribute(newGaugeAttribute);
+ });
+
+ expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
+ 0,
+ expect.objectContaining({
+ aggregateAttribute: newGaugeAttribute,
+ aggregations: [
+ {
+ timeAggregation: MetricAggregateOperator.AVG,
+ metricName: 'new_gauge',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ],
+ }),
+ );
+ });
});
});
diff --git a/frontend/src/hooks/queryBuilder/useGetAggregateKeys.ts b/frontend/src/hooks/queryBuilder/useGetAggregateKeys.ts
index 6041a2c2c0a..537fac37f91 100644
--- a/frontend/src/hooks/queryBuilder/useGetAggregateKeys.ts
+++ b/frontend/src/hooks/queryBuilder/useGetAggregateKeys.ts
@@ -42,8 +42,8 @@ export const useGetAggregateKeys: UseGetAttributeKeys = (
}, [options?.queryKey, requestData, isInfraMonitoring, infraMonitoringEntity]);
return useQuery
| ErrorResponse>({
- queryKey,
queryFn: () => getAggregateKeys(requestData),
...options,
+ queryKey,
});
};
diff --git a/frontend/src/hooks/queryBuilder/useQueryBuilderOperations.ts b/frontend/src/hooks/queryBuilder/useQueryBuilderOperations.ts
index 4a8fbd83989..3b9ede45e4e 100644
--- a/frontend/src/hooks/queryBuilder/useQueryBuilderOperations.ts
+++ b/frontend/src/hooks/queryBuilder/useQueryBuilderOperations.ts
@@ -73,6 +73,28 @@ export const useQueryOperations: UseQueryOperations = ({
SelectOption[]
>([]);
+ const [previousMetricInfo, setPreviousMetricInfo] = useState<{
+ name: string;
+ type: string;
+ } | null>(null);
+
+ useEffect(() => {
+ if (query) {
+ const metricName =
+ query.aggregateAttribute?.key ||
+ (query.aggregations?.[0] as MetricAggregation)?.metricName;
+ const metricType = query.aggregateAttribute?.type;
+ if (metricName && metricType) {
+ setPreviousMetricInfo({
+ name: metricName,
+ type: metricType,
+ });
+ } else {
+ setPreviousMetricInfo(null);
+ }
+ }
+ }, [query]);
+
const { dataSource, aggregateOperator } = query;
const getNewListOfAdditionalFilters = useCallback(
@@ -214,12 +236,19 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
- (value: BaseAutocompleteData, isEditMode?: boolean): void => {
+ (
+ value: BaseAutocompleteData,
+ isEditMode?: boolean,
+ attributeKeys?: BaseAutocompleteData[],
+ ): 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
@@ -267,58 +296,85 @@ export const useQueryOperations: UseQueryOperations = ({
}
if (!isEditMode) {
- if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
- newQuery.aggregations = [
- {
- timeAggregation: MetricAggregateOperator.RATE,
- metricName: newQuery.aggregateAttribute?.key || '',
- temporality: '',
- spaceAggregation: '',
- },
- ];
- } else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
- newQuery.aggregations = [
- {
- timeAggregation: MetricAggregateOperator.AVG,
- metricName: newQuery.aggregateAttribute?.key || '',
- temporality: '',
- spaceAggregation: '',
- },
- ];
- } else {
- newQuery.aggregations = [
- {
- timeAggregation: '',
- metricName: newQuery.aggregateAttribute?.key || '',
- temporality: '',
- spaceAggregation: '',
- },
- ];
- }
-
- newQuery.aggregateOperator = '';
- newQuery.spaceAggregation = '';
-
- // 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 (isEmpty(newQuery.aggregateAttribute?.type)) {
- if (!isEmpty(newQuery.aggregateAttribute?.key)) {
+ // Get current metric info
+ const currentMetricType = newQuery.aggregateAttribute?.type || '';
+
+ const prevMetricType = previousMetricInfo?.type
+ ? previousMetricInfo.type
+ : getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
+
+ // Check if metric type has changed by comparing with tracked previous values
+ const metricTypeChanged =
+ !prevMetricType || !currentMetricType
+ ? false
+ : prevMetricType !== currentMetricType;
+
+ // Only reset operators if metric type has changed or if this is the first metric selection
+ if (metricTypeChanged || !previousMetricInfo) {
+ if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.SUM) {
+ newQuery.aggregations = [
+ {
+ timeAggregation: MetricAggregateOperator.RATE,
+ metricName: newQuery.aggregateAttribute?.key || '',
+ temporality: '',
+ spaceAggregation: '',
+ },
+ ];
+ } else if (newQuery.aggregateAttribute?.type === ATTRIBUTE_TYPES.GAUGE) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
- spaceAggregation: MetricAggregateOperator.AVG,
+ spaceAggregation: '',
},
];
} else {
newQuery.aggregations = [
{
- timeAggregation: MetricAggregateOperator.COUNT,
+ timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
- spaceAggregation: MetricAggregateOperator.SUM,
+ spaceAggregation: '',
+ },
+ ];
+ }
+
+ newQuery.aggregateOperator = '';
+ newQuery.spaceAggregation = '';
+
+ // 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 (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.COUNT,
+ metricName: newQuery.aggregateAttribute?.key || '',
+ temporality: '',
+ spaceAggregation: MetricAggregateOperator.SUM,
+ },
+ ];
+ }
+ }
+ } else {
+ // If metric type hasn't changed, preserve existing aggregations but update metric name
+ const currentAggregation = query.aggregations?.[0] as MetricAggregation;
+ if (currentAggregation) {
+ newQuery.aggregations = [
+ {
+ ...currentAggregation,
+ metricName: newQuery.aggregateAttribute?.key || '',
},
];
}
@@ -334,6 +390,7 @@ export const useQueryOperations: UseQueryOperations = ({
handleSetQueryData,
index,
handleMetricAggregateAtributeTypes,
+ previousMetricInfo,
],
);
diff --git a/frontend/src/hooks/querySuggestions/useGetQueryKeySuggestions.ts b/frontend/src/hooks/querySuggestions/useGetQueryKeySuggestions.ts
index 7d43b08227e..643c2c58c12 100644
--- a/frontend/src/hooks/querySuggestions/useGetQueryKeySuggestions.ts
+++ b/frontend/src/hooks/querySuggestions/useGetQueryKeySuggestions.ts
@@ -55,7 +55,6 @@ export const useGetQueryKeySuggestions: UseGetQueryKeySuggestions = (
signalSource,
]);
return useQuery, AxiosError>({
- queryKey,
queryFn: () =>
getKeySuggestions({
signal,
@@ -66,5 +65,6 @@ export const useGetQueryKeySuggestions: UseGetQueryKeySuggestions = (
signalSource,
}),
...options,
+ queryKey,
});
};
diff --git a/frontend/src/hooks/saveViews/useGetAllViews.ts b/frontend/src/hooks/saveViews/useGetAllViews.ts
index cb5e62dd0be..e511f75750a 100644
--- a/frontend/src/hooks/saveViews/useGetAllViews.ts
+++ b/frontend/src/hooks/saveViews/useGetAllViews.ts
@@ -6,8 +6,10 @@ import { DataSource } from 'types/common/queryBuilder';
export const useGetAllViews = (
sourcepage: DataSource | 'meter',
+ enabled?: boolean,
): UseQueryResult, AxiosError> =>
useQuery, AxiosError>({
queryKey: [{ sourcepage }],
queryFn: () => getAllViews(sourcepage as DataSource),
+ ...(enabled !== undefined ? { enabled } : {}),
});
diff --git a/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx b/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx
index 9948a7a195c..d6e08012784 100644
--- a/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx
+++ b/frontend/src/hooks/useActiveLicenseV3/useActiveLicenseV3.tsx
@@ -10,6 +10,7 @@ const useActiveLicenseV3 = (isLoggedIn: boolean): UseLicense =>
queryFn: getActive,
queryKey: [REACT_QUERY_KEY.GET_ACTIVE_LICENSE_V3],
enabled: !!isLoggedIn,
+ retry: false,
});
type UseLicense = UseQueryResult, APIError>;
diff --git a/frontend/src/hooks/useHandleExplorerTabChange.ts b/frontend/src/hooks/useHandleExplorerTabChange.ts
index cf06337867f..88f95ae08bc 100644
--- a/frontend/src/hooks/useHandleExplorerTabChange.ts
+++ b/frontend/src/hooks/useHandleExplorerTabChange.ts
@@ -9,6 +9,12 @@ import { DataSource } from 'types/common/queryBuilder';
import { useGetSearchQueryParam } from './queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
+export interface ICurrentQueryData {
+ name: string;
+ id: string;
+ query: Query;
+}
+
export const useHandleExplorerTabChange = (): {
handleExplorerTabChange: (
type: string,
@@ -87,9 +93,3 @@ export const useHandleExplorerTabChange = (): {
return { handleExplorerTabChange };
};
-
-interface ICurrentQueryData {
- name: string;
- id: string;
- query: Query;
-}
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 25cfbfb5aba..4ed5800fb1a 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -11,6 +11,7 @@ import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store from 'store';
+import APIError from 'types/api/error';
const queryClient = new QueryClient({
defaultOptions: {
@@ -19,9 +20,13 @@ const queryClient = new QueryClient({
retry(failureCount, error): boolean {
if (
// in case of manually throwing errors please make sure to send error.response.status
- error instanceof AxiosError &&
- error.response?.status &&
- (error.response?.status >= 400 || error.response?.status <= 499)
+ (error instanceof AxiosError &&
+ error.response?.status &&
+ error.response?.status >= 400 &&
+ error.response?.status <= 499) ||
+ (error instanceof APIError &&
+ error.getHttpStatusCode() >= 400 &&
+ error.getHttpStatusCode() <= 499)
) {
return false;
}
diff --git a/frontend/src/lib/dashboard/getQueryResults.ts b/frontend/src/lib/dashboard/getQueryResults.ts
index 517bd38f9f4..3bdcdb53a24 100644
--- a/frontend/src/lib/dashboard/getQueryResults.ts
+++ b/frontend/src/lib/dashboard/getQueryResults.ts
@@ -21,13 +21,14 @@ import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { isEmpty } from 'lodash-es';
import { SuccessResponse, SuccessResponseV2, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
-import { Query } from 'types/api/queryBuilder/queryBuilderData';
+import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
import { QueryData } from 'types/api/widgets/getQuery';
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
+import { EQueryType } from 'types/common/dashboard';
/**
* Validates if metric name is available for METRICS data source
@@ -75,14 +76,13 @@ const getQueryDataSource = (
const getLegendForSingleAggregation = (
queryData: QueryData,
- payloadQuery: Query,
+ allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
- // Find the corresponding query in payloadQuery
- const queryItem = payloadQuery.builder?.queryData.find(
+ const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -107,14 +107,13 @@ const getLegendForSingleAggregation = (
const getLegendForMultipleAggregations = (
queryData: QueryData,
- payloadQuery: Query,
+ allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
- // Find the corresponding query in payloadQuery
- const queryItem = payloadQuery.builder?.queryData.find(
+ const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -142,15 +141,23 @@ export const getLegend = (
payloadQuery: Query,
labelName: string,
) => {
- const aggregationPerQuery = payloadQuery?.builder?.queryData.reduce(
- (acc, query) => {
- if (query.queryName === queryData.queryName) {
- acc[query.queryName] = createAggregation(query);
- }
- return acc;
- },
- {},
- );
+ // For non-query builder queries, return the label name directly
+ if (payloadQuery.queryType !== EQueryType.QUERY_BUILDER) {
+ return labelName;
+ }
+
+ // Combine queryData and queryTraceOperator
+ const allQueries = [
+ ...(payloadQuery?.builder?.queryData || []),
+ ...(payloadQuery?.builder?.queryTraceOperator || []),
+ ];
+
+ const aggregationPerQuery = allQueries.reduce((acc, query) => {
+ if (query.queryName === queryData.queryName) {
+ acc[query.queryName] = createAggregation(query);
+ }
+ return acc;
+ }, {});
const metaData = queryData?.metaData;
const aggregation =
@@ -159,8 +166,8 @@ export const getLegend = (
const aggregationAlias = aggregation?.alias || '';
const aggregationExpression = aggregation?.expression || '';
- // Check if there's only one total query (queryData)
- const singleQuery = payloadQuery?.builder?.queryData?.length === 1;
+ // Check if there's only one total query
+ const singleQuery = allQueries.length === 1;
const singleAggregation =
aggregationPerQuery?.[metaData?.queryName]?.length === 1;
@@ -168,7 +175,7 @@ export const getLegend = (
return singleQuery
? getLegendForSingleAggregation(
queryData,
- payloadQuery,
+ allQueries,
aggregationAlias,
aggregationExpression,
labelName,
@@ -176,7 +183,7 @@ export const getLegend = (
)
: getLegendForMultipleAggregations(
queryData,
- payloadQuery,
+ allQueries,
aggregationAlias,
aggregationExpression,
labelName,
diff --git a/frontend/src/lib/query/createTableColumnsFromQuery.ts b/frontend/src/lib/query/createTableColumnsFromQuery.ts
index 6c9609e4069..53d71cf6281 100644
--- a/frontend/src/lib/query/createTableColumnsFromQuery.ts
+++ b/frontend/src/lib/query/createTableColumnsFromQuery.ts
@@ -662,21 +662,23 @@ const generateTableColumns = (
*
* @param columnKey - The column identifier (could be queryName.expression or queryName)
* @param columnUnits - The column units mapping
- * @returns The unit string or undefined if not found
+ * @returns The unit string (none if the unit is set to empty string) or undefined if not found
*/
export const getColumnUnit = (
columnKey: string,
columnUnits: Record,
): string | undefined => {
// First try the exact match (new syntax: queryName.expression)
- if (columnUnits[columnKey]) {
- return columnUnits[columnKey];
+ if (columnUnits[columnKey] !== undefined) {
+ return columnUnits[columnKey] || 'none';
}
// Fallback to old syntax: extract queryName from queryName.expression
if (columnKey.includes('.')) {
const queryName = columnKey.split('.')[0];
- return columnUnits[queryName];
+ if (columnUnits[queryName] !== undefined) {
+ return columnUnits[queryName] || 'none';
+ }
}
return undefined;
diff --git a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts
index 8a8d60bc821..f3fb72f38ef 100644
--- a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts
+++ b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts
@@ -36,6 +36,7 @@ import { getYAxisScale } from './utils/getYAxisScale';
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
_tooltipCleanup?: () => void;
+ _legendElementCleanup?: Array<() => void>;
}
export interface GetUPlotChartOptions {
@@ -46,6 +47,7 @@ export interface GetUPlotChartOptions {
panelType?: PANEL_TYPES;
onDragSelect?: (startTime: number, endTime: number) => void;
yAxisUnit?: string;
+ decimalPrecision?: PrecisionOption;
onClickHandler?: OnClickPluginOpts['onClick'];
graphsVisibilityStates?: boolean[];
setGraphsVisibilityStates?: FullViewProps['setGraphsVisibilityStates'];
@@ -191,6 +193,7 @@ export const getUPlotChartOptions = ({
apiResponse,
onDragSelect,
yAxisUnit,
+ decimalPrecision,
minTimeScale,
maxTimeScale,
onClickHandler = _noop,
@@ -282,10 +285,11 @@ export const getUPlotChartOptions = ({
cursor: {
lock: false,
focus: {
- prox: 1e6,
+ prox: 25,
bias: 1,
},
points: {
+ one: true,
size: (u, seriesIdx): number => u.series[seriesIdx].points.size * 3,
width: (u, seriesIdx, size): number => size / 4,
stroke: (u, seriesIdx): string =>
@@ -358,6 +362,7 @@ export const getUPlotChartOptions = ({
colorMapping,
customTooltipElement,
query: query || currentQuery,
+ decimalPrecision,
}),
onClickPlugin({
onClick: onClickHandler,
@@ -390,14 +395,25 @@ export const getUPlotChartOptions = ({
hooks: {
draw: [
(u): void => {
- if (isAnomalyRule) {
+ if (isAnomalyRule || !thresholds?.length) {
return;
}
- thresholds?.forEach((threshold) => {
+ const { ctx } = u;
+ const { left: plotLeft, width: plotWidth } = u.bbox;
+ const plotRight = plotLeft + plotWidth;
+ const canvasHeight = ctx.canvas.height;
+ const threshold90Percent = canvasHeight * 0.9;
+
+ // Single save/restore for all thresholds
+ ctx.save();
+ ctx.lineWidth = 2;
+ ctx.setLineDash([10, 5]);
+
+ for (let i = 0; i < thresholds.length; i++) {
+ const threshold = thresholds[i];
if (threshold.thresholdValue !== undefined) {
- const { ctx } = u;
- ctx.save();
+ const color = threshold.thresholdColor || 'red';
const yPos = u.valToPos(
convertValue(
threshold.thresholdValue,
@@ -407,35 +423,28 @@ export const getUPlotChartOptions = ({
'y',
true,
);
- ctx.strokeStyle = threshold.thresholdColor || 'red';
- ctx.lineWidth = 2;
- ctx.setLineDash([10, 5]);
+
+ // Draw threshold line
+ ctx.strokeStyle = color;
ctx.beginPath();
- const plotLeft = u.bbox.left; // left edge of the plot area
- const plotRight = plotLeft + u.bbox.width; // right edge of the plot area
ctx.moveTo(plotLeft, yPos);
ctx.lineTo(plotRight, yPos);
ctx.stroke();
- // Text configuration
+
+ // Draw threshold label if present
if (threshold.thresholdLabel) {
- const text = threshold.thresholdLabel;
- const textX = plotRight - ctx.measureText(text).width - 20;
- const canvasHeight = ctx.canvas.height;
+ const textWidth = ctx.measureText(threshold.thresholdLabel).width;
+ const textX = plotRight - textWidth - 20;
const yposHeight = canvasHeight - yPos;
- const isHeightGreaterThan90Percent = canvasHeight * 0.9 < yposHeight;
- // Adjust textY based on the condition
- let textY;
- if (isHeightGreaterThan90Percent) {
- textY = yPos + 15; // Below the threshold line
- } else {
- textY = yPos - 15; // Above the threshold line
- }
- ctx.fillStyle = threshold.thresholdColor || 'red';
- ctx.fillText(text, textX, textY);
+ const textY = yposHeight > threshold90Percent ? yPos + 15 : yPos - 15;
+
+ ctx.fillStyle = color;
+ ctx.fillText(threshold.thresholdLabel, textX, textY);
}
- ctx.restore();
}
- });
+ }
+
+ ctx.restore();
},
],
setSelect: [
@@ -473,6 +482,9 @@ export const getUPlotChartOptions = ({
if (legend) {
const legendElement = legend as HTMLElement;
+ // Initialize cleanup array for legend element listeners
+ (self as ExtendedUPlot)._legendElementCleanup = [];
+
// Apply enhanced legend styling
if (enhancedLegend) {
applyEnhancedLegendStyling(
@@ -548,19 +560,22 @@ export const getUPlotChartOptions = ({
// Get the current text content
const legendText = seriesLabels[index];
- // Clear the th content and rebuild it
- thElement.innerHTML = '';
+ // Use DocumentFragment to batch DOM operations
+ const fragment = document.createDocumentFragment();
// Add back the marker
if (markerClone) {
- thElement.appendChild(markerClone);
+ fragment.appendChild(markerClone);
}
// Create text wrapper
const textSpan = document.createElement('span');
textSpan.className = 'legend-text';
textSpan.textContent = legendText;
- thElement.appendChild(textSpan);
+ fragment.appendChild(textSpan);
+
+ // Replace the children in a single operation
+ thElement.replaceChildren(fragment);
// Setup tooltip functionality - check truncation on hover
let tooltipElement: HTMLElement | null = null;
@@ -639,6 +654,17 @@ export const getUPlotChartOptions = ({
thElement.addEventListener('mouseenter', showTooltip);
thElement.addEventListener('mouseleave', hideTooltip);
+ // Store cleanup function for tooltip listeners
+ (self as ExtendedUPlot)._legendElementCleanup?.push(() => {
+ thElement.removeEventListener('mouseenter', showTooltip);
+ thElement.removeEventListener('mouseleave', hideTooltip);
+ // Cleanup any lingering tooltip
+ if (tooltipElement) {
+ tooltipElement.remove();
+ tooltipElement = null;
+ }
+ });
+
// Add click handlers for marker and text separately
const currentMarker = thElement.querySelector('.u-marker');
const textElement = thElement.querySelector('.legend-text');
@@ -658,7 +684,7 @@ export const getUPlotChartOptions = ({
// Marker click handler - checkbox behavior (toggle individual series)
if (currentMarker) {
- currentMarker.addEventListener('click', (e) => {
+ const markerClickHandler = (e: Event): void => {
e.stopPropagation?.(); // Prevent event bubbling to text handler
if (stackChart) {
@@ -680,12 +706,19 @@ export const getUPlotChartOptions = ({
return newGraphVisibilityStates;
});
}
+ };
+
+ currentMarker.addEventListener('click', markerClickHandler);
+
+ // Store cleanup function for marker click listener
+ (self as ExtendedUPlot)._legendElementCleanup?.push(() => {
+ currentMarker.removeEventListener('click', markerClickHandler);
});
}
// Text click handler - show only/show all behavior (existing behavior)
if (textElement) {
- textElement.addEventListener('click', (e) => {
+ const textClickHandler = (e: Event): void => {
e.stopPropagation?.(); // Prevent event bubbling
if (stackChart) {
@@ -716,6 +749,13 @@ export const getUPlotChartOptions = ({
return newGraphVisibilityStates;
});
}
+ };
+
+ textElement.addEventListener('click', textClickHandler);
+
+ // Store cleanup function for text click listener
+ (self as ExtendedUPlot)._legendElementCleanup?.push(() => {
+ textElement.removeEventListener('click', textClickHandler);
});
}
}
@@ -723,6 +763,33 @@ export const getUPlotChartOptions = ({
}
},
],
+ destroy: [
+ (self): void => {
+ // Clean up legend scroll listener
+ if ((self as ExtendedUPlot)._legendScrollCleanup) {
+ (self as ExtendedUPlot)._legendScrollCleanup?.();
+ (self as ExtendedUPlot)._legendScrollCleanup = undefined;
+ }
+
+ // Clean up tooltip global listener
+ if ((self as ExtendedUPlot)._tooltipCleanup) {
+ (self as ExtendedUPlot)._tooltipCleanup?.();
+ (self as ExtendedUPlot)._tooltipCleanup = undefined;
+ }
+
+ // Clean up all legend element listeners
+ if ((self as ExtendedUPlot)._legendElementCleanup) {
+ (self as ExtendedUPlot)._legendElementCleanup?.forEach((cleanup) => {
+ cleanup();
+ });
+ (self as ExtendedUPlot)._legendElementCleanup = [];
+ }
+
+ // Clean up any remaining tooltips in DOM
+ const existingTooltips = document.querySelectorAll('.legend-tooltip');
+ existingTooltips.forEach((tooltip) => tooltip.remove());
+ },
+ ],
},
series: customSeries
? customSeries(apiResponse?.data?.result || [])
diff --git a/frontend/src/lib/uPlotLib/getUplotHistogramChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotHistogramChartOptions.ts
index bee488f891b..12fd1fd8e59 100644
--- a/frontend/src/lib/uPlotLib/getUplotHistogramChartOptions.ts
+++ b/frontend/src/lib/uPlotLib/getUplotHistogramChartOptions.ts
@@ -17,6 +17,11 @@ import { drawStyles } from './utils/constants';
import { generateColor } from './utils/generateColor';
import getAxes from './utils/getAxes';
+// Extended uPlot interface with custom properties
+interface ExtendedUPlot extends uPlot {
+ _legendScrollCleanup?: () => void;
+}
+
type GetUplotHistogramChartOptionsProps = {
id?: string;
apiResponse?: MetricRangePayloadProps;
@@ -30,6 +35,8 @@ type GetUplotHistogramChartOptionsProps = {
setGraphsVisibilityStates?: Dispatch>;
mergeAllQueries?: boolean;
onClickHandler?: OnClickPluginOpts['onClick'];
+ legendScrollPosition?: number;
+ setLegendScrollPosition?: (position: number) => void;
};
type GetHistogramSeriesProps = {
@@ -124,6 +131,8 @@ export const getUplotHistogramChartOptions = ({
mergeAllQueries,
onClickHandler = _noop,
panelType,
+ legendScrollPosition,
+ setLegendScrollPosition,
}: GetUplotHistogramChartOptionsProps): uPlot.Options =>
({
id,
@@ -179,33 +188,94 @@ export const getUplotHistogramChartOptions = ({
(self): void => {
const legend = self.root.querySelector('.u-legend');
if (legend) {
+ const legendElement = legend as HTMLElement;
+
+ // Enhanced legend scroll position preservation
+ if (setLegendScrollPosition && typeof legendScrollPosition === 'number') {
+ const handleScroll = (): void => {
+ setLegendScrollPosition(legendElement.scrollTop);
+ };
+
+ // Add scroll event listener to save position
+ legendElement.addEventListener('scroll', handleScroll);
+
+ // Restore scroll position
+ requestAnimationFrame(() => {
+ legendElement.scrollTop = legendScrollPosition;
+ });
+
+ // Store cleanup function
+ const extSelf = self as ExtendedUPlot;
+ extSelf._legendScrollCleanup = (): void => {
+ legendElement.removeEventListener('scroll', handleScroll);
+ };
+ }
+
const seriesEls = legend.querySelectorAll('.u-series');
const seriesArray = Array.from(seriesEls);
seriesArray.forEach((seriesEl, index) => {
- seriesEl.addEventListener('click', () => {
- if (graphsVisibilityStates) {
- setGraphsVisibilityStates?.((prev) => {
- const newGraphVisibilityStates = [...prev];
- if (
- newGraphVisibilityStates[index + 1] &&
- newGraphVisibilityStates.every((value, i) =>
- i === index + 1 ? value : !value,
- )
- ) {
- newGraphVisibilityStates.fill(true);
- } else {
- newGraphVisibilityStates.fill(false);
- newGraphVisibilityStates[index + 1] = true;
+ // Add click handlers for marker and text separately
+ const thElement = seriesEl.querySelector('th');
+ if (thElement) {
+ const currentMarker = thElement.querySelector('.u-marker');
+ const textElement =
+ thElement.querySelector('.legend-text') || thElement;
+
+ // Marker click handler - checkbox behavior (toggle individual series)
+ if (currentMarker) {
+ currentMarker.addEventListener('click', (e) => {
+ e.stopPropagation?.(); // Prevent event bubbling to text handler
+
+ if (graphsVisibilityStates) {
+ setGraphsVisibilityStates?.((prev) => {
+ const newGraphVisibilityStates = [...prev];
+ // Toggle the specific series visibility (checkbox behavior)
+ newGraphVisibilityStates[index + 1] = !newGraphVisibilityStates[
+ index + 1
+ ];
+
+ saveLegendEntriesToLocalStorage({
+ options: self,
+ graphVisibilityState: newGraphVisibilityStates,
+ name: id || '',
+ });
+ return newGraphVisibilityStates;
+ });
}
- saveLegendEntriesToLocalStorage({
- options: self,
- graphVisibilityState: newGraphVisibilityStates,
- name: id || '',
- });
- return newGraphVisibilityStates;
});
}
- });
+
+ // Text click handler - show only/show all behavior (existing behavior)
+ textElement.addEventListener('click', (e) => {
+ e.stopPropagation?.(); // Prevent event bubbling
+
+ if (graphsVisibilityStates) {
+ setGraphsVisibilityStates?.((prev) => {
+ const newGraphVisibilityStates = [...prev];
+ // Show only this series / show all behavior
+ if (
+ newGraphVisibilityStates[index + 1] &&
+ newGraphVisibilityStates.every((value, i) =>
+ i === index + 1 ? value : !value,
+ )
+ ) {
+ // If only this series is visible, show all
+ newGraphVisibilityStates.fill(true);
+ } else {
+ // Otherwise, show only this series
+ newGraphVisibilityStates.fill(false);
+ newGraphVisibilityStates[index + 1] = true;
+ }
+ saveLegendEntriesToLocalStorage({
+ options: self,
+ graphVisibilityState: newGraphVisibilityStates,
+ name: id || '',
+ });
+ return newGraphVisibilityStates;
+ });
+ }
+ });
+ }
});
}
},
diff --git a/frontend/src/lib/uPlotLib/placement.ts b/frontend/src/lib/uPlotLib/placement.ts
index 629e2dbcb2e..03f1c6b6a87 100644
--- a/frontend/src/lib/uPlotLib/placement.ts
+++ b/frontend/src/lib/uPlotLib/placement.ts
@@ -16,8 +16,20 @@
// https://tobyzerner.github.io/placement.js/dist/index.js
+/**
+ * Positions an element (tooltip/popover) relative to a reference element.
+ * Automatically flips to the opposite side if there's insufficient space.
+ *
+ * @param element - The HTMLElement to position
+ * @param reference - Reference element/Range or bounding rect
+ * @param side - Preferred side: 'top', 'bottom', 'left', 'right' (default: 'bottom')
+ * @param align - Alignment: 'start', 'center', 'end' (default: 'center')
+ * @param options - Optional bounds for constraining the element
+ * - bound: Custom boundary rect/element
+ * - followCursor: { x, y } - If provided, tooltip follows cursor with smart positioning
+ */
export const placement = (function () {
- const e = {
+ const AXIS_PROPS = {
size: ['height', 'width'],
clientSize: ['clientHeight', 'clientWidth'],
offsetSize: ['offsetHeight', 'offsetWidth'],
@@ -28,87 +40,241 @@ export const placement = (function () {
marginAfter: ['marginBottom', 'marginRight'],
scrollOffset: ['pageYOffset', 'pageXOffset'],
};
- function t(e) {
- return { top: e.top, bottom: e.bottom, left: e.left, right: e.right };
+
+ function extractRect(source) {
+ return {
+ top: source.top,
+ bottom: source.bottom,
+ left: source.left,
+ right: source.right,
+ };
}
- return function (o, r, f, a, i) {
- void 0 === f && (f = 'bottom'),
- void 0 === a && (a = 'center'),
- void 0 === i && (i = {}),
- (r instanceof Element || r instanceof Range) &&
- (r = t(r.getBoundingClientRect()));
- const n = {
- top: r.bottom,
- bottom: r.top,
- left: r.right,
- right: r.left,
- ...r,
+
+ return function (element, reference, side, align, options) {
+ // Default parameters
+ void 0 === side && (side = 'bottom');
+ void 0 === align && (align = 'center');
+ void 0 === options && (options = {});
+
+ // Handle cursor following mode
+ if (options.followCursor) {
+ const cursorX = options.followCursor.x;
+ const cursorY = options.followCursor.y;
+ const offset = options.followCursor.offset || 10; // Default 10px offset from cursor
+
+ element.style.position = 'absolute';
+ element.style.maxWidth = '';
+ element.style.maxHeight = '';
+
+ const elementWidth = element.offsetWidth;
+ const elementHeight = element.offsetHeight;
+
+ // Use viewport bounds for cursor following (not chart bounds)
+ const viewportBounds = {
+ top: 0,
+ left: 0,
+ bottom: window.innerHeight,
+ right: window.innerWidth,
+ };
+
+ // Vertical positioning: follow cursor Y with offset, clamped to viewport
+ const topPosition = cursorY + offset;
+ const clampedTop = Math.max(
+ viewportBounds.top,
+ Math.min(topPosition, viewportBounds.bottom - elementHeight),
+ );
+ element.style.top = `${clampedTop}px`;
+ element.style.bottom = 'auto';
+
+ // Horizontal positioning: auto-detect left or right based on available space
+ const spaceOnRight = viewportBounds.right - cursorX;
+ const spaceOnLeft = cursorX - viewportBounds.left;
+
+ if (spaceOnRight >= elementWidth + offset) {
+ // Enough space on the right
+ element.style.left = `${cursorX + offset}px`;
+ element.style.right = 'auto';
+ element.dataset.side = 'right';
+ } else if (spaceOnLeft >= elementWidth + offset) {
+ // Not enough space on right, use left
+ element.style.left = `${cursorX - elementWidth - offset}px`;
+ element.style.right = 'auto';
+ element.dataset.side = 'left';
+ } else if (spaceOnRight > spaceOnLeft) {
+ // Not enough space on either side, pick the side with more space
+ const leftPos = cursorX + offset;
+ const clampedLeft = Math.max(
+ viewportBounds.left,
+ Math.min(leftPos, viewportBounds.right - elementWidth),
+ );
+ element.style.left = `${clampedLeft}px`;
+ element.style.right = 'auto';
+ element.dataset.side = 'right';
+ } else {
+ const leftPos = cursorX - elementWidth - offset;
+ const clampedLeft = Math.max(
+ viewportBounds.left,
+ Math.min(leftPos, viewportBounds.right - elementWidth),
+ );
+ element.style.left = `${clampedLeft}px`;
+ element.style.right = 'auto';
+ element.dataset.side = 'left';
+ }
+
+ element.dataset.align = 'cursor';
+ return; // Exit early, don't run normal positioning logic
+ }
+
+ // Normalize reference to rect object
+ (reference instanceof Element || reference instanceof Range) &&
+ (reference = extractRect(reference.getBoundingClientRect()));
+
+ // Create anchor rect with swapped opposite edges for positioning
+ const anchorRect = {
+ top: reference.bottom,
+ bottom: reference.top,
+ left: reference.right,
+ right: reference.left,
+ ...reference,
};
- const s = {
+
+ // Viewport bounds (can be overridden via options.bound)
+ const bounds = {
top: 0,
left: 0,
bottom: window.innerHeight,
right: window.innerWidth,
};
- i.bound &&
- ((i.bound instanceof Element || i.bound instanceof Range) &&
- (i.bound = t(i.bound.getBoundingClientRect())),
- Object.assign(s, i.bound));
- const l = getComputedStyle(o);
- const m = {};
- const b = {};
- for (const g in e)
- (m[g] = e[g][f === 'top' || f === 'bottom' ? 0 : 1]),
- (b[g] = e[g][f === 'top' || f === 'bottom' ? 1 : 0]);
- (o.style.position = 'absolute'),
- (o.style.maxWidth = ''),
- (o.style.maxHeight = '');
- const d = parseInt(l[b.marginBefore]);
- const c = parseInt(l[b.marginAfter]);
- const u = d + c;
- const p = s[b.after] - s[b.before] - u;
- const h = parseInt(l[b.maxSize]);
- (!h || p < h) && (o.style[b.maxSize] = `${p}px`);
- const x = parseInt(l[m.marginBefore]) + parseInt(l[m.marginAfter]);
- const y = n[m.before] - s[m.before] - x;
- const z = s[m.after] - n[m.after] - x;
- ((f === m.before && o[m.offsetSize] > y) ||
- (f === m.after && o[m.offsetSize] > z)) &&
- (f = y > z ? m.before : m.after);
- const S = f === m.before ? y : z;
- const v = parseInt(l[m.maxSize]);
- (!v || S < v) && (o.style[m.maxSize] = `${S}px`);
- const w = window[m.scrollOffset];
- const O = function (e) {
- return Math.max(s[m.before], Math.min(e, s[m.after] - o[m.offsetSize] - x));
+
+ options.bound &&
+ ((options.bound instanceof Element || options.bound instanceof Range) &&
+ (options.bound = extractRect(options.bound.getBoundingClientRect())),
+ Object.assign(bounds, options.bound));
+
+ const styles = getComputedStyle(element);
+ const isVertical = side === 'top' || side === 'bottom';
+
+ // Build axis property maps based on orientation
+ const mainAxis = {}; // Properties for the main positioning axis
+ const crossAxis = {}; // Properties for the perpendicular axis
+
+ for (const prop in AXIS_PROPS) {
+ mainAxis[prop] = AXIS_PROPS[prop][isVertical ? 0 : 1];
+ crossAxis[prop] = AXIS_PROPS[prop][isVertical ? 1 : 0];
+ }
+
+ // Reset element positioning
+ element.style.position = 'absolute';
+ element.style.maxWidth = '';
+ element.style.maxHeight = '';
+
+ // Cross-axis: calculate and apply max size constraint
+ const crossMarginBefore = parseInt(styles[crossAxis.marginBefore]);
+ const crossMarginAfter = parseInt(styles[crossAxis.marginAfter]);
+ const crossMarginTotal = crossMarginBefore + crossMarginAfter;
+ const crossAvailableSpace =
+ bounds[crossAxis.after] - bounds[crossAxis.before] - crossMarginTotal;
+ const crossMaxSize = parseInt(styles[crossAxis.maxSize]);
+
+ (!crossMaxSize || crossAvailableSpace < crossMaxSize) &&
+ (element.style[crossAxis.maxSize] = `${crossAvailableSpace}px`);
+
+ // Main-axis: calculate space on both sides
+ const mainMarginTotal =
+ parseInt(styles[mainAxis.marginBefore]) +
+ parseInt(styles[mainAxis.marginAfter]);
+ const spaceBefore =
+ anchorRect[mainAxis.before] - bounds[mainAxis.before] - mainMarginTotal;
+ const spaceAfter =
+ bounds[mainAxis.after] - anchorRect[mainAxis.after] - mainMarginTotal;
+
+ // Auto-flip to the side with more space if needed
+ ((side === mainAxis.before && element[mainAxis.offsetSize] > spaceBefore) ||
+ (side === mainAxis.after && element[mainAxis.offsetSize] > spaceAfter)) &&
+ (side = spaceBefore > spaceAfter ? mainAxis.before : mainAxis.after);
+
+ // Apply main-axis max size constraint
+ const mainAvailableSpace =
+ side === mainAxis.before ? spaceBefore : spaceAfter;
+ const mainMaxSize = parseInt(styles[mainAxis.maxSize]);
+
+ (!mainMaxSize || mainAvailableSpace < mainMaxSize) &&
+ (element.style[mainAxis.maxSize] = `${mainAvailableSpace}px`);
+
+ // Position on main axis
+ const mainScrollOffset = window[mainAxis.scrollOffset];
+ const clampMainPosition = function (pos) {
+ return Math.max(
+ bounds[mainAxis.before],
+ Math.min(
+ pos,
+ bounds[mainAxis.after] - element[mainAxis.offsetSize] - mainMarginTotal,
+ ),
+ );
};
- f === m.before
- ? ((o.style[m.before] = `${w + O(n[m.before] - o[m.offsetSize] - x)}px`),
- (o.style[m.after] = 'auto'))
- : ((o.style[m.before] = `${w + O(n[m.after])}px`),
- (o.style[m.after] = 'auto'));
- const B = window[b.scrollOffset];
- const I = function (e) {
- return Math.max(s[b.before], Math.min(e, s[b.after] - o[b.offsetSize] - u));
+
+ side === mainAxis.before
+ ? ((element.style[mainAxis.before] = `${
+ mainScrollOffset +
+ clampMainPosition(
+ anchorRect[mainAxis.before] -
+ element[mainAxis.offsetSize] -
+ mainMarginTotal,
+ )
+ }px`),
+ (element.style[mainAxis.after] = 'auto'))
+ : ((element.style[mainAxis.before] = `${
+ mainScrollOffset + clampMainPosition(anchorRect[mainAxis.after])
+ }px`),
+ (element.style[mainAxis.after] = 'auto'));
+
+ // Position on cross axis based on alignment
+ const crossScrollOffset = window[crossAxis.scrollOffset];
+ const clampCrossPosition = function (pos) {
+ return Math.max(
+ bounds[crossAxis.before],
+ Math.min(
+ pos,
+ bounds[crossAxis.after] - element[crossAxis.offsetSize] - crossMarginTotal,
+ ),
+ );
};
- switch (a) {
+
+ switch (align) {
case 'start':
- (o.style[b.before] = `${B + I(n[b.before] - d)}px`),
- (o.style[b.after] = 'auto');
+ (element.style[crossAxis.before] = `${
+ crossScrollOffset +
+ clampCrossPosition(anchorRect[crossAxis.before] - crossMarginBefore)
+ }px`),
+ (element.style[crossAxis.after] = 'auto');
break;
case 'end':
- (o.style[b.before] = 'auto'),
- (o.style[b.after] = `${
- B + I(document.documentElement[b.clientSize] - n[b.after] - c)
+ (element.style[crossAxis.before] = 'auto'),
+ (element.style[crossAxis.after] = `${
+ crossScrollOffset +
+ clampCrossPosition(
+ document.documentElement[crossAxis.clientSize] -
+ anchorRect[crossAxis.after] -
+ crossMarginAfter,
+ )
}px`);
break;
default:
- var H = n[b.after] - n[b.before];
- (o.style[b.before] = `${
- B + I(n[b.before] + H / 2 - o[b.offsetSize] / 2 - d)
+ // 'center'
+ var crossSize = anchorRect[crossAxis.after] - anchorRect[crossAxis.before];
+ (element.style[crossAxis.before] = `${
+ crossScrollOffset +
+ clampCrossPosition(
+ anchorRect[crossAxis.before] +
+ crossSize / 2 -
+ element[crossAxis.offsetSize] / 2 -
+ crossMarginBefore,
+ )
}px`),
- (o.style[b.after] = 'auto');
+ (element.style[crossAxis.after] = 'auto');
}
- (o.dataset.side = f), (o.dataset.align = a);
+
+ // Store final placement as data attributes
+ (element.dataset.side = side), (element.dataset.align = align);
};
})();
diff --git a/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts b/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts
index ac67c875ffc..c9339c25dcc 100644
--- a/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts
+++ b/frontend/src/lib/uPlotLib/plugins/onClickPlugin.ts
@@ -3,7 +3,71 @@ import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
+function isSeriesValueValid(seriesValue: number | undefined | null): boolean {
+ return (
+ seriesValue !== undefined &&
+ seriesValue !== null &&
+ !Number.isNaN(seriesValue)
+ );
+}
+
// Helper function to get the focused/highlighted series at a specific position
+function resolveSeriesColor(series: uPlot.Series, index: number): string {
+ let color = '#000000';
+ if (typeof series.stroke === 'string') {
+ color = series.stroke;
+ } else if (typeof series.fill === 'string') {
+ color = series.fill;
+ } else {
+ const seriesLabel = series.label || `Series ${index}`;
+ const isDarkMode = !document.body.classList.contains('lightMode');
+ color = generateColor(
+ seriesLabel,
+ isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
+ );
+ }
+ return color;
+}
+
+function getPreferredSeriesIndex(
+ u: uPlot,
+ timestampIndex: number,
+ e: MouseEvent,
+): number {
+ const bbox = u.over.getBoundingClientRect();
+ const top = e.clientY - bbox.top;
+ // Prefer series explicitly marked as focused
+ for (let i = 1; i < u.series.length; i++) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const isSeriesFocused = u.series[i]?._focus === true;
+ const isSeriesShown = u.series[i].show !== false;
+ const seriesValue = u.data[i]?.[timestampIndex];
+ if (isSeriesFocused && isSeriesShown && isSeriesValueValid(seriesValue)) {
+ return i;
+ }
+ }
+
+ // Fallback: choose series with Y closest to mouse position
+ let focusedSeriesIndex = -1;
+ let closestPixelDiff = Infinity;
+ for (let i = 1; i < u.series.length; i++) {
+ const series = u.data[i];
+ const seriesValue = series?.[timestampIndex];
+
+ if (isSeriesValueValid(seriesValue) && u.series[i].show !== false) {
+ const yPx = u.valToPos(seriesValue as number, 'y');
+ const diff = Math.abs(yPx - top);
+ if (diff < closestPixelDiff) {
+ closestPixelDiff = diff;
+ focusedSeriesIndex = i;
+ }
+ }
+ }
+
+ return focusedSeriesIndex;
+}
+
export const getFocusedSeriesAtPosition = (
e: MouseEvent,
u: uPlot,
@@ -17,74 +81,28 @@ export const getFocusedSeriesAtPosition = (
} | null => {
const bbox = u.over.getBoundingClientRect();
const left = e.clientX - bbox.left;
- const top = e.clientY - bbox.top;
const timestampIndex = u.posToIdx(left);
- let focusedSeriesIndex = -1;
- let closestPixelDiff = Infinity;
-
- // Check all series (skip index 0 which is the x-axis)
- for (let i = 1; i < u.data.length; i++) {
- const series = u.data[i];
- const seriesValue = series[timestampIndex];
-
- if (
- seriesValue !== undefined &&
- seriesValue !== null &&
- !Number.isNaN(seriesValue)
- ) {
- const seriesYPx = u.valToPos(seriesValue, 'y');
- const pixelDiff = Math.abs(seriesYPx - top);
-
- if (pixelDiff < closestPixelDiff) {
- closestPixelDiff = pixelDiff;
- focusedSeriesIndex = i;
- }
- }
- }
-
- // If we found a focused series, return its data
- if (focusedSeriesIndex > 0) {
- const series = u.series[focusedSeriesIndex];
- const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
-
- // Ensure we have a valid value
- if (
- seriesValue !== undefined &&
- seriesValue !== null &&
- !Number.isNaN(seriesValue)
- ) {
- // Get color - try series stroke first, then generate based on label
- let color = '#000000';
- if (typeof series.stroke === 'string') {
- color = series.stroke;
- } else if (typeof series.fill === 'string') {
- color = series.fill;
- } else {
- // Generate color based on series label (like the tooltip plugin does)
- const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
- // Detect theme mode by checking body class
- const isDarkMode = !document.body.classList.contains('lightMode');
- color = generateColor(
- seriesLabel,
- isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
- );
- }
+ const preferredIndex = getPreferredSeriesIndex(u, timestampIndex, e);
+ if (preferredIndex > 0) {
+ const series = u.series[preferredIndex];
+ const seriesValue = u.data[preferredIndex][timestampIndex];
+ if (isSeriesValueValid(seriesValue)) {
+ const color = resolveSeriesColor(series, preferredIndex);
return {
- seriesIndex: focusedSeriesIndex,
- seriesName: series.label || `Series ${focusedSeriesIndex}`,
+ seriesIndex: preferredIndex,
+ seriesName: series.label || `Series ${preferredIndex}`,
value: seriesValue as number,
color,
show: series.show !== false,
- isFocused: true, // This indicates it's the highlighted/bold one
+ isFocused: true,
};
}
}
return null;
};
-
export interface OnClickPluginOpts {
onClick: (
xValue: number,
@@ -137,50 +155,31 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
const yValue = u.posToVal(event.offsetY, 'y');
// Get the focused/highlighted series (the one that would be bold in hover)
- const focusedSeries = getFocusedSeriesAtPosition(event, u);
+ const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
let metric = {};
- const { series } = u;
const apiResult = opts.apiResponse?.data?.result || [];
const outputMetric = {
queryName: '',
inFocusOrNot: false,
};
- // this is to get the metric value of the focused series
- if (Array.isArray(series) && series.length > 0) {
- series.forEach((item, index) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- if (item?.show && item?._focus) {
- const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
- metric = focusedMetric;
- outputMetric.queryName = queryName;
- outputMetric.inFocusOrNot = true;
- }
- });
- }
-
- if (!outputMetric.queryName) {
- // Get the focused series data
- const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
-
- // If we found a valid focused series, get its data
- if (
- focusedSeriesData &&
- focusedSeriesData.seriesIndex <= apiResult.length
- ) {
- const { metric: focusedMetric, queryName } =
- apiResult[focusedSeriesData.seriesIndex - 1] || [];
- metric = focusedMetric;
- outputMetric.queryName = queryName;
- outputMetric.inFocusOrNot = true;
- }
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ if (
+ focusedSeriesData &&
+ focusedSeriesData.seriesIndex <= apiResult.length
+ ) {
+ const { metric: focusedMetric, queryName } =
+ apiResult[focusedSeriesData.seriesIndex - 1] || {};
+ metric = focusedMetric;
+ outputMetric.queryName = queryName;
+ outputMetric.inFocusOrNot = true;
}
// Get the actual data point timestamp from the focused series
let actualDataTimestamp = xValue; // fallback to click position timestamp
- if (focusedSeries) {
+ if (focusedSeriesData) {
// Get the data index from the focused series
const dataIndex = u.posToIdx(event.offsetX);
// Get the actual timestamp from the x-axis data (u.data[0])
@@ -209,7 +208,7 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
absoluteMouseX,
absoluteMouseY,
axesData,
- focusedSeries,
+ focusedSeriesData,
);
};
u.over.addEventListener('click', handleClick);
diff --git a/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts b/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts
index 731fbbd0d51..0c36c7fc768 100644
--- a/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts
+++ b/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts
@@ -1,4 +1,4 @@
-import { getToolTipValue } from 'components/Graph/yAxisConfig';
+import { getToolTipValue, PrecisionOption } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { themeColors } from 'constants/theme';
import dayjs from 'dayjs';
@@ -38,12 +38,38 @@ function getTooltipBaseValue(
return data[index][idx];
}
+function sortTooltipContentBasedOnValue(
+ tooltipDataObj: Record,
+): Record {
+ const entries = Object.entries(tooltipDataObj);
+
+ // Separate focused and non-focused entries in a single pass
+ const focusedEntries: [string, UplotTooltipDataProps][] = [];
+ const nonFocusedEntries: [string, UplotTooltipDataProps][] = [];
+
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ if (entry[1].focus) {
+ focusedEntries.push(entry);
+ } else {
+ nonFocusedEntries.push(entry);
+ }
+ }
+
+ // Sort non-focused entries by value (descending)
+ nonFocusedEntries.sort((a, b) => b[1].value - a[1].value);
+
+ // Combine with focused entries on top
+ return Object.fromEntries(focusedEntries.concat(nonFocusedEntries));
+}
+
const generateTooltipContent = (
seriesList: any[],
data: any[],
idx: number,
isDarkMode: boolean,
yAxisUnit?: string,
+ decimalPrecision?: PrecisionOption,
series?: uPlot.Options['series'],
isBillingUsageGraphs?: boolean,
isHistogramGraphs?: boolean,
@@ -56,23 +82,31 @@ const generateTooltipContent = (
): HTMLElement => {
const container = document.createElement('div');
container.classList.add('tooltip-container');
- const overlay = document.getElementById('overlay');
let tooltipCount = 0;
let tooltipTitle = '';
const formattedData: Record = {};
const duplicatedLegendLabels: Record = {};
- function sortTooltipContentBasedOnValue(
- tooltipDataObj: Record,
- ): Record {
- const entries = Object.entries(tooltipDataObj);
- entries.sort((a, b) => b[1].value - a[1].value);
- return Object.fromEntries(entries);
+ // Pre-build a label-to-series map for O(1) lookup instead of O(n) search
+ let seriesColorMap: Map | null = null;
+ if (isBillingUsageGraphs && series) {
+ seriesColorMap = new Map();
+ for (let i = 0; i < series.length; i++) {
+ const item = series[i];
+ if (item.label) {
+ const fillColor = get(item, '_fill');
+ if (fillColor) {
+ seriesColorMap.set(item.label, fillColor);
+ }
+ }
+ }
}
if (Array.isArray(series) && series.length > 0) {
- series.forEach((item, index) => {
+ for (let index = 0; index < series.length; index++) {
+ const item = series[index];
+
if (index === 0) {
if (isBillingUsageGraphs) {
tooltipTitle = dayjs(data[0][idx] * 1000)
@@ -113,26 +147,20 @@ const generateTooltipContent = (
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
- // in case of billing graph pick colors from the series options
- if (isBillingUsageGraphs) {
- let clr;
- series.forEach((item) => {
- if (item.label === label) {
- clr = get(item, '_fill');
- }
- });
- color = clr ?? color;
+ // O(1) lookup instead of O(n) search for billing graph colors
+ if (isBillingUsageGraphs && seriesColorMap) {
+ const billingColor = seriesColorMap.get(label);
+ if (billingColor) {
+ color = billingColor;
+ }
}
let tooltipItemLabel = label;
if (Number.isFinite(value)) {
- const tooltipValue = getToolTipValue(value, yAxisUnit);
+ const tooltipValue = getToolTipValue(value, yAxisUnit, decimalPrecision);
const dataIngestedFormated = getToolTipValue(dataIngested);
- if (
- duplicatedLegendLabels[label] ||
- Object.prototype.hasOwnProperty.call(formattedData, label)
- ) {
+ if (duplicatedLegendLabels[label] || label in formattedData) {
duplicatedLegendLabels[label] = true;
const tempDataObj = formattedData[label];
@@ -169,15 +197,11 @@ const generateTooltipContent = (
formattedData[tooltipItemLabel] = dataObj;
}
}
- });
+ }
}
- // Show tooltip only if atleast only series has a value at the hovered timestamp
+ // Early return if no valid data points - avoids unnecessary DOM manipulation
if (tooltipCount <= 0) {
- if (overlay && overlay.style.display === 'block') {
- overlay.style.display = 'none';
- }
-
return container;
}
@@ -186,48 +210,42 @@ const generateTooltipContent = (
UplotTooltipDataProps
> = sortTooltipContentBasedOnValue(formattedData);
- const div = document.createElement('div');
- div.classList.add('tooltip-content-row');
- div.textContent = isHistogramGraphs ? '' : tooltipTitle;
- div.classList.add('tooltip-content-header');
- container.appendChild(div);
+ const headerDiv = document.createElement('div');
+ headerDiv.classList.add('tooltip-content-row', 'tooltip-content-header');
+ headerDiv.textContent = isHistogramGraphs ? '' : tooltipTitle;
+ container.appendChild(headerDiv);
- const sortedKeys = Object.keys(sortedData);
+ // Use DocumentFragment for better performance when adding multiple elements
+ const fragment = document.createDocumentFragment();
- if (Array.isArray(sortedKeys) && sortedKeys.length > 0) {
- sortedKeys.forEach((key) => {
- if (sortedData[key]) {
- const { textContent, color, focus } = sortedData[key];
- const div = document.createElement('div');
- div.classList.add('tooltip-content-row');
- div.classList.add('tooltip-content');
- const squareBox = document.createElement('div');
- squareBox.classList.add('pointSquare');
+ const sortedValues = Object.values(sortedData);
- squareBox.style.borderColor = color;
+ for (let i = 0; i < sortedValues.length; i++) {
+ const { textContent, color, focus } = sortedValues[i];
- const text = document.createElement('div');
- text.classList.add('tooltip-data-point');
+ const div = document.createElement('div');
+ div.classList.add('tooltip-content-row', 'tooltip-content');
- text.textContent = textContent;
- text.style.color = color;
+ const squareBox = document.createElement('div');
+ squareBox.classList.add('pointSquare');
+ squareBox.style.borderColor = color;
- if (focus) {
- text.classList.add('focus');
- } else {
- text.classList.remove('focus');
- }
+ const text = document.createElement('div');
+ text.classList.add('tooltip-data-point');
+ text.textContent = textContent;
+ text.style.color = color;
- div.appendChild(squareBox);
- div.appendChild(text);
+ if (focus) {
+ text.classList.add('focus');
+ }
- container.appendChild(div);
- }
- });
+ div.appendChild(squareBox);
+ div.appendChild(text);
+ fragment.appendChild(div);
}
- if (overlay && overlay.style.display === 'none') {
- overlay.style.display = 'block';
+ if (fragment.hasChildNodes()) {
+ container.appendChild(fragment);
}
return container;
@@ -239,6 +257,7 @@ type ToolTipPluginProps = {
isBillingUsageGraphs?: boolean;
isHistogramGraphs?: boolean;
isMergedSeries?: boolean;
+ decimalPrecision?: PrecisionOption;
stackBarChart?: boolean;
isDarkMode: boolean;
customTooltipElement?: HTMLDivElement;
@@ -259,84 +278,158 @@ const tooltipPlugin = ({
timezone,
colorMapping,
query,
+ decimalPrecision,
}: // eslint-disable-next-line sonarjs/cognitive-complexity
ToolTipPluginProps): any => {
let over: HTMLElement;
let bound: HTMLElement;
- let bLeft: any;
- let bTop: any;
+ // Cache bounding box to avoid recalculating on every cursor move
+ let cachedBBox: DOMRect | null = null;
+ let isActive = false;
+ let overlay: HTMLElement | null = null;
+
+ // Pre-compute apiResult once
+ const apiResult = apiResponse?.data?.result || [];
+ // Sync bounds and cache the result
const syncBounds = (): void => {
- const bbox = over.getBoundingClientRect();
- bLeft = bbox.left;
- bTop = bbox.top;
+ if (over) {
+ cachedBBox = over.getBoundingClientRect();
+ }
};
- let overlay = document.getElementById('overlay');
+ // Create overlay once and reuse it
+ const initOverlay = (): void => {
+ if (!overlay) {
+ overlay = document.getElementById('overlay');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'overlay';
+ overlay.style.cssText = 'display: none; position: absolute;';
+ document.body.appendChild(overlay);
+ }
+ }
+ };
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.id = 'overlay';
- overlay.style.display = 'none';
- overlay.style.position = 'absolute';
- document.body.appendChild(overlay);
- }
+ const showOverlay = (): void => {
+ if (overlay && overlay.style.display === 'none') {
+ overlay.style.display = 'block';
+ }
+ };
- const apiResult = apiResponse?.data?.result || [];
+ const hideOverlay = (): void => {
+ if (overlay && overlay.style.display === 'block') {
+ overlay.style.display = 'none';
+ }
+ };
+
+ const plotEnter = (): void => {
+ isActive = true;
+ showOverlay();
+ };
+
+ const plotLeave = (): void => {
+ isActive = false;
+ hideOverlay();
+ };
+
+ // Cleanup function to remove event listeners
+ const cleanup = (): void => {
+ if (over) {
+ over.removeEventListener('mouseenter', plotEnter);
+ over.removeEventListener('mouseleave', plotLeave);
+ }
+ };
return {
hooks: {
init: (u: any): void => {
over = u?.over;
bound = over;
- over.onmouseenter = (): void => {
- if (overlay) {
- overlay.style.display = 'block';
- }
- };
- over.onmouseleave = (): void => {
- if (overlay) {
- overlay.style.display = 'none';
- }
- };
+
+ // Initialize overlay once during init
+ initOverlay();
+
+ // Initial bounds sync
+ syncBounds();
+
+ over.addEventListener('mouseenter', plotEnter);
+ over.addEventListener('mouseleave', plotLeave);
},
setSize: (): void => {
+ // Re-sync bounds when size changes
syncBounds();
},
+ // Cache bounding box on syncRect for better performance
+ syncRect: (u: any, rect: DOMRect): void => {
+ cachedBBox = rect;
+ },
setCursor: (u: {
cursor: { left: any; top: any; idx: any };
data: any[];
series: uPlot.Options['series'];
}): void => {
- if (overlay) {
- overlay.textContent = '';
- const { left, top, idx } = u.cursor;
-
- if (Number.isInteger(idx)) {
- const anchor = { left: left + bLeft, top: top + bTop };
- const content = generateTooltipContent(
- apiResult,
- u.data,
- idx,
- isDarkMode,
- yAxisUnit,
- u.series,
- isBillingUsageGraphs,
- isHistogramGraphs,
- isMergedSeries,
- stackBarChart,
- timezone,
- colorMapping,
- query,
- );
- if (customTooltipElement) {
- content.appendChild(customTooltipElement);
- }
- overlay.appendChild(content);
- placement(overlay, anchor, 'right', 'start', { bound });
+ if (!overlay) {
+ return;
+ }
+
+ const { left, top, idx } = u.cursor;
+
+ // Early return if not active or no valid index
+ if (!isActive || !Number.isInteger(idx)) {
+ if (isActive) {
+ // Clear tooltip content efficiently using replaceChildren
+ overlay.replaceChildren();
+ }
+ return;
+ }
+
+ // Use cached bounding box if available
+ const bbox = cachedBBox || over.getBoundingClientRect();
+ const anchor = {
+ left: left + bbox.left,
+ top: top + bbox.top,
+ };
+
+ const content = generateTooltipContent(
+ apiResult,
+ u.data,
+ idx,
+ isDarkMode,
+ yAxisUnit,
+ decimalPrecision,
+ u.series,
+ isBillingUsageGraphs,
+ isHistogramGraphs,
+ isMergedSeries,
+ stackBarChart,
+ timezone,
+ colorMapping,
+ query,
+ );
+
+ // Only show tooltip if there's actual content
+ if (content.children.length > 1) {
+ if (customTooltipElement) {
+ content.appendChild(customTooltipElement);
}
+ // Clear and set new content in one operation
+ overlay.replaceChildren(content);
+ placement(overlay, anchor, 'right', 'start', {
+ bound,
+ followCursor: { x: anchor.left, y: anchor.top, offset: 4 },
+ });
+
+ showOverlay();
+ } else {
+ hideOverlay();
}
},
+ destroy: (): void => {
+ // Cleanup on destroy
+ cleanup();
+ hideOverlay();
+ },
},
};
};
diff --git a/frontend/src/lib/uPlotLib/utils/getAxes.ts b/frontend/src/lib/uPlotLib/utils/getAxes.ts
index 4742ed22b24..6a2a65aa1ca 100644
--- a/frontend/src/lib/uPlotLib/utils/getAxes.ts
+++ b/frontend/src/lib/uPlotLib/utils/getAxes.ts
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
-import { getToolTipValue } from 'components/Graph/yAxisConfig';
+import { getToolTipValue, PrecisionOption } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from './constants';
@@ -18,11 +18,13 @@ const getAxes = ({
yAxisUnit,
panelType,
isLogScale,
+ decimalPrecision,
}: {
isDarkMode: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
isLogScale?: boolean;
+ decimalPrecision?: PrecisionOption;
// eslint-disable-next-line sonarjs/cognitive-complexity
}): any => [
{
@@ -61,7 +63,7 @@ const getAxes = ({
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
- const value = getToolTipValue(v.toString(), yAxisUnit);
+ const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
}),
gap: 5,
diff --git a/frontend/src/lib/uPlotLib/utils/tests/__mocks__/uplotChartOptionsData.ts b/frontend/src/lib/uPlotLib/utils/tests/__mocks__/uplotChartOptionsData.ts
index e9757d371bc..0d3dace746e 100644
--- a/frontend/src/lib/uPlotLib/utils/tests/__mocks__/uplotChartOptionsData.ts
+++ b/frontend/src/lib/uPlotLib/utils/tests/__mocks__/uplotChartOptionsData.ts
@@ -1,4 +1,4 @@
-import { PANEL_TYPES } from 'constants/queryBuilder';
+import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { GetUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
export const inputPropsTimeSeries = {
@@ -204,6 +204,7 @@ export const inputPropsTimeSeries = {
softMax: null,
softMin: null,
panelType: PANEL_TYPES.TIME_SERIES,
+ query: initialQueriesMap.metrics,
} as GetUPlotChartOptions;
export const inputPropsBar = {
diff --git a/frontend/src/mocks-server/handlers.ts b/frontend/src/mocks-server/handlers.ts
index ccb4dd603ab..abb3fc1aec0 100644
--- a/frontend/src/mocks-server/handlers.ts
+++ b/frontend/src/mocks-server/handlers.ts
@@ -26,8 +26,11 @@ export const handlers = [
res(ctx.status(200), ctx.json(queryRangeSuccessResponse)),
),
- rest.post('http://localhost/api/v1/services', (req, res, ctx) =>
- res(ctx.status(200), ctx.json(serviceSuccessResponse)),
+ rest.post('http://localhost/api/v2/services', (req, res, ctx) =>
+ res(
+ ctx.status(200),
+ ctx.json({ status: 'success', data: serviceSuccessResponse }),
+ ),
),
rest.post(
diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx
index 4c7bd338acd..cebf97248f3 100644
--- a/frontend/src/pages/AlertDetails/AlertDetails.tsx
+++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx
@@ -117,6 +117,11 @@ function AlertDetails(): JSX.Element {
}
};
+ // Show spinner until we have alert data loaded
+ if (isLoading && !alertRuleDetails) {
+ return ;
+ }
+
return (
-
-
+
);
}
diff --git a/frontend/src/pages/InfrastructureMonitoring/constants.tsx b/frontend/src/pages/InfrastructureMonitoring/constants.tsx
index 0b335855778..0fe3cd27557 100644
--- a/frontend/src/pages/InfrastructureMonitoring/constants.tsx
+++ b/frontend/src/pages/InfrastructureMonitoring/constants.tsx
@@ -3,14 +3,9 @@ import ROUTES from 'constants/routes';
import InfraMonitoringHosts from 'container/InfraMonitoringHosts';
import InfraMonitoringK8s from 'container/InfraMonitoringK8s';
import { Inbox } from 'lucide-react';
-import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
export const Hosts: TabRoutes = {
- Component: (): JSX.Element => (
-
-
-
- ),
+ Component: (): JSX.Element => ,
name: (
Hosts
@@ -21,11 +16,7 @@ export const Hosts: TabRoutes = {
};
export const Kubernetes: TabRoutes = {
- Component: (): JSX.Element => (
-
-
-
- ),
+ Component: (): JSX.Element =>
,
name: (
Kubernetes
diff --git a/frontend/src/pages/LiveLogs/LiveLogs.tsx b/frontend/src/pages/LiveLogs/LiveLogs.tsx
index 3203c85d3b1..fe0521904e3 100644
--- a/frontend/src/pages/LiveLogs/LiveLogs.tsx
+++ b/frontend/src/pages/LiveLogs/LiveLogs.tsx
@@ -3,7 +3,6 @@ import { liveLogsCompositeQuery } from 'container/LiveLogs/constants';
import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
-import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { useEffect } from 'react';
import { DataSource } from 'types/common/queryBuilder';
@@ -15,11 +14,7 @@ function LiveLogs(): JSX.Element {
handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS);
}, [handleSetConfig]);
- return (
-
-
-
- );
+ return
;
}
export default LiveLogs;
diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx
index 8a78de4e290..5c69c5efd50 100644
--- a/frontend/src/pages/Login/index.tsx
+++ b/frontend/src/pages/Login/index.tsx
@@ -1,16 +1,8 @@
import './Login.styles.scss';
import LoginContainer from 'container/Login';
-import useURLQuery from 'hooks/useUrlQuery';
function Login(): JSX.Element {
- const urlQueryParams = useURLQuery();
- const jwt = urlQueryParams.get('jwt') || '';
- const refreshJwt = urlQueryParams.get('refreshjwt') || '';
- const userId = urlQueryParams.get('usr') || '';
- const ssoerror = urlQueryParams.get('ssoerror') || '';
- const withPassword = urlQueryParams.get('password') || '';
-
return (
@@ -25,13 +17,7 @@ function Login(): JSX.Element {
SigNoz
-
+
);
diff --git a/frontend/src/pages/Logs/index.tsx b/frontend/src/pages/Logs/index.tsx
index 0714c475e4b..43909b53311 100644
--- a/frontend/src/pages/Logs/index.tsx
+++ b/frontend/src/pages/Logs/index.tsx
@@ -10,7 +10,6 @@ import LogsFilters from 'container/LogsFilters';
import LogsSearchFilter from 'container/LogsSearchFilter';
import LogsTable from 'container/LogsTable';
import history from 'lib/history';
-import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
@@ -83,71 +82,69 @@ function OldLogsExplorer(): JSX.Element {
};
return (
-
-
-
}
- align="center"
- direction="horizontal"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+ }
+ align="center"
+ direction="horizontal"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {viewModeOptionList.map((option) => (
+ {option.label}
+ ))}
+
+
+ {isFormatButtonVisible && (
+
- {viewModeOptionList.map((option) => (
- {option.label}
- ))}
-
-
- {isFormatButtonVisible && (
-
- Format
-
- )}
-
-
- {orderItems.map((item) => (
- {item.name}
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Format
+
+ )}
+
+
+ {orderItems.map((item) => (
+ {item.name}
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx
index c20e6ea7772..e4da7619ca3 100644
--- a/frontend/src/pages/LogsExplorer/index.tsx
+++ b/frontend/src/pages/LogsExplorer/index.tsx
@@ -10,8 +10,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
-import { QueryParams } from 'constants/query';
-import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
+import { PANEL_TYPES } from 'constants/queryBuilder';
import LogExplorerQuerySection from 'container/LogExplorerQuerySection';
import LogsExplorerViewsContainer from 'container/LogsExplorerViews';
import {
@@ -25,36 +24,36 @@ import RightToolbarActions from 'container/QueryBuilder/components/ToolbarAction
import Toolbar from 'container/Toolbar/Toolbar';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
-import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
-import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
+import {
+ ICurrentQueryData,
+ useHandleExplorerTabChange,
+} from 'hooks/useHandleExplorerTabChange';
import useUrlQueryData from 'hooks/useUrlQueryData';
-import { isEmpty, isEqual, isNull } from 'lodash-es';
+import { defaultTo, isEmpty, isEqual, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { EventSourceProvider } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useSearchParams } from 'react-router-dom-v5-compat';
import { Warning } from 'types/api';
-import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
- getExplorerViewForPanelType,
- getExplorerViewFromUrl,
+ explorerViewToPanelType,
+ panelTypeToExplorerView,
} from 'utils/explorerUtils';
import { ExplorerViews } from './utils';
function LogsExplorer(): JSX.Element {
- const [searchParams] = useSearchParams();
const [showLiveLogs, setShowLiveLogs] = useState