Compare commits

..

9 Commits

Author SHA1 Message Date
Nikhil Soni
5480aaef6d fix: add missing filtering for ip address for scalar data
In domain listing api for external api monitoring,
we have option to filter out the IP address but
it only handles timeseries and raw type data while
domain list handler returns scalar data.
2026-02-10 17:53:02 +05:30
Vikrant Gupta
e699ad8122 fix(meter): custom step intervals for meter aggregations (#10255)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): remove frontend harcoding for step interval

* fix(meter): remove frontend harcoding for step interval
2026-02-10 16:54:14 +05:30
Ishan
a1cc05848c feat: added open filter (#10244) 2026-02-10 11:33:17 +05:30
Ederson G. Elias
6d78df2275 fix: Service Map environment filter not working with DOT_METRICS_ENABLED (#10227)
* fix: Service Map environment filter not working with DOT_METRICS_ENABLED

When DOT_METRICS_ENABLED is active, resource attribute keys use dot notation
(e.g. resource_deployment.environment) instead of underscore notation
(e.g. resource_deployment_environment). The whilelistedKeys array only
contained underscore-notation keys, causing getVisibleQueries and
mappingWithRoutesAndKeys to filter out valid queries on the Service Map.

- Add dot-notation variants to whilelistedKeys for environment, k8s cluster
  name, and k8s namespace
- Remove unnecessary onBlur handler from environment Select component
- Add unit tests for whilelistedKeys and mappingWithRoutesAndKeys

Closes #10226

* chore: run perttify

---------

Co-authored-by: srikanthccv <srikanth.chekuri92@gmail.com>
2026-02-10 04:48:31 +00:00
Ashwin Bhatkal
df49484bea fix: fix flaky dashboard test (#10254)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-09 14:43:48 +00:00
Ashwin Bhatkal
72b0f27494 chore: use variable select strategy + (#10245)
* chore: use variable select strategy

* chore: revert custom multi select

* chore: fix tests

* chore: fix tests

* chore: fix flaky test
2026-02-09 14:29:28 +00:00
Abhi kumar
e36b647bc7 test: added test suites for uplotchart component (#10247)
* test: added test suites for uplotchart component

* chore: resolved pr review comments

* chore: resolved pr review comments
2026-02-09 14:15:21 +00:00
Nikhil Soni
b491772eaa fix: ensure trace time range is fetch correctly (#10252)
If multiple batches are inserted with same trace_id, then
trace_summary table can have multiple rows before they are
aggregated by clickhouse. Query to get the time range from
trace_summary was assuming a single which was create
unpredictable behaviour as any random row could be returned.
2026-02-09 19:31:08 +05:30
Ashwin Bhatkal
128497f27a chore: folder name change + CODEOWNER update (#10246)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: folder name change + CODEOWNER update

* chore: revert multi select file change
2026-02-09 09:40:26 +00:00
66 changed files with 2126 additions and 2109 deletions

7
.github/CODEOWNERS vendored
View File

@@ -133,5 +133,8 @@
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## UplotV2
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend

View File

@@ -140,10 +140,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
const allOptionShown = value === ALL_SELECTED_VALUE;
// Value passed to the underlying Ant Select component
const displayValue = useMemo(

View File

@@ -18,8 +18,8 @@ import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynam
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,

View File

@@ -1,10 +1,11 @@
import { memo, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { memo, useEffect, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { VariableItemProps } from './VariableItem';
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
type CustomVariableInputProps = Pick<
VariableItemProps,
@@ -29,16 +30,31 @@ function CustomVariableInput({
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: customVariableSelectStrategy,
});
// Apply default on mount — options are available synchronously for custom variables
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(applyDefaultIfNeeded, []);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={optionsData}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}

View File

@@ -13,7 +13,6 @@ import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
@@ -153,14 +152,7 @@ function DashboardVariableSelection(): JSX.Element | null {
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
) : (
return (
<VariableItem
key={key}
existingVariables={dashboardVariables}

View File

@@ -0,0 +1,343 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { getOptionsForDynamicVariable } from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
import './DashboardVariableSelection.styles.scss';
type DynamicVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
// eslint-disable-next-line sonarjs/cognitive-complexity
function DynamicVariableInput({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const {
value,
tempSelection,
setTempSelection,
handleClear,
enableSelectAll,
defaultValue,
applyDefaultIfNeeded,
onChange,
onDropdownVisibleChange,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: dynamicVariableSelectStrategy,
allAvailableOptionStrings,
});
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
// eslint-disable-next-line sonarjs/cognitive-complexity
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((val) => val !== null && val !== undefined && val !== '')
.map((val) => val.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((val) => {
// Check if value is a number
const numValue = Number(val);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return val; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${val.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
(visible: boolean): void => {
setIsDropdownOpen(visible);
onDropdownVisibleChange(visible);
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
},
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
],
{
enabled:
variableData.type === 'DYNAMIC' &&
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute,
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
const allNewOptions = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
applyDefaultIfNeeded(allNewOptions);
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}, [refetch]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const lowerText = text.toLowerCase();
setFilteredOptionsData(
optionsData.filter((option) =>
option.toString().toLowerCase().includes(lowerText),
),
);
setRelatedValues(
originalRelatedValues.filter((val) =>
val.toLowerCase().includes(lowerText),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const selectOptions = useMemo(
() =>
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
[filteredOptionsData, relatedValues],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// dynamic variable specific + API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
isDynamicVariable
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
/>
);
}
export default memo(DynamicVariableInput);

View File

@@ -1,602 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty, isUndefined } from 'lodash-es';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import {
areArraysEqual,
getOptionsForDynamicVariable,
getSelectValue,
uniqueValues,
} from './util';
import './DashboardVariableSelection.styles.scss';
interface DynamicVariableSelectionProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}
function DynamicVariableSelection({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableSelectionProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((value) => value !== null && value !== undefined && value !== '')
.map((value) => value.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((value) => {
// Check if value is a number
const numValue = Number(value);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return value; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${value.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
],
{
enabled: variableData.type === 'DYNAMIC',
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// Build union of available options shown in dropdown (normalized + related)
const allAvailableOptionStrings = [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
];
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
}
},
[variableData, onValueUpdate, optionsData, relatedValues],
);
useEffect(() => {
if (
variableData.dynamicVariablesSource &&
variableData.dynamicVariablesAttribute
) {
refetch();
}
}, [
refetch,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
]);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const localFilteredOptionsData: (string | number | boolean)[] = [];
optionsData.forEach((option) => {
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
localFilteredOptionsData.push(option);
}
});
setFilteredOptionsData(localFilteredOptionsData);
setRelatedValues(
originalRelatedValues.filter((value) =>
value.toLowerCase().includes(text.toLowerCase()),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
// Add a handler for tracking temporary selection changes
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
const sanitizedValue = uniqueValues(value);
setTempSelection(sanitizedValue);
},
[variableData.multiSelect],
);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Update dropdown open state for auto-checking
setIsDropdownOpen(visible);
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// When ALL is selected, set selection to exactly the latest available values
const latestAll = [...allAvailableOptionStrings];
setTempSelection(latestAll);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Only call handleChange if there's actually a change in the selection
const currentValue = variableData.selectedValue;
// Helper function to check if arrays have the same elements regardless of order
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return areArraysEqual(sortedA, sortedB);
};
// If ALL was selected before and remains ALL after, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === allAvailableOptionStrings.length &&
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
const hasChanged =
tempSelection !== currentValue &&
!(
Array.isArray(tempSelection) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(tempSelection, currentValue)
);
if (hasChanged) {
handleChange(tempSelection);
}
setTempSelection(undefined);
}
// Always reset filtered data when dropdown closes, regardless of tempSelection state
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
};
useEffect(
() => (): void => {
// Cleanup on unmount
setTempSelection(undefined);
setFilteredOptionsData([]);
setRelatedValues([]);
setApiSearchText('');
},
[],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else if (variableData.allSelected) {
// If ALL is selected but no stored values, derive from available options
// This handles the case where we don't store values in localStorage for ALL
value = allAvailableOptionStrings;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
variableData.allSelected,
selectedValue,
tempSelection,
optionsData,
allAvailableOptionStrings,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
<Tooltip title={variableData.description}>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={variableData.id}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
defaultValue={variableData.defaultValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={
(tempSelection || selectValue) === ALL_SELECT_VALUE
? 'ALL'
: tempSelection || selectValue
}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
) : (
<CustomSelect
key={variableData.id}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
// eslint-disable-next-line sonarjs/no-identical-functions
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -1,13 +1,11 @@
import { memo, useCallback, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isString } from 'lodash-es';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -15,20 +13,18 @@ import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, checkAPIInvocation } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
interface QueryVariableInputProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dependencyData: IDependencyData | null;
}
type QueryVariableInputProps = Pick<
VariableItemProps,
| 'variableData'
| 'existingVariables'
| 'onValueUpdate'
| 'variablesToGetUpdated'
| 'setVariablesToGetUpdated'
| 'dependencyData'
>;
function QueryVariableInput({
variableData,
@@ -56,13 +52,15 @@ function QueryVariableInput({
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: queryVariableSelectStrategy,
});
const validVariableUpdate = (): boolean => {
const validVariableUpdate = useCallback((): boolean => {
if (!variableData.name) {
return false;
}
@@ -70,86 +68,100 @@ function QueryVariableInput({
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
};
}, [variableData.name, variablesToGetUpdated]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const getOptions = (variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
let valueNotInList = false;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
let valueNotInList = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
valueNotInList = true;
}
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.name && variableData.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (variableData.name && variableData.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
}
setOptionsData(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
}
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
};
},
[
variableData,
optionsData,
onValueUpdate,
tempSelection,
setTempSelection,
validVariableUpdate,
setVariablesToGetUpdated,
applyDefaultIfNeeded,
],
);
const { isLoading, refetch } = useQuery(
[
@@ -162,7 +174,6 @@ function QueryVariableInput({
{
enabled:
variableData &&
variableData.type === 'QUERY' &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
@@ -207,10 +218,19 @@ function QueryVariableInput({
refetch();
}, [refetch]);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={optionsData}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}

View File

@@ -3,6 +3,7 @@ import { orange } from '@ant-design/colors';
import { WarningOutlined } from '@ant-design/icons';
import { Popover, Tooltip, Typography } from 'antd';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { OptionData } from 'components/NewSelect/types';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
@@ -12,7 +13,7 @@ const errorIconStyle = { margin: '0 0.5rem' };
interface SelectVariableInputProps {
variableId: string;
options: (string | number | boolean)[];
options: OptionData[];
value: string | string[] | undefined;
enableSelectAll: boolean;
isMultiSelect: boolean;
@@ -23,13 +24,17 @@ interface SelectVariableInputProps {
loading?: boolean;
errorMessage?: string | null;
onRetry?: () => void;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
}
const MAX_TAG_DISPLAY_VALUES = 10;
function maxTagPlaceholder(
export const renderMaxTagPlaceholder = (
omittedValues: { label?: React.ReactNode; value?: string | number }[],
): JSX.Element {
): JSX.Element => {
const valuesToShow = omittedValues.slice(0, MAX_TAG_DISPLAY_VALUES);
const hasMore = omittedValues.length > MAX_TAG_DISPLAY_VALUES;
const tooltipText =
@@ -41,7 +46,7 @@ function maxTagPlaceholder(
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}
};
function SelectVariableInput({
variableId,
@@ -56,16 +61,11 @@ function SelectVariableInput({
enableSelectAll,
isMultiSelect,
defaultValue,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}: SelectVariableInputProps): JSX.Element {
const selectOptions = useMemo(
() =>
options.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[options],
);
const commonProps = useMemo(
() => ({
// main props
@@ -82,23 +82,33 @@ function SelectVariableInput({
showSearch: true,
bordered: false,
// dynamic props
// changing props
'data-testid': 'variable-select',
onChange,
loading,
options: selectOptions,
options,
errorMessage,
onRetry,
// dynamic variable only props
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}),
[
variableId,
defaultValue,
onChange,
loading,
selectOptions,
options,
value,
errorMessage,
onRetry,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
],
);
@@ -110,11 +120,11 @@ function SelectVariableInput({
placement="bottomLeft"
maxTagCount={2}
onDropdownVisibleChange={onDropdownVisibleChange}
maxTagPlaceholder={maxTagPlaceholder}
maxTagPlaceholder={renderMaxTagPlaceholder}
onClear={onClear}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={value !== ALL_SELECT_VALUE && value !== 'ALL'}
allowClear={value !== ALL_SELECT_VALUE}
/>
) : (
<CustomSelect {...commonProps} />

View File

@@ -5,6 +5,7 @@ import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/da
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import CustomVariableInput from './CustomVariableInput';
import DynamicVariableInput from './DynamicVariableInput';
import QueryVariableInput from './QueryVariableInput';
import TextboxVariableInput from './TextboxVariableInput';
@@ -16,8 +17,9 @@ export interface VariableItemProps {
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
@@ -68,6 +70,13 @@ function VariableItem({
dependencyData={dependencyData}
/>
)}
{variableType === 'DYNAMIC' && (
<DynamicVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
)}
</div>
</div>
);

View File

@@ -5,7 +5,7 @@ import * as ReactRedux from 'react-redux';
import { fireEvent, render, screen } from '@testing-library/react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DynamicVariableSelection';
import DynamicVariableInput from '../DynamicVariableInput';
// Don't mock the components - use real ones
@@ -54,7 +54,7 @@ const mockApiResponse = {
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableSelection Component', () => {
describe('DynamicVariableInput Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
@@ -108,18 +108,13 @@ describe('DynamicVariableSelection Component', () => {
it('renders with single select variable correctly', () => {
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders correctly
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Verify the selected value is displayed
const selectedItem = screen.getByRole('combobox');
expect(selectedItem).toBeInTheDocument();
@@ -136,18 +131,13 @@ describe('DynamicVariableSelection Component', () => {
};
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={multiSelectWithAllSelected}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify variable name is rendered
expect(
screen.getByText(`$${multiSelectWithAllSelected.name}`),
).toBeInTheDocument();
// In ALL selected mode, there should be an "ALL" text element
expect(screen.getByText('ALL')).toBeInTheDocument();
});
@@ -164,18 +154,13 @@ describe('DynamicVariableSelection Component', () => {
});
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders in loading state
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Open dropdown to see loading text
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
@@ -199,18 +184,13 @@ describe('DynamicVariableSelection Component', () => {
});
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component renders
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// For error states, we should check that error handling is in place
// Without opening the dropdown as the error message might be handled differently
expect(ReactQuery.useQuery).toHaveBeenCalled();
@@ -219,7 +199,7 @@ describe('DynamicVariableSelection Component', () => {
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
@@ -235,6 +215,8 @@ describe('DynamicVariableSelection Component', () => {
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
'',
'Traces',
'service.name',
],
expect.objectContaining({
enabled: true, // Type is 'DYNAMIC'
@@ -255,16 +237,13 @@ describe('DynamicVariableSelection Component', () => {
};
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={customVariable}
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component correctly displays the selected value
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
// Find the selection item in the component using data-testid
const selectElement = screen.getByTestId('variable-select');
expect(selectElement).toBeInTheDocument();

View File

@@ -63,10 +63,10 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('option1')).toBeInTheDocument();
expect(await screen.findByText('option1')).toBeInTheDocument();
});
test('should show placeholder when no previous and no default', async () => {
test('should auto-select first option when no previous and no default', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
@@ -85,7 +85,8 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('Select value')).toBeInTheDocument();
// With the new variable select strategy, the first option is auto-selected
expect(await screen.findByText('option1')).toBeInTheDocument();
});
});
@@ -110,7 +111,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('ALL')).toBeInTheDocument();
expect(await screen.findByText('ALL')).toBeInTheDocument();
});
});
@@ -134,7 +135,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('Select value')).toBeInTheDocument();
expect(await screen.findByText('Select value')).toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { isEmpty } from 'lodash-es';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { ALL_SELECT_VALUE } from '../utils';
import { areArraysEqual, getSelectValue } from './util';
import { VariableSelectStrategy } from './variableSelectStrategy/variableSelectStrategyTypes';
import {
areArraysEqualIgnoreOrder,
uniqueValues,
} from './variableSelectStrategy/variableSelectStrategyUtils';
interface UseDashboardVariableSelectHelperParams {
variableData: IDashboardVariable;
@@ -12,7 +18,11 @@ interface UseDashboardVariableSelectHelperParams {
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
strategy: VariableSelectStrategy;
/** Override for all available option strings (default: optionsData.map(String)) */
allAvailableOptionStrings?: string[];
}
interface UseDashboardVariableSelectHelperReturn {
@@ -31,6 +41,11 @@ interface UseDashboardVariableSelectHelperReturn {
onChange: (value: string | string[]) => void;
onDropdownVisibleChange: (visible: boolean) => void;
handleClear: () => void;
// Default value helpers
applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[],
) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -38,6 +53,8 @@ export function useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy,
allAvailableOptionStrings,
}: UseDashboardVariableSelectHelperParams): UseDashboardVariableSelectHelperReturn {
const { selectedValue } = variableData;
@@ -52,11 +69,37 @@ export function useDashboardVariableSelectHelper({
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const effectiveAllAvailableOptionStrings = useMemo(
() => allAvailableOptionStrings ?? optionsData.map((v) => v.toString()),
[allAvailableOptionStrings, optionsData],
);
const selectValue =
variableData.allSelected && enableSelectAll
? 'ALL'
? ALL_SELECT_VALUE
: selectedValueStringified;
const getDefaultValue = useCallback(
(overrideOptions?: (string | number | boolean)[]) => {
const options = overrideOptions || optionsData;
if (variableData.multiSelect) {
if (variableData.showALLOption) {
return variableData.defaultValue || options.map((o) => o.toString());
}
return variableData.defaultValue || options?.[0]?.toString();
}
return variableData.defaultValue || options[0]?.toString();
},
[
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
optionsData,
],
);
const defaultValue = useMemo(() => getDefaultValue(), [getDefaultValue]);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
@@ -69,29 +112,21 @@ export function useDashboardVariableSelectHelper({
) {
return;
}
if (variableData.name) {
// Check if ALL is effectively selected by comparing with available options
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
// For ALL selection, pass optionsData as the value and set allSelected to true
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
strategy.handleChange({
value,
variableData,
optionsData,
allAvailableOptionStrings: effectiveAllAvailableOptionStrings,
onValueUpdate,
});
},
[
variableData.multiSelect,
variableData.selectedValue,
variableData.name,
variableData.id,
variableData.showALLOption,
onValueUpdate,
variableData,
optionsData,
effectiveAllAvailableOptionStrings,
onValueUpdate,
strategy,
],
);
@@ -99,79 +134,96 @@ export function useDashboardVariableSelectHelper({
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
setTempSelection(uniqueValues(value));
},
[variableData.multiSelect],
);
// Apply default value on first render if no selection exists
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
// Single select onChange: apply default if value is empty
const handleSingleSelectChange = useCallback(
(inputValue: string | string[]): void => {
if (isEmpty(inputValue)) {
if (defaultValue !== undefined) {
handleChange(defaultValue as string | string[]);
}
return;
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
selectedValue,
tempSelection,
optionsData,
]);
// Apply default values when needed
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
handleChange(inputValue);
},
[handleChange, defaultValue],
);
// Handle dropdown visibility changes
const onDropdownVisibleChange = useCallback(
(visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
if (variableData.allSelected && enableSelectAll) {
// When ALL is selected, show all available options as individually checked
setTempSelection([...effectiveAllAvailableOptionStrings]);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
// If ALL was selected before AND all options remain selected, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === effectiveAllAvailableOptionStrings.length &&
effectiveAllAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
// Apply default if closing with empty selection
let valueToApply = tempSelection;
if (isEmpty(tempSelection) && defaultValue !== undefined) {
valueToApply = defaultValue as string | string[];
}
// Order-agnostic change detection
const currentValue = variableData.selectedValue;
const hasChanged =
valueToApply !== currentValue &&
!(
Array.isArray(valueToApply) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(valueToApply, currentValue)
);
if (hasChanged) {
handleChange(valueToApply);
}
setTempSelection(undefined);
}
},
[variableData, tempSelection, handleChange],
[
variableData,
enableSelectAll,
effectiveAllAvailableOptionStrings,
tempSelection,
handleChange,
defaultValue,
],
);
// Explicit function for callers to apply default on mount / data load
// Pass overrideOptions when freshly-loaded options aren't in state yet (async callers)
const applyDefaultIfNeeded = useCallback(
(overrideOptions?: (string | number | boolean)[]): void => {
if (isEmpty(selectValue)) {
const defaultValueFromOptions = getDefaultValue(overrideOptions);
if (defaultValueFromOptions !== undefined) {
handleChange(defaultValueFromOptions as string | string[]);
}
}
},
[selectValue, handleChange, getDefaultValue],
);
const handleClear = useCallback((): void => {
@@ -182,11 +234,9 @@ export function useDashboardVariableSelectHelper({
? tempSelection || selectValue
: selectValue;
const defaultValue = variableData.defaultValue || selectValue;
const onChange = useMemo(() => {
return variableData.multiSelect ? handleTempChange : handleChange;
}, [variableData.multiSelect, handleTempChange, handleChange]);
return variableData.multiSelect ? handleTempChange : handleSingleSelectChange;
}, [variableData.multiSelect, handleTempChange, handleSingleSelectChange]);
return {
tempSelection,
@@ -197,5 +247,6 @@ export function useDashboardVariableSelectHelper({
value,
defaultValue,
onChange,
applyDefaultIfNeeded,
};
}

View File

@@ -363,25 +363,6 @@ export const uniqueOptions = (options: OptionData[]): OptionData[] => {
return uniqueOptions;
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,

View File

@@ -0,0 +1,4 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const customVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -0,0 +1,20 @@
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const defaultVariableSelectStrategy: VariableSelectStrategy = {
handleChange({ value, variableData, optionsData, onValueUpdate }) {
if (!variableData.name) {
return;
}
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
},
};

View File

@@ -0,0 +1,37 @@
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const dynamicVariableSelectStrategy: VariableSelectStrategy = {
handleChange({
value,
variableData,
allAvailableOptionStrings,
onValueUpdate,
}) {
if (!variableData.name) {
return;
}
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
},
};

View File

@@ -0,0 +1,4 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const queryVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -0,0 +1,17 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface VariableSelectStrategy {
handleChange(params: {
value: string | string[];
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
allAvailableOptionStrings: string[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}): void;
}

View File

@@ -0,0 +1,32 @@
import { isEqual } from 'lodash-es';
export const areArraysEqualIgnoreOrder = (
a: (string | number | boolean)[],
b: (string | number | boolean)[],
): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return isEqual(sortedA, sortedB);
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};

View File

@@ -2,7 +2,7 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import * as ReactRedux from 'react-redux';
import {
act,
@@ -15,13 +15,22 @@ import {
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
// Mock useQuery from react-query
jest.mock('react-query', () => {
const originalModule = jest.requireActual('react-query');
return {
...originalModule,
useQuery: jest.fn(),
};
});
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
@@ -59,6 +68,46 @@ describe('Dynamic Variable Default Behavior', () => {
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
// Mock useQuery implementation to avoid infinite re-renders
// and ensure onSuccess is called once
(useQuery as jest.Mock).mockImplementation((key, options) => {
const { onSuccess, enabled, queryFn } = options || {};
const variableName = key[1];
const dynamicVarsKey = key[2];
React.useEffect(() => {
if (enabled !== false) {
if (onSuccess) {
// For 'services' tests:
// 1. "Default to ALL" expectations imply empty options -> [] behavior. This happens when selectedValue is undefined (dynamicVarsKey has 'null').
// 2. "ALL Option Special Value" needs full options to render the "ALL" item in dropdown. This happens when selectedValue is defined.
if (
variableName === 'services' &&
typeof dynamicVarsKey === 'string' &&
dynamicVarsKey.includes('null')
) {
onSuccess({
...mockApiResponse,
data: { ...mockApiResponse.data, normalizedValues: [] },
});
} else {
onSuccess(mockApiResponse);
}
}
if (queryFn) {
queryFn();
}
}
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
return {
isLoading: false,
isError: false,
data: mockApiResponse,
refetch: jest.fn(),
};
});
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
@@ -84,7 +133,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -120,7 +169,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -164,7 +213,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -172,9 +221,6 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$service')).toBeInTheDocument();
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
@@ -232,7 +278,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -267,7 +313,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -293,7 +339,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('should default to ALL when no default and no previous selection', async () => {
it('sahould default to ALL when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
@@ -311,7 +357,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -345,7 +391,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[], // Empty array when allSelected is true
[],
true, // allSelected = true
false,
);
@@ -371,7 +417,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -408,7 +454,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -416,9 +462,6 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$services')).toBeInTheDocument();
// Check if ALL is displayed in the UI (in the main selection area)
const allTextElement = screen.getByText('ALL');
expect(allTextElement).toBeInTheDocument();

View File

@@ -33,8 +33,8 @@ import { useChartMutable } from 'hooks/useChartMutable';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import GetMinMax from 'lib/getMinMax';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';

View File

@@ -8,8 +8,8 @@ import { populateMultipleResults } from 'container/NewWidget/LeftContainer/Widge
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import getTimeString from 'lib/getTimeString';
import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';

View File

@@ -6,7 +6,7 @@ import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -103,7 +103,7 @@ export const getTotalLogSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -140,7 +140,7 @@ export const getTotalTraceSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -177,7 +177,7 @@ export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -214,7 +214,7 @@ export const getLogCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -251,7 +251,7 @@ export const getLogSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -288,7 +288,7 @@ export const getSpanCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -325,7 +325,7 @@ export const getSpanSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],
@@ -362,7 +362,7 @@ export const getMetricCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
stepInterval: null,
timeAggregation: 'increase',
},
],

View File

@@ -39,7 +39,10 @@ function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
dataSource={DataSource.METRICS}
/>
)}
<DashboardsAndAlertsPopover metricName={metric.name} />
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
</div>
);
}

View File

@@ -2,8 +2,8 @@
import { useMemo, useState } from 'react';
import { Card, Input, Select, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { MetricsexplorertypesMetricMetadataDTOType } from 'api/generated/services/sigNoz.schemas';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import classNames from 'classnames';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
@@ -40,10 +40,8 @@ import {
* returns true if the feature flag is enabled, false otherwise
* Show the inspect button in metrics explorer if the feature flag is enabled
*/
export function isInspectEnabled(
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined,
): boolean {
return metricType === MetricsexplorertypesMetricMetadataDTOType.gauge;
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
return metricType === MetricType.GAUGE;
}
export function getAllTimestampsOfMetrics(

View File

@@ -1,17 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import {
Button,
Collapse,
Input,
Menu,
Popover,
Skeleton,
Typography,
} from 'antd';
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent';
import { useGetMetricAttributes } from 'api/generated/services/metrics';
import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { useNotifications } from 'hooks/useNotifications';
@@ -24,8 +15,6 @@ import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import { AllAttributesProps, AllAttributesValueProps } from './types';
import { getMetricDetailsQuery } from './utils';
const ALL_ATTRIBUTES_KEY = 'all-attributes';
export function AllAttributesValue({
filterKey,
filterValue,
@@ -121,31 +110,12 @@ export function AllAttributesValue({
function AllAttributes({
metricName,
attributes,
metricType,
}: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
const {
data: attributesData,
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
mutate: getMetricAttributes,
} = useGetMetricAttributes();
useEffect(() => {
if (metricName) {
getMetricAttributes({
data: {
metricName,
},
});
}
}, [getMetricAttributes, metricName]);
const attributes = useMemo(
() => attributesData?.data?.data?.attributes ?? [],
[attributesData],
const [activeKey, setActiveKey] = useState<string | string[]>(
'all-attributes',
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
@@ -208,7 +178,7 @@ function AllAttributes({
attributes.filter(
(attribute) =>
attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
attribute.values?.some((value) =>
attribute.value.some((value) =>
value.toLowerCase().includes(searchString.toLowerCase()),
),
),
@@ -225,7 +195,7 @@ function AllAttributes({
},
value: {
key: attribute.key,
value: attribute.values,
value: attribute.value,
},
}))
: [],
@@ -282,12 +252,6 @@ function AllAttributes({
],
);
const emptyText = useMemo(
() =>
isErrorAttributes ? 'Error fetching attributes' : 'No attributes found',
[isErrorAttributes],
);
const items = useMemo(
() => [
{
@@ -306,7 +270,6 @@ function AllAttributes({
onClick={(e): void => {
e.stopPropagation();
}}
disabled={isLoadingAttributes}
/>
</div>
),
@@ -314,37 +277,25 @@ function AllAttributes({
children: (
<ResizeTable
columns={columns}
loading={isLoadingAttributes}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }}
locale={{
emptyText,
}}
/>
),
},
],
[searchString, columns, isLoadingAttributes, tableData, emptyText],
[columns, tableData, searchString],
);
if (isLoadingAttributes) {
return (
<div className="all-attributes-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered
className="metrics-accordion metrics-all-attributes-accordion"
className="metrics-accordion metrics-metadata-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys as string[])}
onChange={(keys): void => setActiveKey(keys)}
items={items}
/>
);

View File

@@ -2,84 +2,36 @@ import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Dropdown, Typography } from 'antd';
import { Skeleton } from 'antd/lib';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types';
import {
useGetMetricAlerts,
useGetMetricDashboards,
} from 'api/generated/services/metrics';
function DashboardsAndAlertsPopover({
metricName,
alerts,
dashboards,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const {
data: alertsData,
isLoading: isLoadingAlerts,
isError: isErrorAlerts,
} = useGetMetricAlerts(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const {
data: dashboardsData,
isLoading: isLoadingDashboards,
isError: isErrorDashboards,
} = useGetMetricDashboards(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const alerts = useMemo(() => {
return alertsData?.data?.data?.alerts ?? [];
}, [alertsData]);
const dashboards = useMemo(() => {
const currentDashboards = dashboardsData?.data?.data?.dashboards ?? [];
// Remove duplicate dashboards
return currentDashboards.filter(
(dashboard, index, self) =>
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
);
}, [dashboardsData]);
const alertsPopoverContent = useMemo(() => {
if (alerts && alerts.length > 0) {
return alerts.map((alert) => ({
key: alert.alertId,
key: alert.alert_id,
label: (
<Typography.Link
key={alert.alertId}
key={alert.alert_id}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alertId);
params.set(QueryParams.ruleId, alert.alert_id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}}
className="dashboards-popover-content-item"
>
{alert.alertName || alert.alertId}
{alert.alert_name || alert.alert_id}
</Typography.Link>
),
}));
@@ -87,44 +39,41 @@ function DashboardsAndAlertsPopover({
return null;
}, [alerts, params]);
const uniqueDashboards = useMemo(
() =>
dashboards?.filter(
(item, index, self) =>
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
),
[dashboards],
);
const dashboardsPopoverContent = useMemo(() => {
if (dashboards && dashboards.length > 0) {
return dashboards.map((dashboard) => ({
key: dashboard.dashboardId,
if (uniqueDashboards && uniqueDashboards.length > 0) {
return uniqueDashboards.map((dashboard) => ({
key: dashboard.dashboard_id,
label: (
<Typography.Link
key={dashboard.dashboardId}
key={dashboard.dashboard_id}
onClick={(): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
dashboardId: dashboard.dashboard_id,
}),
);
}}
className="dashboards-popover-content-item"
>
{dashboard.dashboardName || dashboard.dashboardId}
{dashboard.dashboard_name || dashboard.dashboard_id}
</Typography.Link>
),
}));
}
return null;
}, [dashboards, safeNavigate]);
}, [uniqueDashboards, safeNavigate]);
if (isLoadingAlerts || isLoadingDashboards) {
return (
<div className="dashboards-and-alerts-popover-container">
<Skeleton title={false} paragraph={{ rows: 1 }} active />
</div>
);
}
// If there are no dashboards or alerts or both have errors, don't show the popover
const hidePopover =
(!dashboardsPopoverContent && !alertsPopoverContent) ||
(isErrorAlerts && isErrorDashboards);
if (hidePopover) {
return <div className="dashboards-and-alerts-popover-container" />;
if (!dashboardsPopoverContent && !alertsPopoverContent) {
return null;
}
return (
@@ -143,7 +92,8 @@ function DashboardsAndAlertsPopover({
>
<Grid size={12} color={Color.BG_SIENNA_500} />
<Typography.Text>
{pluralize(dashboards.length, 'dashboard')}
{uniqueDashboards?.length} dashboard
{uniqueDashboards?.length === 1 ? '' : 's'}
</Typography.Text>
</div>
</Dropdown>
@@ -162,7 +112,7 @@ function DashboardsAndAlertsPopover({
>
<Bell size={12} color={Color.BG_SAKURA_500} />
<Typography.Text>
{pluralize(alerts.length, 'alert rule')}
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
</Typography.Text>
</div>
</Dropdown>

View File

@@ -1,129 +0,0 @@
import { Skeleton, Tooltip, Typography } from 'antd';
import { useMemo } from 'react';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { HighlightsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
} from './utils';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
function Highlights({ metricName }: HighlightsProps): JSX.Element {
const {
data: metricHighlightsData,
isLoading: isLoadingMetricHighlights,
isError: isErrorMetricHighlights,
} = useGetMetricHighlights(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const metricHighlights = useMemo(() => {
return metricHighlightsData?.data?.data ?? null;
}, [metricHighlightsData]);
const dataPoints = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
const timeSeries = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
const timeSeriesActive = formatNumberToCompactFormat(
metricHighlights.activeTimeSeries,
);
const timeSeriesTotal = formatNumberToCompactFormat(
metricHighlights.totalTimeSeries,
);
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
const lastReceived = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
const displayText = formatTimestampToReadableDate(
metricHighlights.lastReceived,
);
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip title={displayText}>{displayText}</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
if (isLoadingMetricHighlights) {
return (
<div className="metric-details-content-grid">
<Skeleton title={false} paragraph={{ rows: 2 }} active />
</div>
);
}
return (
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
{dataPoints}
{timeSeries}
{lastReceived}
</div>
</div>
);
}
export default Highlights;

View File

@@ -1,25 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
import { Button, Collapse, Input, Select, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent';
import {
invalidateGetMetricMetadata,
useUpdateMetricMetadata,
} from 'api/generated/services/metrics';
import {
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
import { ResizeTable } from 'components/ResizeTable';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { useNotifications } from 'hooks/useNotifications';
import { Edit2, Save, X } from 'lucide-react';
@@ -30,27 +23,23 @@ import {
} from '../Summary/constants';
import { MetricTypeRenderer } from '../Summary/utils';
import { METRIC_METADATA_KEYS } from './constants';
import { MetadataProps, MetricMetadataState, TableFields } from './types';
import {
transformMetricType,
transformTemporality,
transformUpdateMetricMetadataRequest,
} from './utils';
import { MetadataProps } from './types';
import { determineIsMonotonic } from './utils';
function Metadata({
metricName,
metadata,
isErrorMetricMetadata,
isLoadingMetricMetadata,
refetchMetricDetails,
}: MetadataProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [metricMetadata, setMetricMetadata] = useState<MetricMetadataState>({
type: MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum,
description: '',
temporality:
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified,
unit: '',
const [
metricMetadata,
setMetricMetadata,
] = useState<UpdateMetricMetadataProps>({
metricType: metadata?.metric_type || MetricType.SUM,
description: metadata?.description || '',
temporality: metadata?.temporality,
unit: metadata?.unit,
});
const { notifications } = useNotifications();
const {
@@ -62,18 +51,6 @@ function Metadata({
);
const queryClient = useQueryClient();
// Initialize state from metadata api data
useEffect(() => {
if (metadata) {
setMetricMetadata({
type: transformMetricType(metadata.type),
description: metadata.description,
temporality: transformTemporality(metadata.temporality),
unit: metadata.unit,
});
}
}, [metadata]);
const tableData = useMemo(
() =>
metadata
@@ -82,7 +59,7 @@ function Metadata({
temporality: metadata?.temporality,
})
// Filter out monotonic as user input is not required
.filter((key) => key !== TableFields.IS_MONOTONIC)
.filter((key) => key !== 'monotonic')
.map((key) => ({
key,
value: {
@@ -95,37 +72,30 @@ function Metadata({
);
// Render un-editable field value
const renderUneditableField = useCallback(
(key: keyof MetricMetadataState, value: string) => {
if (isErrorMetricMetadata) {
return <FieldRenderer field="-" />;
}
if (key === TableFields.TYPE) {
return <MetricTypeRenderer type={value as MetricType} />;
}
let fieldValue = value;
if (key === TableFields.UNIT) {
fieldValue = getUniversalNameFromMetricUnit(value);
}
return <FieldRenderer field={fieldValue || '-'} />;
},
[isErrorMetricMetadata],
);
const renderUneditableField = useCallback((key: string, value: string) => {
if (key === 'metric_type') {
return <MetricTypeRenderer type={value as MetricType} />;
}
let fieldValue = value;
if (key === 'unit') {
fieldValue = getUniversalNameFromMetricUnit(value);
}
return <FieldRenderer field={fieldValue || '-'} />;
}, []);
const renderColumnValue = useCallback(
(field: { value: string; key: keyof MetricMetadataState }): JSX.Element => {
(field: { value: string; key: string }): JSX.Element => {
if (!isEditing) {
return renderUneditableField(field.key, field.value);
}
// Don't allow editing of unit if it's already set
const metricUnitAlreadySet =
field.key === TableFields.UNIT && Boolean(metadata?.unit);
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
if (metricUnitAlreadySet) {
return renderUneditableField(field.key, field.value);
}
if (field.key === TableFields.TYPE) {
if (field.key === 'metric_type') {
return (
<Select
data-testid="metric-type-select"
@@ -133,17 +103,17 @@ function Metadata({
value: key,
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
}))}
value={metricMetadata.type}
value={metricMetadata.metricType}
onChange={(value): void => {
setMetricMetadata((prev) => ({
...prev,
metricType: value,
metricType: value as MetricType,
}));
}}
/>
);
}
if (field.key === TableFields.UNIT) {
if (field.key === 'unit') {
return (
<YAxisUnitSelector
value={metricMetadata.unit}
@@ -155,7 +125,7 @@ function Metadata({
/>
);
}
if (field.key === TableFields.Temporality) {
if (field.key === 'temporality') {
return (
<Select
data-testid="temporality-select"
@@ -167,18 +137,22 @@ function Metadata({
onChange={(value): void => {
setMetricMetadata((prev) => ({
...prev,
temporality: value,
temporality: value as Temporality,
}));
}}
/>
);
}
if (field.key === TableFields.DESCRIPTION) {
if (field.key === 'description') {
return (
<Input
data-testid="description-input"
name={field.key}
defaultValue={metricMetadata.description}
defaultValue={
metricMetadata[
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
]
}
onChange={(e): void => {
setMetricMetadata((prev) => ({
...prev,
@@ -227,14 +201,18 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
pathParams: {
metricName: metricName ?? '',
metricName,
payload: {
...metricMetadata,
isMonotonic: determineIsMonotonic(
metricMetadata.metricType,
metricMetadata.temporality,
),
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadata),
},
{
onSuccess: (response): void => {
if (response.status === 200) {
if (response?.statusCode === 200) {
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
[MetricsExplorerEventKeys.MetricName]: metricName,
[MetricsExplorerEventKeys.Tab]: 'summary',
@@ -243,13 +221,9 @@ function Metadata({
notifications.success({
message: 'Metadata updated successfully',
});
refetchMetricDetails();
setIsEditing(false);
// TODO: To update this to use invalidateGetMetricList
// once we have switched to the V2 API in summary page
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_METRICS_LIST]);
invalidateGetMetricMetadata(queryClient, {
metricName,
});
queryClient.invalidateQueries(['metricsList']);
} else {
notifications.error({
message:
@@ -269,24 +243,10 @@ function Metadata({
metricName,
metricMetadata,
notifications,
refetchMetricDetails,
queryClient,
]);
const cancelEdit = useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
e.stopPropagation();
if (metadata) {
setMetricMetadata({
type: transformMetricType(metadata.type),
description: metadata.description,
unit: metadata.unit,
});
}
setIsEditing(false);
},
[metadata],
);
const actionButton = useMemo(() => {
if (isEditing) {
return (
@@ -294,7 +254,10 @@ function Metadata({
<Button
className="action-button"
type="text"
onClick={cancelEdit}
onClick={(e): void => {
e.stopPropagation();
setIsEditing(false);
}}
disabled={isUpdatingMetricsMetadata}
>
<X size={14} />
@@ -331,7 +294,7 @@ function Metadata({
</Button>
</div>
);
}, [isEditing, isUpdatingMetricsMetadata, cancelEdit, handleSave]);
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
const items = useMemo(
() => [
@@ -358,14 +321,6 @@ function Metadata({
[actionButton, columns, tableData],
);
if (isLoadingMetricMetadata) {
return (
<div className="metrics-metadata-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered

View File

@@ -39,7 +39,6 @@
gap: 12px;
.metric-details-content-grid {
height: 50px;
.labels-row,
.values-row {
display: grid;
@@ -73,7 +72,6 @@
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;
height: 32px;
.dashboards-and-alerts-popover {
border-radius: 20px;
@@ -104,14 +102,6 @@
}
}
.metrics-metadata-skeleton-container {
height: 330px;
}
.all-attributes-skeleton-container {
height: 600px;
}
.metrics-accordion {
.ant-table-body {
&::-webkit-scrollbar {
@@ -158,6 +148,7 @@
.all-attributes-search-input {
width: 300px;
border: 1px solid var(--bg-slate-300);
}
}
@@ -170,7 +161,6 @@
.ant-typography:first-child {
font-family: 'Geist Mono';
color: var(--bg-robin-400);
background-color: transparent;
}
}
.all-attributes-contribution {
@@ -247,7 +237,6 @@
}
.metric-metadata-value {
height: 67px;
background: rgba(22, 25, 34, 0.4);
overflow-x: scroll;
.field-renderer-container {
@@ -341,26 +330,18 @@
.metric-details-content {
.metrics-accordion {
.metrics-accordion-header {
.action-menu {
.action-button {
.ant-typography {
color: var(--bg-slate-400);
}
.action-button {
.ant-typography {
color: var(--bg-slate-400);
}
}
}
.metrics-accordion-content {
.metric-metadata-key {
.field-renderer-container {
.label {
color: var(--bg-slate-300);
}
}
.all-attributes-key {
.ant-typography:last-child {
color: var(--bg-vanilla-200);
color: var(--bg-slate-400);
background-color: var(--bg-robin-300);
}
}

View File

@@ -1,8 +1,16 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Empty, Typography } from 'antd';
import {
Button,
Divider,
Drawer,
Empty,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricMetadata } from 'api/generated/services/metrics';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, Crosshair, X } from 'lucide-react';
@@ -11,12 +19,16 @@ import ROUTES from '../../../constants/routes';
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import { isInspectEnabled } from '../Inspect/utils';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata';
import { MetricDetailsProps } from './types';
import { getMetricDetailsQuery } from './utils';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from './utils';
import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss';
@@ -31,52 +43,55 @@ function MetricDetails({
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const {
data: metricMetadataResponse,
isLoading: isLoadingMetricMetadata,
isError: isErrorMetricMetadata,
} = useGetMetricMetadata(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
data,
isLoading,
isFetching,
error: metricDetailsError,
refetch: refetchMetricDetails,
} = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const metadata = useMemo(() => {
if (
!metricMetadataResponse ||
!metricMetadataResponse.data ||
!metricMetadataResponse.data.data
) {
const metric = data?.payload?.data;
const lastReceived = useMemo(() => {
if (!metric) {
return null;
}
const {
type,
description,
unit,
temporality,
isMonotonic,
} = metricMetadataResponse.data.data;
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
return {
type,
description,
unit,
temporality,
isMonotonic,
};
}, [metricMetadataResponse]);
const showInspectFeature = useMemo(
() => isInspectEnabled(metric?.metadata?.metric_type),
[metric],
);
const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [
metadata,
]);
const isMetricDetailsLoading = isLoading || isFetching;
const timeSeries = useMemo(() => {
if (!metric) {
return null;
}
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
return (
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
);
}, [metric]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) {
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
const compositeQuery = getMetricDetailsQuery(
metricName,
metric?.metadata?.metric_type,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -92,7 +107,9 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details',
});
}
}, [metricName, handleExplorerTabChange, metadata?.type]);
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
const isMetricDetailsError = metricDetailsError || !metric;
useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -100,10 +117,6 @@ function MetricDetails({
});
}, []);
if (!metricName) {
return <Empty description="Metric not found" />;
}
return (
<Drawer
width="60%"
@@ -111,7 +124,7 @@ function MetricDetails({
<div className="metric-details-header">
<div className="metric-details-title">
<Divider type="vertical" />
<Typography.Text>{metricName}</Typography.Text>
<Typography.Text>{metric?.name}</Typography.Text>
</div>
<div className="metric-details-header-buttons">
<Button
@@ -129,8 +142,8 @@ function MetricDetails({
aria-label="Inspect Metric"
icon={<Crosshair size={18} />}
onClick={(): void => {
if (metricName) {
openInspectModal(metricName);
if (metric?.name) {
openInspectModal(metric.name);
}
}}
data-testid="inspect-metric-button"
@@ -150,17 +163,60 @@ function MetricDetails({
destroyOnClose
closeIcon={<X size={16} />}
>
<div className="metric-details-content">
<Highlights metricName={metricName} />
<DashboardsAndAlertsPopover metricName={metricName} />
<Metadata
metricName={metricName}
metadata={metadata}
isErrorMetricMetadata={isErrorMetricMetadata}
isLoadingMetricMetadata={isLoadingMetricMetadata}
/>
<AllAttributes metricName={metricName} metricType={metadata?.type} />
</div>
{isMetricDetailsLoading && (
<div data-testid="metric-details-skeleton">
<Skeleton active />
</div>
)}
{isMetricDetailsError && !isMetricDetailsLoading && (
<Empty description="Error fetching metric details" />
)}
{!isMetricDetailsLoading && !isMetricDetailsError && (
<div className="metric-details-content">
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metric?.samples.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric?.samples)}
</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
</Typography.Text>
</div>
</div>
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
<Metadata
metricName={metric?.name}
metadata={metric.metadata}
refetchMetricDetails={refetchMetricDetails}
/>
{metric.attributes && (
<AllAttributes
metricName={metric?.name}
attributes={metric.attributes}
metricType={metric?.metadata?.metric_type}
/>
)}
</div>
)}
</Drawer>
);
}

View File

@@ -1,13 +1,11 @@
import * as reactUseHooks from 'react-use';
import { render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
import ROUTES from '../../../../constants/routes';
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
import { getMockMetricAttributesData } from './testUtlls';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -24,30 +22,32 @@ jest
const mockMetricName = 'test-metric';
const mockMetricType = MetricType.GAUGE;
const mockAttributes: MetricDetailsAttribute[] = [
{
key: 'attribute1',
value: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
value: ['value3'],
valueCount: 1,
},
];
const mockUseCopyToClipboard = jest.fn();
jest
.spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
const useGetMetricAttributesMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAttributes',
);
const mockUseGetMetricAttributes = jest.fn();
describe('AllAttributes', () => {
beforeEach(() => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(),
mutate: mockUseGetMetricAttributes,
});
});
it('renders attributes section with title', () => {
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
@@ -55,7 +55,11 @@ describe('AllAttributes', () => {
it('renders all attribute keys and values', () => {
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
);
// Check attribute keys are rendered
@@ -70,7 +74,11 @@ describe('AllAttributes', () => {
it('renders value counts correctly', () => {
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
);
expect(screen.getByText('2')).toBeInTheDocument(); // For attribute1
@@ -78,36 +86,41 @@ describe('AllAttributes', () => {
});
it('handles empty attributes array', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData({
data: {
attributes: [],
totalKeys: 0,
},
}),
mutate: mockUseGetMetricAttributes,
});
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={[]}
metricType={mockMetricType}
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
expect(screen.getByText('No attributes found')).toBeInTheDocument();
expect(screen.queryByText('No data')).toBeInTheDocument();
});
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
it('clicking on an attribute key opens the explorer with the attribute filter applied', () => {
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
);
await userEvent.click(screen.getByText('attribute1'));
fireEvent.click(screen.getByText('attribute1'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
});
it('filters attributes based on search input', async () => {
it('filters attributes based on search input', () => {
render(
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search'), 'value1');
fireEvent.change(screen.getByPlaceholderText('Search'), {
target: { value: 'value1' },
});
expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
@@ -131,7 +144,7 @@ describe('AllAttributesValue', () => {
expect(screen.getByText('value2')).toBeInTheDocument();
});
it('loads more attributes when show more button is clicked', async () => {
it('loads more attributes when show more button is clicked', () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -142,7 +155,7 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.queryByText('value6')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Show More'));
fireEvent.click(screen.getByText('Show More'));
expect(screen.getByText('value6')).toBeInTheDocument();
});
@@ -159,7 +172,7 @@ describe('AllAttributesValue', () => {
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
});
it('copy button should copy the attribute value to the clipboard', async () => {
it('copy button should copy the attribute value to the clipboard', () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -170,13 +183,13 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1'));
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
await userEvent.click(screen.getByText('Copy Attribute'));
fireEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
});
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
it('explorer button should go to metrics explore with the attribute filter applied', () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -187,10 +200,10 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
await userEvent.click(screen.getByText('value1'));
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
await userEvent.click(screen.getByText('Open in Explorer'));
fireEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1',
'value1',

View File

@@ -1,17 +1,26 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { userEvent } from 'tests/test-utils';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
import {
getMockAlertsData,
getMockDashboardsData,
MOCK_ALERT_1,
MOCK_ALERT_2,
MOCK_DASHBOARD_1,
MOCK_DASHBOARD_2,
} from './testUtlls';
const mockAlert1 = {
alert_id: '1',
alert_name: 'Alert 1',
};
const mockAlert2 = {
alert_id: '2',
alert_name: 'Alert 2',
};
const mockDashboard1 = {
dashboard_id: '1',
dashboard_name: 'Dashboard 1',
};
const mockDashboard2 = {
dashboard_id: '2',
dashboard_name: 'Dashboard 2',
};
const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
@@ -19,6 +28,7 @@ jest.mock('hooks/useSafeNavigate', () => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
@@ -29,155 +39,125 @@ jest.mock('hooks/useUrlQuery', () => ({
default: jest.fn(() => mockUrlQuery),
}));
const MOCK_METRIC_NAME = 'test-metric';
const useGetMetricAlertsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAlerts',
);
const useGetMetricDashboardsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricDashboards',
);
describe('DashboardsAndAlertsPopover', () => {
beforeEach(() => {
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
});
it('renders the popover correctly with multiple dashboards and alerts', () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText(`2 dashboards`)).toBeInTheDocument();
expect(screen.getByText(`2 alert rules`)).toBeInTheDocument();
});
it('renders null with no dashboards and alerts', () => {
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: undefined,
}),
);
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: undefined,
}),
);
const { container } = render(
<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />,
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
expect(
container.querySelector('dashboards-and-alerts-popover-container'),
).toBeNull();
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
expect(
screen.getByText(`${mockAlerts.length} alert rules`),
).toBeInTheDocument();
});
it('renders null with no dashboards and alerts', () => {
const { container } = render(
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
);
expect(container).toBeEmptyDOMElement();
});
it('renders popover with single dashboard and alert', () => {
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: {
alerts: [MOCK_ALERT_1],
},
}),
render(
<DashboardsAndAlertsPopover
alerts={[mockAlert1]}
dashboards={[mockDashboard1]}
/>,
);
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1],
},
}),
);
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
});
it('renders popover with dashboard id if name is not available', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [{ ...MOCK_DASHBOARD_1, dashboardName: '' }],
},
}),
it('renders popover with dashboard id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
/>,
);
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
await userEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardId)).toBeInTheDocument();
fireEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
});
it('renders popover with alert id if name is not available', async () => {
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: {
alerts: [{ ...MOCK_ALERT_1, alertName: '' }],
},
}),
it('renders popover with alert id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
dashboards={mockDashboards}
/>,
);
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
await userEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(MOCK_ALERT_1.alertId)).toBeInTheDocument();
fireEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
});
it('navigates to the dashboard when the dashboard is clicked', async () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
it('navigates to the dashboard when the dashboard is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 dashboards button
await userEvent.click(screen.getByText(`2 dashboards`));
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
// Popover showing list of 2 dashboards should be visible
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
// Click on the first dashboard
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
`/dashboard/${mockDashboard1.dashboard_id}`,
);
});
it('navigates to the alert when the alert is clicked', async () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
it('navigates to the alert when the alert is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 alert rules button
await userEvent.click(screen.getByText(`2 alert rules`));
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
// Popover showing list of 2 alert rules should be visible
expect(screen.getByText(MOCK_ALERT_1.alertName)).toBeInTheDocument();
expect(screen.getByText(MOCK_ALERT_2.alertName)).toBeInTheDocument();
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
// Click on the first alert rule
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
fireEvent.click(screen.getByText(mockAlert1.alert_name));
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
MOCK_ALERT_1.alertId,
mockAlert1.alert_id,
);
});
it('renders unique dashboards even when there are duplicates', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
},
}),
it('renders unique dashboards even when there are duplicates', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[...mockDashboards, mockDashboard1]}
/>,
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText('2 dashboards')).toBeInTheDocument();
await userEvent.click(screen.getByText('2 dashboards'));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
});
});

View File

@@ -1,85 +0,0 @@
import { render } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import Highlights from '../Highlights';
import { getMockMetricHighlightsData } from './testUtlls';
import { formatTimestampToReadableDate } from '../utils';
const MOCK_METRIC_NAME = 'test-metric';
const METRIC_DETAILS_GRID_VALUE_SELECTOR = '.metric-details-grid-value';
const useGetMetricHighlightsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricHighlights',
);
describe('Highlights', () => {
beforeEach(() => {
useGetMetricHighlightsMock.mockReturnValue(getMockMetricHighlightsData());
});
it('should render all highlights data correctly', () => {
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues).toHaveLength(3);
expect(metricHighlightsValues[0].textContent).toBe('1M+');
expect(metricHighlightsValues[1].textContent).toBe('1M total ⎯ 1M active');
expect(metricHighlightsValues[2].textContent).toBe(
formatTimestampToReadableDate('2026-01-24T00:00:00Z'),
);
});
it('should render "-" for highlights data when there is an error', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isError: true,
},
),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues[0].textContent).toBe('-');
expect(metricHighlightsValues[1].textContent).toBe('-');
expect(metricHighlightsValues[2].textContent).toBe('-');
});
it('should render loading state when data is loading', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isLoading: true,
},
),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('should not render grid values when there is no data', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData({
data: undefined,
}),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues).toHaveLength(0);
});
});

View File

@@ -1,22 +1,16 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
UniversalYAxisUnit,
YAxisUnitSelectorProps,
} from 'components/YAxisUnitSelector/types';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useNotificationsHooks from 'hooks/useNotifications';
import { userEvent } from 'tests/test-utils';
import { SelectOption } from 'types/common/select';
import Metadata from '../Metadata';
import { MetricMetadata } from '../types';
import { transformMetricMetadata } from '../utils';
import { getMockMetricMetadataData } from './testUtlls';
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
import { AxiosResponse } from 'axios';
// Mock antd select for testing
jest.mock('antd', () => ({
@@ -78,18 +72,13 @@ jest.mock('react-query', () => ({
}),
}));
const mockUseUpdateMetricMetadataHook = jest.spyOn(
metricsExplorerHooks,
'useUpdateMetricMetadata',
);
type UseUpdateMetricMetadataResult = ReturnType<
typeof metricsExplorerHooks.useUpdateMetricMetadata
>;
const mockUseUpdateMetricMetadata = jest.fn();
const mockMetricMetadata = transformMetricMetadata(
(getMockMetricMetadataData().data as AxiosResponse<GetMetricMetadata200>).data,
) as MetricMetadata;
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue({
mutate: mockUseUpdateMetricMetadata,
isLoading: false,
} as any);
const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn();
@@ -101,26 +90,26 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
} as any);
const mockMetricName = 'test_metric';
const mockMetricMetadata = {
metric_type: MetricType.GAUGE,
description: 'test_description',
unit: 'test_unit',
temporality: Temporality.DELTA,
};
const mockRefetchMetricDetails = jest.fn();
describe('Metadata', () => {
beforeEach(() => {
mockUseUpdateMetricMetadataHook.mockReturnValue(({
mutate: mockUseUpdateMetricMetadata,
} as Partial<UseUpdateMetricMetadataResult>) as UseUpdateMetricMetadataResult);
});
it('should render the metadata properly', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metricType)).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
expect(screen.getByText('Unit')).toBeInTheDocument();
@@ -129,19 +118,18 @@ describe('Metadata', () => {
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
});
it('editing the metadata should show the form inputs', async () => {
it('editing the metadata should show the form inputs', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
fireEvent.click(editButton);
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
@@ -156,47 +144,52 @@ describe('Metadata', () => {
...mockMetricMetadata,
unit: '',
}}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument();
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const metricTypeSelect = screen.getByTestId('metric-type-select');
expect(metricTypeSelect).toBeInTheDocument();
await userEvent.selectOptions(metricTypeSelect, MetricType.SUM);
fireEvent.change(metricTypeSelect, {
target: { value: MetricType.SUM },
});
const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument();
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
fireEvent.change(temporalitySelect, {
target: { value: Temporality.CUMULATIVE },
});
const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument();
await userEvent.selectOptions(unitSelect, 'By');
fireEvent.change(unitSelect, {
target: { value: 'By' },
});
const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument();
await userEvent.click(saveButton);
fireEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: MetricType.SUM,
metricName: mockMetricName,
payload: expect.objectContaining({
description: 'Updated description',
metricType: MetricType.SUM,
temporality: Temporality.CUMULATIVE,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: mockMetricName,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),
@@ -210,28 +203,29 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
await userEvent.click(editButton);
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
await userEvent.click(saveButton);
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ status: 200 });
onSuccessCallback({ statusCode: 200 });
expect(mockSuccessNotification).toHaveBeenCalledWith({
message: 'Metadata updated successfully',
});
expect(mockRefetchMetricDetails).toHaveBeenCalled();
});
it('should show error notification when metadata update fails with non-200 response', async () => {
@@ -239,24 +233,24 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
await userEvent.click(editButton);
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
await userEvent.click(saveButton);
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ status: 500 });
onSuccessCallback({ statusCode: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
@@ -269,20 +263,20 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
await userEvent.click(editButton);
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
await userEvent.click(saveButton);
fireEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
@@ -295,41 +289,39 @@ describe('Metadata', () => {
});
});
it('cancel button should cancel the edit mode', async () => {
it('cancel button should cancel the edit mode', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
fireEvent.click(editButton);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument();
await userEvent.click(cancelButton);
fireEvent.click(cancelButton);
const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument();
});
it('should not allow editing of unit if it is already set', async () => {
it('should not allow editing of unit if it is already set', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
fireEvent.click(editButton);
const unitSelect = screen.queryByTestId('unit-select');
expect(unitSelect).not.toBeInTheDocument();

View File

@@ -1,16 +1,68 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import ROUTES from 'constants/routes';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import MetricDetails from '../MetricDetails';
import { getMockMetricMetadataData } from './testUtlls';
const mockMetricName = 'test-metric';
const mockMetricDescription = 'description for a test metric';
const mockMetricData: MetricDetailsType = {
name: mockMetricName,
description: mockMetricDescription,
unit: 'count',
attributes: [
{
key: 'test-attribute',
value: ['test-value'],
valueCount: 1,
},
],
alerts: [],
dashboards: [],
metadata: {
metric_type: MetricType.SUM,
description: mockMetricDescription,
unit: 'count',
},
type: '',
timeseries: 0,
samples: 0,
timeSeriesTotal: 0,
timeSeriesActive: 0,
lastReceived: '',
};
const mockOpenInspectModal = jest.fn();
const mockOnClose = jest.fn();
const mockUseGetMetricDetailsData = {
data: {
payload: {
data: mockMetricData,
},
},
isLoading: false,
isFetching: false,
isError: false,
error: null,
refetch: jest.fn(),
};
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
jest.spyOn(useUpdateMetricMetadata, 'useUpdateMetricMetadata').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
isError: false,
error: null,
} as any);
const mockHandleExplorerTabChange = jest.fn();
jest
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
@@ -36,50 +88,7 @@ jest.mock('react-query', () => ({
}),
}));
jest.mock(
'container/MetricsExplorer/MetricDetails/AllAttributes',
() =>
function MockAllAttributes(): JSX.Element {
return <div data-testid="all-attributes">All Attributes</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover',
() =>
function MockDashboardsAndAlertsPopover(): JSX.Element {
return (
<div data-testid="dashboards-and-alerts-popover">
Dashboards and Alerts Popover
</div>
);
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Highlights',
() =>
function MockHighlights(): JSX.Element {
return <div data-testid="highlights">Highlights</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Metadata',
() =>
function MockMetadata(): JSX.Element {
return <div data-testid="metadata">Metadata</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',
);
describe('MetricDetails', () => {
beforeEach(() => {
useGetMetricMetadataMock.mockReturnValue(getMockMetricMetadataData());
});
it('renders metric details correctly', () => {
render(
<MetricDetails
@@ -92,15 +101,27 @@ describe('MetricDetails', () => {
);
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
expect(screen.getByTestId('all-attributes')).toBeInTheDocument();
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
expect(
screen.getByTestId('dashboards-and-alerts-popover'),
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
).toBeInTheDocument();
expect(screen.getByTestId('highlights')).toBeInTheDocument();
expect(screen.getByTestId('metadata')).toBeInTheDocument();
});
it('renders the "open in explorer" and "inspect" buttons', async () => {
it('renders the "open in explorer" and "inspect" buttons', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValueOnce({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
metadata: {
...mockMetricData.metadata,
metric_type: MetricType.GAUGE,
},
},
},
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
@@ -114,24 +135,93 @@ describe('MetricDetails', () => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('open-in-explorer-button'));
fireEvent.click(screen.getByTestId('open-in-explorer-button'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
await userEvent.click(screen.getByTestId('inspect-metric-button'));
fireEvent.click(screen.getByTestId('inspect-metric-button'));
expect(mockOpenInspectModal).toHaveBeenCalled();
});
it('should render empty state when metric name is not provided', () => {
it('should render error state when metric details are not found', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isError: true,
error: {
message: 'Error fetching metric details',
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={null}
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('Metric not found')).toBeInTheDocument();
expect(screen.getByText('Error fetching metric details')).toBeInTheDocument();
});
it('should render loading state when metric details are loading', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isLoading: true,
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByTestId('metric-details-skeleton')).toBeInTheDocument();
});
it('should render all attributes section', () => {
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
});
it('should not render all attributes section when relevant data is not present', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
attributes: null,
},
},
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.queryByText('All Attributes')).not.toBeInTheDocument();
});
});

View File

@@ -1,175 +0,0 @@
import {
GetMetricAlerts200,
GetMetricAttributes200,
GetMetricDashboards200,
GetMetricHighlights200,
GetMetricMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { Temporality } from 'types/common/queryBuilder';
export function getMockMetricHighlightsData(
overrides?: Partial<GetMetricHighlights200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights> {
return {
data: {
data: {
data: {
dataPoints: 1000000,
lastReceived: '2026-01-24T00:00:00Z',
totalTimeSeries: 1000000,
activeTimeSeries: 1000000,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights>;
}
export const MOCK_DASHBOARD_1 = {
dashboardName: 'Dashboard 1',
dashboardId: '1',
widgetId: '1',
widgetName: 'Widget 1',
};
export const MOCK_DASHBOARD_2 = {
dashboardName: 'Dashboard 2',
dashboardId: '2',
widgetId: '2',
widgetName: 'Widget 2',
};
export const MOCK_ALERT_1 = {
alertName: 'Alert 1',
alertId: '1',
};
export const MOCK_ALERT_2 = {
alertName: 'Alert 2',
alertId: '2',
};
export function getMockDashboardsData(
overrides?: Partial<GetMetricDashboards200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
return {
data: {
data: {
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2],
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
}
export function getMockAlertsData(
overrides?: Partial<GetMetricAlerts200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts> {
return {
data: {
data: {
data: {
alerts: [MOCK_ALERT_1, MOCK_ALERT_2],
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts>;
}
export function getMockMetricAttributesData(
overrides?: Partial<GetMetricAttributes200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes> {
return {
data: {
data: {
data: {
attributes: [
{
key: 'attribute1',
values: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
values: ['value3'],
valueCount: 1,
},
],
totalKeys: 2,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes>;
}
export function getMockMetricMetadataData(
overrides?: Partial<GetMetricMetadata200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata> {
return {
data: {
data: {
data: {
description: 'test_description',
type: 'gauge',
unit: 'test_unit',
temporality: Temporality.Delta,
isMonotonic: false,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata>;
}

View File

@@ -1,6 +1,6 @@
export const METRIC_METADATA_KEYS = {
description: 'Description',
unit: 'Unit',
metricType: 'Metric Type',
metric_type: 'Metric Type',
temporality: 'Temporality',
};

View File

@@ -1,13 +1,10 @@
import {
MetricsexplorertypesMetricAlertDTO,
MetricsexplorertypesMetricAttributeDTO,
MetricsexplorertypesMetricDashboardDTO,
MetricsexplorertypesMetricHighlightsResponseDTO,
MetricsexplorertypesMetricMetadataDTO,
MetricsexplorertypesMetricMetadataDTOType,
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
MetricDetails,
MetricDetailsAlert,
MetricDetailsAttribute,
MetricDetailsDashboard,
} from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
export interface MetricDetailsProps {
onClose: () => void;
@@ -17,23 +14,21 @@ export interface MetricDetailsProps {
openInspectModal?: (metricName: string) => void;
}
export interface HighlightsProps {
metricName: string;
}
export interface DashboardsAndAlertsPopoverProps {
metricName: string;
dashboards: MetricDetailsDashboard[] | null;
alerts: MetricDetailsAlert[] | null;
}
export interface MetadataProps {
metricName: string;
metadata: MetricMetadata | null;
isErrorMetricMetadata: boolean;
isLoadingMetricMetadata: boolean;
metadata: MetricDetails['metadata'] | undefined;
refetchMetricDetails: () => void;
}
export interface AllAttributesProps {
attributes: MetricDetailsAttribute[];
metricName: string;
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined;
metricType: MetricType | undefined;
}
export interface AllAttributesValueProps {
@@ -41,27 +36,3 @@ export interface AllAttributesValueProps {
filterValue: string[];
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
}
export type MetricHighlight = MetricsexplorertypesMetricHighlightsResponseDTO;
export type MetricAlert = MetricsexplorertypesMetricAlertDTO;
export type MetricDashboard = MetricsexplorertypesMetricDashboardDTO;
export type MetricMetadata = MetricsexplorertypesMetricMetadataDTO;
export interface MetricMetadataState {
type: MetricsexplorertypesUpdateMetricMetadataRequestDTOType;
description: string;
temporality?: MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality;
unit: string;
}
export type MetricAttribute = MetricsexplorertypesMetricAttributeDTO;
export enum TableFields {
DESCRIPTION = 'description',
UNIT = 'unit',
TYPE = 'type',
Temporality = 'temporality',
IS_MONOTONIC = 'isMonotonic',
}

View File

@@ -1,25 +1,12 @@
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
import {
GetMetricMetadata200,
MetricsexplorertypesMetricMetadataDTOTemporality,
MetricsexplorertypesMetricMetadataDTOType,
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricMetadata, MetricMetadataState } from './types';
export function formatTimestampToReadableDate(
timestamp: number | string | undefined,
): string {
if (!timestamp) {
return '-';
}
export function formatTimestampToReadableDate(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
@@ -52,10 +39,7 @@ export function formatTimestampToReadableDate(
return date.toLocaleDateString();
}
export function formatNumberToCompactFormat(num: number | undefined): string {
if (!num) {
return '-';
}
export function formatNumberToCompactFormat(num: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
@@ -63,37 +47,27 @@ export function formatNumberToCompactFormat(num: number | undefined): string {
}
export function determineIsMonotonic(
metricType: MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
temporality?: MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
metricType: MetricType,
temporality?: Temporality,
): boolean {
if (
metricType ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOType.histogram ||
metricType ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOType.exponentialhistogram
metricType === MetricType.HISTOGRAM ||
metricType === MetricType.EXPONENTIAL_HISTOGRAM
) {
return true;
}
if (
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.gauge ||
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.summary
) {
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
return false;
}
if (
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum
) {
return (
temporality ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.cumulative
);
if (metricType === MetricType.SUM) {
return temporality === Temporality.CUMULATIVE;
}
return false;
}
export function getMetricDetailsQuery(
metricName: string,
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined,
metricType: MetricType | undefined,
filter?: { key: string; value: string },
groupBy?: string,
): Query {
@@ -101,23 +75,23 @@ export function getMetricDetailsQuery(
let spaceAggregation;
let aggregateOperator;
switch (metricType) {
case MetricsexplorertypesMetricMetadataDTOType.sum:
case MetricType.SUM:
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
break;
case MetricsexplorertypesMetricMetadataDTOType.gauge:
case MetricType.GAUGE:
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
break;
case MetricsexplorertypesMetricMetadataDTOType.summary:
case MetricType.SUMMARY:
timeAggregation = 'noop';
spaceAggregation = 'sum';
aggregateOperator = 'noop';
break;
case MetricsexplorertypesMetricMetadataDTOType.histogram:
case MetricsexplorertypesMetricMetadataDTOType.exponentialhistogram:
case MetricType.HISTOGRAM:
case MetricType.EXPONENTIAL_HISTOGRAM:
timeAggregation = 'noop';
spaceAggregation = 'p90';
aggregateOperator = 'noop';
@@ -186,69 +160,3 @@ export function getMetricDetailsQuery(
},
};
}
export function transformMetricMetadata(
apiData: GetMetricMetadata200 | undefined,
): MetricMetadata | null {
if (!apiData || !apiData.data) {
return null;
}
const { type, description, unit, temporality, isMonotonic } = apiData.data;
return {
type,
description,
unit,
temporality,
isMonotonic,
};
}
export function transformUpdateMetricMetadataRequest(
metricName: string,
metricMetadata: MetricMetadataState,
): UpdateMetricMetadataMutationBody {
return {
metricName: metricName,
type: metricMetadata.type,
description: metricMetadata.description,
unit: metricMetadata.unit,
temporality:
metricMetadata.temporality ??
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified,
isMonotonic: determineIsMonotonic(
metricMetadata.type,
metricMetadata.temporality,
),
};
}
export function transformMetricType(
type: MetricsexplorertypesMetricMetadataDTOType,
): MetricsexplorertypesUpdateMetricMetadataRequestDTOType {
switch (type) {
case MetricsexplorertypesMetricMetadataDTOType.sum:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum;
case MetricsexplorertypesMetricMetadataDTOType.gauge:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.gauge;
case MetricsexplorertypesMetricMetadataDTOType.histogram:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.histogram;
case MetricsexplorertypesMetricMetadataDTOType.summary:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.summary;
case MetricsexplorertypesMetricMetadataDTOType.exponentialhistogram:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.exponentialhistogram;
}
}
export function transformTemporality(
temporality: MetricsexplorertypesMetricMetadataDTOTemporality,
): MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality {
switch (temporality) {
case MetricsexplorertypesMetricMetadataDTOTemporality.delta:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.delta;
case MetricsexplorertypesMetricMetadataDTOTemporality.cumulative:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.cumulative;
case MetricsexplorertypesMetricMetadataDTOTemporality.unspecified:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified;
}
}

View File

@@ -34,7 +34,6 @@ import {
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import './Summary.styles.scss';
@@ -129,7 +128,7 @@ function Summary(): JSX.Element {
} = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery && !isInspectModalOpen,
queryKey: [
REACT_QUERY_KEY.GET_METRICS_LIST,
'metricsList',
queryFiltersWithoutId,
orderBy,
pageSize,

View File

@@ -157,12 +157,9 @@ function ValidateRowValueWrapper({
}
export const formatNumberIntoHumanReadableFormat = (
num: number | undefined,
num: number,
addPlusSign = true,
): string => {
if (!num) {
return '-';
}
function format(num: number, divisor: number, suffix: string): string {
const value = num / divisor;
return value % 1 === 0

View File

@@ -27,8 +27,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { FilterOutlined } from '@ant-design/icons';
import { FilterOutlined, VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { Atom, Binoculars, SquareMousePointer, Terminal } from 'lucide-react';
@@ -32,6 +32,7 @@ export default function LeftToolbarActions({
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<FilterOutlined />
<VerticalAlignTopOutlined rotate={90} />
</Button>
</Tooltip>
)}

View File

@@ -7,7 +7,7 @@
align-items: center;
justify-content: center;
box-shadow: none;
width: 32px;
width: 40px;
height: 32px;
margin-right: 12px;
border: 1px solid var(--bg-slate-400);

View File

@@ -95,7 +95,6 @@ function ResourceAttributesFilter({
data-testid="resource-environment-filter"
style={{ minWidth: 200, height: 34 }}
onChange={handleEnvironmentChange}
onBlur={handleBlur}
>
{environments.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>

View File

@@ -1,8 +1,8 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { ServicesList } from 'types/api/metrics/getService';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -1,5 +1,5 @@
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
import { getMetricMetadata } from 'api/generated/services/metrics';
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';

View File

@@ -13,7 +13,7 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';

View File

@@ -3,8 +3,8 @@ import { useSelector } from 'react-redux';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -0,0 +1,77 @@
import ROUTES from 'constants/routes';
import { whilelistedKeys } from '../config';
import { mappingWithRoutesAndKeys } from '../utils';
describe('useResourceAttribute config', () => {
describe('whilelistedKeys', () => {
it('should include underscore-notation keys (DOT_METRICS_ENABLED=false)', () => {
expect(whilelistedKeys).toContain('resource_deployment_environment');
expect(whilelistedKeys).toContain('resource_k8s_cluster_name');
expect(whilelistedKeys).toContain('resource_k8s_cluster_namespace');
});
it('should include dot-notation keys (DOT_METRICS_ENABLED=true)', () => {
expect(whilelistedKeys).toContain('resource_deployment.environment');
expect(whilelistedKeys).toContain('resource_k8s.cluster.name');
expect(whilelistedKeys).toContain('resource_k8s.cluster.namespace');
});
});
describe('mappingWithRoutesAndKeys', () => {
const dotNotationFilters = [
{
label: 'deployment.environment',
value: 'resource_deployment.environment',
},
{ label: 'k8s.cluster.name', value: 'resource_k8s.cluster.name' },
{ label: 'k8s.cluster.namespace', value: 'resource_k8s.cluster.namespace' },
];
const underscoreNotationFilters = [
{
label: 'deployment.environment',
value: 'resource_deployment_environment',
},
{ label: 'k8s.cluster.name', value: 'resource_k8s_cluster_name' },
{ label: 'k8s.cluster.namespace', value: 'resource_k8s_cluster_namespace' },
];
const nonWhitelistedFilters = [
{ label: 'host.name', value: 'resource_host_name' },
{ label: 'service.name', value: 'resource_service_name' },
];
it('should keep dot-notation filters on the Service Map route', () => {
const result = mappingWithRoutesAndKeys(
ROUTES.SERVICE_MAP,
dotNotationFilters,
);
expect(result).toHaveLength(3);
expect(result).toEqual(dotNotationFilters);
});
it('should keep underscore-notation filters on the Service Map route', () => {
const result = mappingWithRoutesAndKeys(
ROUTES.SERVICE_MAP,
underscoreNotationFilters,
);
expect(result).toHaveLength(3);
expect(result).toEqual(underscoreNotationFilters);
});
it('should filter out non-whitelisted keys on the Service Map route', () => {
const allFilters = [...dotNotationFilters, ...nonWhitelistedFilters];
const result = mappingWithRoutesAndKeys(ROUTES.SERVICE_MAP, allFilters);
expect(result).toHaveLength(3);
expect(result).toEqual(dotNotationFilters);
});
it('should return all filters on non-Service Map routes', () => {
const allFilters = [...dotNotationFilters, ...nonWhitelistedFilters];
const result = mappingWithRoutesAndKeys('/services', allFilters);
expect(result).toHaveLength(5);
expect(result).toEqual(allFilters);
});
});
});

View File

@@ -1,5 +1,8 @@
export const whilelistedKeys = [
'resource_deployment_environment',
'resource_deployment.environment',
'resource_k8s_cluster_name',
'resource_k8s.cluster.name',
'resource_k8s_cluster_namespace',
'resource_k8s.cluster.namespace',
];

View File

@@ -0,0 +1,412 @@
import type { ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { AlignedData } from 'uplot';
import { PlotContextProvider } from '../../context/PlotContext';
import UPlotChart from '../UPlotChart';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
updateSeriesVisibilityToLocalStorage: jest.fn(),
}),
);
jest.mock('@sentry/react', () => ({
ErrorBoundary: ({ children }: { children: ReactNode }): JSX.Element => (
<>{children}</>
),
}));
jest.mock('pages/ErrorBoundaryFallback/ErrorBoundaryFallback', () => ({
__esModule: true,
default: (): JSX.Element => <div>Error Fallback</div>,
}));
interface MockUPlotInstance {
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
}
let instances: MockUPlotInstance[] = [];
const mockUPlotConstructor = jest.fn();
jest.mock('uplot', () => {
function MockUPlot(
opts: Record<string, unknown>,
data: unknown,
target: HTMLElement,
): MockUPlotInstance {
mockUPlotConstructor(opts, data, target);
const rootEl = document.createElement('div');
target.appendChild(rootEl);
const inst: MockUPlotInstance = {
root: rootEl,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
};
instances.push(inst);
return inst;
}
MockUPlot.paths = {
spline: jest.fn(() => jest.fn()),
bars: jest.fn(() => jest.fn()),
linear: jest.fn(() => jest.fn()),
stepped: jest.fn(() => jest.fn()),
};
MockUPlot.tzDate = jest.fn();
return { __esModule: true, default: MockUPlot };
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const createMockConfig = (): UPlotConfigBuilder => {
return ({
getConfig: jest.fn().mockReturnValue({
series: [{ value: (): string => '' }],
axes: [],
scales: {},
hooks: {},
cursor: {},
}),
getWidgetId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
} as unknown) as UPlotConfigBuilder;
};
const validData: AlignedData = [
[1, 2, 3],
[10, 20, 30],
];
const emptyData: AlignedData = [[]];
const Wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<PlotContextProvider>{children}</PlotContextProvider>
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('UPlotChart', () => {
beforeEach(() => {
instances = [];
mockUPlotConstructor.mockClear();
});
describe('when data is empty', () => {
it('displays "No Data" message instead of the chart container', () => {
render(
<UPlotChart
config={createMockConfig()}
data={emptyData}
width={600}
height={400}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.queryByTestId('uplot-main-div')).not.toBeInTheDocument();
});
it('sizes the empty-state container to the given width and height', () => {
render(
<UPlotChart
config={createMockConfig()}
data={emptyData}
width={750}
height={350}
/>,
{ wrapper: Wrapper },
);
const noDataContainer = screen
.getByText('No Data')
.closest('.uplot-no-data');
expect(noDataContainer).toHaveStyle({ width: '750px', height: '350px' });
});
it('does not create a uPlot instance', () => {
render(
<UPlotChart
config={createMockConfig()}
data={emptyData}
width={600}
height={400}
/>,
{ wrapper: Wrapper },
);
expect(mockUPlotConstructor).not.toHaveBeenCalled();
});
});
describe('chart container', () => {
it('renders children inside the chart wrapper', () => {
render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
>
<div data-testid="tooltip-plugin">Tooltip</div>
</UPlotChart>,
{ wrapper: Wrapper },
);
expect(screen.getByTestId('tooltip-plugin')).toBeInTheDocument();
});
});
describe('plot creation', () => {
it('instantiates uPlot with floored dimensions and the container element', () => {
render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600.9}
height={400.2}
/>,
{ wrapper: Wrapper },
);
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
const [opts, data, target] = mockUPlotConstructor.mock.calls[0];
expect(opts.width).toBe(600);
expect(opts.height).toBe(400);
expect(data).toBe(validData);
expect(target).toBe(screen.getByTestId('uplot-main-div'));
});
it('merges config builder output into the uPlot options', () => {
const config = createMockConfig();
config.getConfig = jest.fn().mockReturnValue({
series: [{ value: (): string => '' }],
axes: [{ scale: 'y' }],
scales: { y: {} },
hooks: {},
cursor: { show: true },
});
render(
<UPlotChart
config={(config as unknown) as UPlotConfigBuilder}
data={validData}
width={500}
height={300}
/>,
{ wrapper: Wrapper },
);
const [opts] = mockUPlotConstructor.mock.calls[0];
expect(opts.width).toBe(500);
expect(opts.height).toBe(300);
expect(opts.axes).toEqual([{ scale: 'y' }]);
expect(opts.cursor).toEqual({ show: true });
});
it('skips creation when width or height is 0', () => {
render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={0}
height={0}
/>,
{ wrapper: Wrapper },
);
expect(mockUPlotConstructor).not.toHaveBeenCalled();
});
});
describe('lifecycle callbacks', () => {
it('invokes plotRef with the uPlot instance after creation', () => {
const plotRef = jest.fn();
render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
plotRef={plotRef}
/>,
{ wrapper: Wrapper },
);
expect(plotRef).toHaveBeenCalledTimes(1);
expect(plotRef).toHaveBeenCalledWith(instances[0]);
});
it('destroys the instance and notifies callbacks when data becomes empty', () => {
const plotRef = jest.fn();
const onDestroy = jest.fn();
const config = createMockConfig();
const { rerender } = render(
<UPlotChart
config={config}
data={validData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
plotRef.mockClear();
rerender(
<UPlotChart
config={config}
data={emptyData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
);
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalled();
expect(plotRef).toHaveBeenCalledWith(null);
expect(screen.getByText('No Data')).toBeInTheDocument();
});
it('destroys the previous instance before creating a new one on config change', () => {
const onDestroy = jest.fn();
const config1 = createMockConfig();
const config2 = createMockConfig();
const { rerender } = render(
<UPlotChart
config={config1}
data={validData}
width={600}
height={400}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
rerender(
<UPlotChart
config={config2}
data={validData}
width={600}
height={400}
onDestroy={onDestroy}
/>,
);
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalled();
expect(instances).toHaveLength(2);
});
});
describe('prop updates', () => {
it('calls setData without recreating the plot when only data changes', () => {
const config = createMockConfig();
const newData: AlignedData = [
[4, 5, 6],
[40, 50, 60],
];
const { rerender } = render(
<UPlotChart config={config} data={validData} width={600} height={400} />,
{ wrapper: Wrapper },
);
const inst = instances[0];
rerender(
<UPlotChart config={config} data={newData} width={600} height={400} />,
);
expect(inst.setData).toHaveBeenCalledWith(newData);
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
});
it('calls setSize with floored values when only dimensions change', () => {
const config = createMockConfig();
const { rerender } = render(
<UPlotChart config={config} data={validData} width={600} height={400} />,
{ wrapper: Wrapper },
);
const instance = instances[0];
rerender(
<UPlotChart
config={config}
data={validData}
width={800.7}
height={500.3}
/>,
);
expect(instance.setSize).toHaveBeenCalledWith({ width: 800, height: 500 });
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
});
it('recreates the plot when config changes', () => {
const config1 = createMockConfig();
const config2 = createMockConfig();
const { rerender } = render(
<UPlotChart config={config1} data={validData} width={600} height={400} />,
{ wrapper: Wrapper },
);
rerender(
<UPlotChart config={config2} data={validData} width={600} height={400} />,
);
expect(mockUPlotConstructor).toHaveBeenCalledTimes(2);
});
it('does nothing when all props remain the same', () => {
const config = createMockConfig();
const { rerender } = render(
<UPlotChart config={config} data={validData} width={600} height={400} />,
{ wrapper: Wrapper },
);
const instance = instances[0];
rerender(
<UPlotChart config={config} data={validData} width={600} height={400} />,
);
expect(mockUPlotConstructor).toHaveBeenCalledTimes(1);
expect(instance.setData).not.toHaveBeenCalled();
expect(instance.setSize).not.toHaveBeenCalled();
});
});
});

View File

@@ -412,14 +412,16 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify dashboard state contains the variables with default values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should merge URL variables with dashboard data and normalize values correctly', async () => {
@@ -466,16 +468,26 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify the dashboard state reflects the normalized URL values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// First ensure the variables exist
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
// Then check their properties
expect(parsedVariables.environment).toHaveProperty('selectedValue');
expect(parsedVariables.services).toHaveProperty('selectedValue');
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should handle ALL_SELECTED_VALUE from URL and set allSelected correctly', async () => {
@@ -500,8 +512,8 @@ describe('Dashboard Provider - URL Variables Integration', () => {
);
// Verify that allSelected is set to true for the services variable
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.services.allSelected).toBe(true);
@@ -603,8 +615,8 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
});
// Verify that defaultValue is set from textboxValue
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
@@ -648,8 +660,8 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
});
// Verify that existing defaultValue is preserved
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
@@ -694,8 +706,8 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
});
// Verify that defaultValue is set to empty string
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
@@ -739,8 +751,8 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
});
// Verify that defaultValue is NOT set from textboxValue for QUERY type
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = screen.getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myQuery.type).toBe('QUERY');

View File

@@ -1,7 +1,7 @@
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { commaValuesParser } from '../../lib/dashbaordVariables/customCommaValuesParser';
import { commaValuesParser } from '../../lib/dashboardVariables/customCommaValuesParser';
interface UrlVariables {
[key: string]: any;

View File

@@ -1,13 +0,0 @@
export function pluralize(
count: number,
singular: string,
plural?: string,
): string {
if (count === 1) {
return `${count} ${singular}`;
}
if (plural) {
return `${count} ${plural}`;
}
return `${count} ${singular}s`;
}

View File

@@ -247,6 +247,8 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
}
}
resultData.Rows = filteredRows
case *qbtypes.ScalarData:
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
}
filteredData = append(filteredData, result)
@@ -272,6 +274,39 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
return true
}
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
// Find column indices for server address fields
serverColIndices := make([]int, 0)
for i, col := range columns {
if col.Name == serverAddressKeyLegacy || col.Name == serverAddressKey {
serverColIndices = append(serverColIndices, i)
}
}
if len(serverColIndices) == 0 {
return data
}
filtered := make([][]any, 0, len(data))
for _, row := range data {
includeRow := true
for _, colIdx := range serverColIndices {
if colIdx < len(row) {
if strVal, ok := row[colIdx].(string); ok {
if net.ParseIP(strVal) != nil {
includeRow = false
break
}
}
}
}
if includeRow {
filtered = append(filtered, row)
}
}
return filtered
}
func shouldIncludeRow(row *qbtypes.RawRow) bool {
if row.Data != nil {
for _, key := range []string{serverAddressKeyLegacy, serverAddressKey} {

View File

@@ -116,6 +116,59 @@ func TestFilterResponse(t *testing.T) {
},
},
},
{
name: "should filter out IP addresses from scalar data",
input: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "net.peer.name"},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"192.168.1.1", 10},
{"example.com", 20},
{"10.0.0.1", 5},
},
},
},
},
},
},
expected: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "net.peer.name"},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"example.com", 20},
},
},
},
},
},
},
},
}
for _, tt := range tests {

View File

@@ -208,7 +208,16 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
event.GroupByApplied = len(spec.GroupBy) > 0
if spec.Source == telemetrytypes.SourceMeter {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)) {
newStep := qbtypes.Step{
Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)),
}
spec.StepInterval = newStep
}
} else {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{

View File

@@ -830,7 +830,7 @@ func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetU
func (r *ClickHouseReader) GetSpansForTrace(ctx context.Context, traceID string, traceDetailsQuery string) ([]model.SpanItemV2, *model.ApiError) {
var traceSummary model.TraceSummary
summaryQuery := fmt.Sprintf("SELECT * from %s.%s WHERE trace_id=$1", r.TraceDB, r.traceSummaryTable)
summaryQuery := fmt.Sprintf("SELECT * from %s.%s FINAL WHERE trace_id=$1", r.TraceDB, r.traceSummaryTable)
err := r.db.QueryRow(ctx, summaryQuery, traceID).Scan(&traceSummary.TraceID, &traceSummary.Start, &traceSummary.End, &traceSummary.NumSpans)
if err != nil {
if err == sql.ErrNoRows {
@@ -6458,7 +6458,7 @@ func (r *ClickHouseReader) SearchTraces(ctx context.Context, params *model.Searc
}
var traceSummary model.TraceSummary
summaryQuery := fmt.Sprintf("SELECT * from %s.%s WHERE trace_id=$1", r.TraceDB, r.traceSummaryTable)
summaryQuery := fmt.Sprintf("SELECT * from %s.%s FINAL WHERE trace_id=$1", r.TraceDB, r.traceSummaryTable)
err := r.db.QueryRow(ctx, summaryQuery, params.TraceID).Scan(&traceSummary.TraceID, &traceSummary.Start, &traceSummary.End, &traceSummary.NumSpans)
if err != nil {
if err == sql.ErrNoRows {

View File

@@ -89,6 +89,23 @@ func RecommendedStepIntervalForMeter(start, end uint64) uint64 {
return recommended
}
func MinAllowedStepIntervalForMeter(start, end uint64) uint64 {
start = ToNanoSecs(start)
end = ToNanoSecs(end)
step := (end - start) / RecommendedNumberOfPoints / 1e9
// for meter queries the minimum step interval allowed is 1 hour as this is our granularity
if step < 3600 {
return 3600
}
// return the nearest lower multiple of 3600 ( 1 hour )
minAllowed := step - step%3600
return minAllowed
}
func RecommendedStepIntervalForMetric(start, end uint64) uint64 {
start = ToNanoSecs(start)
end = ToNanoSecs(end)